From 6df03bdbb36d0e5c887bcb12dc393104fca4057a Mon Sep 17 00:00:00 2001 From: Boden Fuller Date: Thu, 2 Apr 2026 15:32:50 -0400 Subject: [PATCH 1/2] feat(hooks): add claude factory startup routing --- README.md | 6 + cli/README.md | 12 +- cli/embedded/hooks/factory-router.sh | 193 ++++++++++++++++++++ cli/embedded/hooks/hooks.json | 5 + cli/embedded/hooks/session-start.sh | 134 +++++++++++++- cli/embedded/skills/using-agentops/SKILL.md | 1 + docs/ENV-VARS.md | 5 +- docs/contracts/hook-runtime-contract.md | 5 +- docs/how-it-works.md | 4 +- docs/workflows/session-lifecycle.md | 17 +- hooks/factory-router.sh | 193 ++++++++++++++++++++ hooks/hooks.json | 5 + hooks/session-start.sh | 134 +++++++++++++- tests/hooks/hook-output-schema.bats | 28 +++ tests/hooks/test-hook-lifecycle.sh | 1 + tests/hooks/test-hooks.bats | 90 +++++++++ tests/hooks/test-hooks.sh | 117 ++++++++++++ 17 files changed, 921 insertions(+), 29 deletions(-) create mode 100755 cli/embedded/hooks/factory-router.sh create mode 100755 hooks/factory-router.sh diff --git a/README.md b/README.md index a5810ab25..cc78e117f 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,12 @@ Or install via [release binaries](https://github.com/boshu2/agentops/releases) o Then type `/quickstart` in your agent chat. +In Claude Code, the native hook path now behaves like a software-factory +startup surface: `SessionStart` prefers a matched goal-time briefing when +handoff or tracker state already gives the runtime a goal, and when no goal is +recovered the first substantive prompt becomes intake and the plugin routes the +session toward `/rpi`. + --- ## Start Here diff --git a/cli/README.md b/cli/README.md index 0a3445021..7ef55c1e3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -18,11 +18,19 @@ ao init --hooks ao hooks test ``` -That's it. Knowledge now flows automatically between sessions. +That's it. In Claude Code, the installed hooks now provide the native factory +startup lane: `SessionStart` prefers matched knowledge briefings, and the first +substantive prompt can become intake when no startup goal exists. ## What It Does -**SessionStart**: Injects relevant prior knowledge weighted by freshness and utility. +**SessionStart**: Performs startup maintenance, then surfaces the best +available factory startup context. If a goal is already known, it prefers a +matched knowledge briefing and treats ranked learnings as supporting evidence. + +**UserPromptSubmit**: When startup lacked a goal, the first substantive prompt +is captured as factory intake, a goal-time briefing is built if possible, and +the runtime is nudged toward `/rpi`. **SessionEnd**: Extracts learnings and updates the feedback loop. diff --git a/cli/embedded/hooks/factory-router.sh b/cli/embedded/hooks/factory-router.sh new file mode 100755 index 000000000..3407e378e --- /dev/null +++ b/cli/embedded/hooks/factory-router.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# factory-router.sh - UserPromptSubmit hook: convert first substantive prompt into +# a goal-time briefing when SessionStart had no factory objective. + +[ "${AGENTOPS_HOOKS_DISABLED:-}" = "1" ] && exit 0 +[ "${AGENTOPS_FACTORY_ROUTER_DISABLED:-}" = "1" ] && exit 0 + +AO_TIMEOUT_BIN="timeout" +command -v "$AO_TIMEOUT_BIN" >/dev/null 2>&1 || AO_TIMEOUT_BIN="gtimeout" + +run_with_timeout() { + local seconds="$1" + shift + if command -v "$AO_TIMEOUT_BIN" >/dev/null 2>&1; then + "$AO_TIMEOUT_BIN" "$seconds" "$@" 2>/dev/null + return $? + fi + "$@" 2>/dev/null +} + +trim_text() { + printf '%s' "${1:-}" \ + | tr '\n' ' ' \ + | tr -s '[:space:]' ' ' \ + | sed 's/^ //; s/ $//' +} + +resolve_startup_context_mode() { + local mode + if [ "${AGENTOPS_STARTUP_LEGACY_INJECT:-}" = "1" ]; then + printf 'manual' + return 0 + fi + + mode=$(printf '%s' "${AGENTOPS_STARTUP_CONTEXT_MODE:-factory}" | tr '[:upper:]' '[:lower:]') + case "$mode" in + ""|factory) + printf 'factory' + ;; + manual|lean|legacy) + printf 'manual' + ;; + *) + printf 'factory' + ;; + esac +} + +build_factory_briefing() { + local goal="$1" + local output path + + [ -n "$goal" ] || return 0 + command -v ao >/dev/null 2>&1 || return 0 + command -v jq >/dev/null 2>&1 || return 0 + + output=$(run_with_timeout 8 ao knowledge brief --json --goal "$goal") || return 0 + [ -n "$output" ] || return 0 + + path=$(printf '%s' "$output" | jq -r '.output_path // empty' 2>/dev/null) + path=$(trim_text "$path") + [ -n "$path" ] || return 0 + [ -f "$path" ] || return 0 + printf '%s' "$path" +} + +read_factory_briefing() { + local path="$1" + local max_chars=2200 + local content trimmed + + [ -f "$path" ] || return 0 + content=$(cat "$path" 2>/dev/null || true) + [ -n "$content" ] || return 0 + + if [ "${#content}" -gt "$max_chars" ]; then + trimmed="${content:0:$max_chars}" + content="${trimmed% +*} + +*[briefing truncated by factory router]*" + fi + + printf '%s' "$content" +} + +extract_prompt() { + local input="$1" + if command -v jq >/dev/null 2>&1; then + printf '%s' "$input" | jq -r '.prompt // ""' 2>/dev/null + return 0 + fi + printf '%s' "$input" \ + | grep -o '"prompt"[[:space:]]*:[[:space:]]*"[^"]*"' \ + | head -1 \ + | sed 's/.*"prompt"[[:space:]]*:[[:space:]]*"//;s/"$//' +} + +normalize_factory_goal() { + local prompt goal + prompt="$(trim_text "$1")" + [ -n "$prompt" ] || return 0 + + case "$prompt" in + /session-start*|/session-end*|/quickstart*|/status*|/help*) + return 0 + ;; + /rpi*) + goal="$(trim_text "${prompt#/rpi}")" + goal="${goal#\"}" + goal="${goal%\"}" + prompt="$(trim_text "$goal")" + ;; + /*) + return 0 + ;; + esac + + case "$(printf '%s' "$prompt" | tr '[:upper:]' '[:lower:]')" in + ""|"help"|"status"|"where was i"|"where was i?"|"what should i work on"|"what should i work on?"|"continue"|"resume") + return 0 + ;; + esac + + if [ "${#prompt}" -lt 12 ]; then + return 0 + fi + + printf '%s' "$prompt" +} + +INPUT=$(cat) +PROMPT="$(extract_prompt "$INPUT")" +[ -n "$PROMPT" ] || exit 0 +[ "$(resolve_startup_context_mode)" = "factory" ] || exit 0 + +ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +STATE_DIR="$ROOT/.agents/ao" +INTAKE_FLAG="$STATE_DIR/.factory-intake-needed" +ROUTER_FLAG="$STATE_DIR/.factory-router-fired" +GOAL_FILE="$STATE_DIR/factory-goal.txt" +BRIEFING_FILE="$STATE_DIR/factory-briefing.txt" +mkdir -p "$STATE_DIR" 2>/dev/null + +[ -f "$INTAKE_FLAG" ] || exit 0 +[ ! -s "$GOAL_FILE" ] || exit 0 + +if [ -f "$ROUTER_FLAG" ] && find "$ROUTER_FLAG" -mmin -30 2>/dev/null | grep -q .; then + exit 0 +fi + +GOAL="$(normalize_factory_goal "$PROMPT")" +[ -n "$GOAL" ] || exit 0 + +BRIEFING_PATH="$(build_factory_briefing "$GOAL")" +printf '%s' "$GOAL" > "$GOAL_FILE" 2>/dev/null || true +rm -f "$INTAKE_FLAG" 2>/dev/null || true +printf '%s\n' "${CLAUDE_SESSION_ID:-unknown}" > "$ROUTER_FLAG" 2>/dev/null || true + +MESSAGE="Factory intake captured from the first substantive prompt. +- **Goal:** ${GOAL}" + +if [ -n "$BRIEFING_PATH" ]; then + printf '%s' "$BRIEFING_PATH" > "$BRIEFING_FILE" 2>/dev/null || true + BRIEFING_CONTENT="$(read_factory_briefing "$BRIEFING_PATH")" + MESSAGE="${MESSAGE} +- **Primary briefing:** ${BRIEFING_PATH} +- **Delivery lane:** Continue naturally, or run \`/rpi \"${GOAL}\"\`. + +Treat the briefing below as the primary dynamic surface for this session. + + +${BRIEFING_CONTENT} +" +else + rm -f "$BRIEFING_FILE" 2>/dev/null || true + MESSAGE="${MESSAGE} +- **Primary briefing:** not available yet +- **Delivery lane:** Run \`/rpi \"${GOAL}\"\` or continue with this objective. + +No matched knowledge briefing was available for the intake goal, so the captured goal above is the active factory objective." +fi + +if command -v jq >/dev/null 2>&1; then + jq -n --arg msg "$MESSAGE" '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":$msg}}' +else + safe_msg=${MESSAGE//\\/\\\\} + safe_msg=${safe_msg//\"/\\\"} + safe_msg=$(printf '%s' "$safe_msg" | tr '\n' ' ') + echo "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"$safe_msg\"}}" +fi + +exit 0 diff --git a/cli/embedded/hooks/hooks.json b/cli/embedded/hooks/hooks.json index f7cbfb92b..2a8c8f088 100644 --- a/cli/embedded/hooks/hooks.json +++ b/cli/embedded/hooks/hooks.json @@ -41,6 +41,11 @@ "UserPromptSubmit": [ { "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/factory-router.sh", + "timeout": 8 + }, { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/prompt-nudge.sh", diff --git a/cli/embedded/hooks/session-start.sh b/cli/embedded/hooks/session-start.sh index a9abfbd7d..f47a97dec 100755 --- a/cli/embedded/hooks/session-start.sh +++ b/cli/embedded/hooks/session-start.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash -# AgentOps Session Start Hook (manual mode) -# Creates .agents/ directories, consumes handoffs, injects skill context. -# MEMORY.md is auto-loaded by Claude Code — no extract/inject needed. +# AgentOps Session Start Hook +# Creates .agents/ directories, consumes handoffs, and surfaces startup context. +# In default factory mode, SessionStart prefers a matched knowledge briefing when +# a goal is available; manual mode keeps a lighter startup surface. # Kill switches [ "${AGENTOPS_HOOKS_DISABLED:-}" = "1" ] && exit 0 @@ -20,12 +21,24 @@ ROOT="$(cd "$ROOT" 2>/dev/null && pwd -P 2>/dev/null || printf '%s' "$ROOT")" AO_DIR="$ROOT/.agents/ao" HOOK_ERROR_LOG="$AO_DIR/hook-errors.log" +AO_TIMEOUT_BIN="timeout" +command -v "$AO_TIMEOUT_BIN" >/dev/null 2>&1 || AO_TIMEOUT_BIN="gtimeout" log_hook_fail() { mkdir -p "$AO_DIR" 2>/dev/null || return 0 echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) HOOK_FAIL: $1" >> "$HOOK_ERROR_LOG" 2>/dev/null || true } +run_with_timeout() { + local seconds="$1" + shift + if command -v "$AO_TIMEOUT_BIN" >/dev/null 2>&1; then + "$AO_TIMEOUT_BIN" "$seconds" "$@" 2>/dev/null + return $? + fi + "$@" 2>/dev/null +} + trim_lookup_text() { printf '%s' "${1:-}" \ | tr '\n' ' ' \ @@ -33,6 +46,27 @@ trim_lookup_text() { | sed 's/^ //; s/ $//' } +resolve_startup_context_mode() { + local mode + if [ "${AGENTOPS_STARTUP_LEGACY_INJECT:-}" = "1" ]; then + printf 'manual' + return 0 + fi + + mode=$(printf '%s' "${AGENTOPS_STARTUP_CONTEXT_MODE:-factory}" | tr '[:upper:]' '[:lower:]') + case "$mode" in + ""|factory) + printf 'factory' + ;; + manual|lean|legacy) + printf 'manual' + ;; + *) + printf 'factory' + ;; + esac +} + derive_lookup_query() { if [ -n "${AGENTOPS_SESSION_LOOKUP_QUERY:-}" ]; then trim_lookup_text "$AGENTOPS_SESSION_LOOKUP_QUERY" @@ -53,7 +87,45 @@ detect_lookup_bead() { if ! command -v bd >/dev/null 2>&1; then return 0 fi - timeout 1 bd current 2>/dev/null | head -1 | tr -d '\r' | sed 's/^ //; s/ $//' + run_with_timeout 1 bd current | head -1 | tr -d '\r' | sed 's/^ //; s/ $//' +} + +build_factory_briefing() { + local goal="$1" + local output path + + [ -n "$goal" ] || return 0 + command -v ao >/dev/null 2>&1 || return 0 + command -v jq >/dev/null 2>&1 || return 0 + + output=$(run_with_timeout 8 ao knowledge brief --json --goal "$goal") || return 0 + [ -n "$output" ] || return 0 + + path=$(printf '%s' "$output" | jq -r '.output_path // empty' 2>/dev/null) + path=$(trim_lookup_text "$path") + [ -n "$path" ] || return 0 + [ -f "$path" ] || return 0 + printf '%s' "$path" +} + +read_factory_briefing() { + local path="$1" + local max_chars=2200 + local content trimmed + + [ -f "$path" ] || return 0 + content=$(cat "$path" 2>/dev/null || true) + [ -n "$content" ] || return 0 + + if [ "${#content}" -gt "$max_chars" ]; then + trimmed="${content:0:$max_chars}" + content="${trimmed% +*} + +*[briefing truncated by session-start hook]*" + fi + + printf '%s' "$content" } write_environment_manifest() { @@ -130,6 +202,10 @@ write_environment_manifest # Clear stale dedup flags from prior sessions (prevents cross-session suppression) rm -f "$ROOT/.agents/ao/.intent-echo-fired" 2>/dev/null +rm -f "$ROOT/.agents/ao/.factory-router-fired" \ + "$ROOT/.agents/ao/.factory-intake-needed" \ + "$ROOT/.agents/ao/factory-goal.txt" \ + "$ROOT/.agents/ao/factory-briefing.txt" 2>/dev/null # Auto-cleanup stale RPI runs (lightweight, <1s, dry-run only) if command -v ao &>/dev/null; then @@ -226,6 +302,10 @@ fi # Flywheel behavior INJECTED_KNOWLEDGE="" +STARTUP_CONTEXT_MODE="$(resolve_startup_context_mode)" +FACTORY_GOAL="" +FACTORY_BRIEFING_PATH="" +FACTORY_BRIEFING_CONTENT="" # Derive MEMORY.md path once (used for stale nudge) MEMORY_DIR="$HOME/.claude/projects" @@ -280,7 +360,7 @@ if [ -z "$PREDECESSOR_FILE" ] && [ -d "$ROOT/.agents/handoff" ]; then PREDECESSOR_FILE=$(ls -t "$ROOT/.agents/handoff/"*.md 2>/dev/null | head -1) fi -# Build injection context (manual mode — MEMORY.md auto-loaded by Claude Code) +# Build injection context (MEMORY.md is auto-loaded by Claude Code) INJECTED_KNOWLEDGE="MEMORY.md is auto-loaded by Claude Code for this project. For on-demand retrieval: \`ao search \"\"\` or \`ao lookup --query \"\"\`" if [ -n "$HANDOFF_CONTEXT" ]; then @@ -306,6 +386,46 @@ Knowledge artifacts are in \`.agents/\` (if populated). Use \`ao lookup --query \"topic\"\` for on-demand learnings retrieval." fi +if [ "$STARTUP_CONTEXT_MODE" = "factory" ]; then + FACTORY_GOAL="$(derive_lookup_query)" + if [ -n "$FACTORY_GOAL" ]; then + printf '%s' "$FACTORY_GOAL" > "$ROOT/.agents/ao/factory-goal.txt" 2>/dev/null || true + FACTORY_BRIEFING_PATH="$(build_factory_briefing "$FACTORY_GOAL")" + if [ -n "$FACTORY_BRIEFING_PATH" ]; then + printf '%s' "$FACTORY_BRIEFING_PATH" > "$ROOT/.agents/ao/factory-briefing.txt" 2>/dev/null || true + FACTORY_BRIEFING_CONTENT="$(read_factory_briefing "$FACTORY_BRIEFING_PATH")" + INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} + +### Factory Startup Surface +- **Goal:** ${FACTORY_GOAL} +- **Primary briefing:** ${FACTORY_BRIEFING_PATH} +- **Delivery lane:** Continue naturally, or run \`/rpi \"${FACTORY_GOAL}\"\` for the full factory cycle. + +Treat the matched briefing below as the primary dynamic startup surface for this session. Use ranked learnings only as supporting evidence. + + +${FACTORY_BRIEFING_CONTENT} +" + else + INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} + +### Factory Startup Surface +- **Goal:** ${FACTORY_GOAL} +- **Primary briefing:** not available yet +- **Delivery lane:** Run \`/rpi \"${FACTORY_GOAL}\"\` or continue with this objective. + +No matched knowledge briefing was available at session start. Treat the goal above as the active factory objective; supporting learnings below are advisory rather than the primary brief." + fi + else + : > "$ROOT/.agents/ao/.factory-intake-needed" 2>/dev/null || true + INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} + +### Factory Startup Surface +No startup goal was recovered from handoff or tracker state. +Treat the first substantive user prompt as factory intake: compile a goal-time briefing, then route into \`/rpi\`." + fi +fi + # Auto-retrieve and cite top learnings for this session (closes citation gap) if command -v ao &>/dev/null; then LOOKUP_QUERY="$(derive_lookup_query)" @@ -327,14 +447,14 @@ if command -v ao &>/dev/null; then LOOKUP_CONTEXT="task context unavailable" fi if [ -n "$LOOKUP_QUERY" ] || [ -n "$LOOKUP_BEAD" ]; then - FLYWHEEL_KNOWLEDGE=$(timeout 5 ao "${LOOKUP_ARGS[@]}" 2>/dev/null || true) + FLYWHEEL_KNOWLEDGE=$(run_with_timeout 5 ao "${LOOKUP_ARGS[@]}" || true) else FLYWHEEL_KNOWLEDGE="" fi if [ -n "$FLYWHEEL_KNOWLEDGE" ]; then INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} -### Prior Learnings (auto-retrieved: ${LOOKUP_CONTEXT}) +### Supporting Learnings (auto-retrieved: ${LOOKUP_CONTEXT}) ${FLYWHEEL_KNOWLEDGE}" fi fi diff --git a/cli/embedded/skills/using-agentops/SKILL.md b/cli/embedded/skills/using-agentops/SKILL.md index 708a6e3b0..644f1f7b9 100644 --- a/cli/embedded/skills/using-agentops/SKILL.md +++ b/cli/embedded/skills/using-agentops/SKILL.md @@ -145,6 +145,7 @@ These are the skills every user needs first. Everything else is available when y | `/security` | Continuous repository security scanning and release gating | | `/security-suite` | Binary and prompt-surface security suite — static analysis, dynamic tracing, offline redteam, policy gating | | `/test` | Test generation, coverage analysis, and TDD workflow | +| `/red-team` | Persona-based adversarial validation — probe docs and skills from constrained user perspectives | | `/review` | Review incoming PRs, agent output, or diffs — SCORED checklist | | `/refactor` | Safe, verified refactoring with regression testing at each step | | `/deps` | Dependency audit, update, vulnerability scanning, and license compliance | diff --git a/docs/ENV-VARS.md b/docs/ENV-VARS.md index c09a4bd81..3216bfe77 100644 --- a/docs/ENV-VARS.md +++ b/docs/ENV-VARS.md @@ -59,8 +59,9 @@ These control the optional hook system installed via `ao init --hooks`. Each hoo |----------|---------|-------------| | `AGENTOPS_HOOKS_DISABLED` | `0` | Set to `1` to disable **all** hooks at once. Global kill switch. Useful for debugging or when hooks interfere with a specific workflow. | | `AGENTOPS_SESSION_START_DISABLED` | `0` | Set to `1` to disable the session-start hook entirely. | -| `AGENTOPS_STARTUP_CONTEXT_MODE` | `manual` | Controls SessionStart behavior: `manual` (default — MEMORY.md auto-loaded, no extract/inject), `lean` (extract + inject with reduced budget), `legacy` (extract + inject with full budget). | -| `AGENTOPS_STARTUP_LEGACY_INJECT` | (unset) | Set to `1` to force `lean` startup mode regardless of `AGENTOPS_STARTUP_CONTEXT_MODE`. Backward compatibility rollback if `manual` default causes issues. | +| `AGENTOPS_STARTUP_CONTEXT_MODE` | `factory` | Controls SessionStart behavior: `factory` (default — prefer matched goal-time briefings, keep ranked learnings as supporting context, and mark missing-goal sessions for prompt-time intake) or `manual` (lighter startup surface without factory briefing/routing). Legacy values `lean` and `legacy` are treated as `manual` for compatibility. | +| `AGENTOPS_STARTUP_LEGACY_INJECT` | (unset) | Compatibility alias that forces `manual` startup mode regardless of `AGENTOPS_STARTUP_CONTEXT_MODE`. Use only as a rollback for older startup expectations. | +| `AGENTOPS_FACTORY_ROUTER_DISABLED` | `0` | Set to `1` to disable the `UserPromptSubmit` factory intake hook that captures the first substantive prompt and routes the session toward `/rpi` when startup had no goal. | | `AGENTOPS_MEMORY_SYNC` | `0` | Set to `1` to enable repo-root MEMORY.md sync at SessionEnd. Writes session history with managed block markers for cross-runtime access (Codex, OpenCode). | | `AGENTOPS_EVICTION_DISABLED` | `0` | Set to `1` to disable knowledge eviction. Eviction removes stale learnings that have decayed below the retention threshold. Disable if you want to keep all learnings indefinitely. | | `AGENTOPS_GITIGNORE_AUTO` | `1` | Set to `0` to prevent the session-start hook from auto-adding `.agents/` to `.gitignore`. Useful if you want to commit knowledge artifacts to your repo. | diff --git a/docs/contracts/hook-runtime-contract.md b/docs/contracts/hook-runtime-contract.md index 313e01ecc..f4f82366c 100644 --- a/docs/contracts/hook-runtime-contract.md +++ b/docs/contracts/hook-runtime-contract.md @@ -17,7 +17,7 @@ | Claude Event | Codex Equivalent | Mechanism | |-------------|-----------------|-----------| -| `SessionStart` | `ao codex start` / `ao codex ensure-start` | Explicit command in skill preamble or user invocation | +| `SessionStart` | `ao codex start` / `ao codex ensure-start` | Claude native hooks can recover a goal and surface a matched briefing automatically; Codex uses the explicit startup command path | | `SessionEnd` | `ao codex stop` / `ao codex ensure-stop` | Explicit command in skill epilogue or user invocation | | `Stop` | `ao codex stop --close-loop` | Explicit flywheel close via command | @@ -25,7 +25,7 @@ | Claude Event | Codex Adaptation | Notes | |-------------|-----------------|-------| -| `UserPromptSubmit` | Skill preamble checks | Prompt nudges and intent echo run as inline skill logic, not hook callbacks | +| `UserPromptSubmit` | Skill preamble checks | Claude native hooks can capture first-prompt intake, emit prompt nudges, and run intent echo; Codex keeps equivalent guidance inside skill/runtime preambles instead of callbacks | | `TaskCompleted` | Skill epilogue validation | Task validation gates run inline after task completion | ### Unsupported (no Codex equivalent) @@ -46,6 +46,7 @@ ao hook install # Merges hooks.json into ~/.claude/settings.json - Full event manifest from `hooks/hooks.json` - `${CLAUDE_PLUGIN_ROOT}` path resolution - All events wired automatically +- Native startup surface: `SessionStart` prefers matched goal-time briefings, and `UserPromptSubmit` can turn the first substantive prompt into factory intake when startup lacked a goal ### Codex diff --git a/docs/how-it-works.md b/docs/how-it-works.md index a54a917a1..1be33fe00 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -108,7 +108,7 @@ The active runtime manifest currently declares **7 hook event sections** in `hoo | Hook surface | Trigger | What it does | Gap closed | |--------------|---------|--------------|------------| -| Session start | `SessionStart` | Runs `session-start.sh` — startup maintenance, lightweight retrieval, and continuity hints | Durable learning (retrieval) | +| Session start | `SessionStart` | Runs `session-start.sh` — startup maintenance, handoff recovery, matched briefing selection, and supporting retrieval | Durable learning (retrieval) | | Session end maintenance | `SessionEnd` | Runs `session-end-maintenance.sh` (transcript mining, maturity management) and `athena-session-defrag.sh` (knowledge deduplication and defrag) | Durable learning (extraction), Loop closure | | Flywheel close | `Stop` | Runs `ao-flywheel-close.sh` — closes the feedback loop via `ao flywheel close-loop` | Loop closure | @@ -116,7 +116,7 @@ The active runtime manifest currently declares **7 hook event sections** in `hoo | Hook surface | Trigger | What it does | Gap closed | |--------------|---------|--------------|------------| -| Prompt guidance | `UserPromptSubmit` | Runs `prompt-nudge.sh` (nudges missing intent and ratchet status) and `intent-echo.sh` (confirms task understanding) | Judgment validation | +| Prompt guidance | `UserPromptSubmit` | Runs `factory-router.sh` (captures first-goal intake when startup had none), `prompt-nudge.sh` (ratchet nudges), and `intent-echo.sh` (confirms high-stakes intent) | Judgment validation | | Pre-tool gates | `PreToolUse` | `pre-mortem-gate.sh` (blocks `/crank` without plan review), `commit-review-gate.sh` (pre-commit checks), `go-test-precommit.sh`, `git-worker-guard.sh` (worker isolation), `edit-knowledge-surface.sh`, `codex-parity-warn.sh` | Judgment validation | | Post-tool checks | `PostToolUse` | `write-time-quality.sh` (edit quality), `go-complexity-precommit.sh`, `go-vet-post-edit.sh`, `research-loop-detector.sh` (detects stalled loops), `context-monitor.sh` | Judgment validation, Loop closure | | Task completion gate | `TaskCompleted` | Runs `task-validation-gate.sh` — executes compiled constraints from `.agents/constraints/index.json` before accepting task completion | Judgment validation, Loop closure | diff --git a/docs/workflows/session-lifecycle.md b/docs/workflows/session-lifecycle.md index 76c9b0e01..7f0e48fe7 100644 --- a/docs/workflows/session-lifecycle.md +++ b/docs/workflows/session-lifecycle.md @@ -14,8 +14,8 @@ Just describe what you want: | Say This | What Happens | |----------|--------------| -| "Continue working on X" | Loads context, shows progress, finds next task when runtime hooks are available | -| "I need to add Y" | Checks for plan, guides you if needed | +| "Continue working on X" | `SessionStart` prefers a matched goal-time briefing, then loads supporting context and progress | +| "I need to add Y" | If no startup goal exists yet, the first substantive prompt becomes factory intake and the runtime routes you toward `/rpi` | | "I'm done for today" | Saves progress, offers retrospective | | "What should I work on?" | Shows status, suggests next task | | "Where was I?" | Shows last session, current state, blockers | @@ -62,7 +62,7 @@ with `ao codex ensure-stop`. | Mode | Start | Closeout | Notes | |------|-------|----------|-------| -| Hook-capable | Natural language, `/session-start`, or startup hooks | Natural language, `/session-end`, or session-end hooks | Best fit for Claude/OpenCode when hooks are installed | +| Hook-capable | Natural language, `/session-start`, or startup hooks | Natural language, `/session-end`, or session-end hooks | Best fit for Claude/OpenCode when hooks are installed; startup prefers matched briefings and prompt-time intake can route into `/rpi` | | Codex hookless fallback | `ao codex start` or skill-driven `ao codex ensure-start` | `ao codex stop` or skill-driven `ao codex ensure-stop` | No startup/session-end hook surface under `~/.codex`; lifecycle is explicit | | Manual fallback | `ao inject`, `ao lookup` | `ao forge transcript`, `ao flywheel close-loop` | Lowest-level portable path | @@ -75,10 +75,10 @@ with `ao codex ensure-stop`. │ SESSION START │ │ "Continue the API work" or /session-start │ ├─────────────────────────────────────────────────────────────┤ -│ • Load bundle (if exists) │ -│ • Read claude-progress.json │ -│ • Show: last session, current work, next steps │ -│ • Suggest: next feature to work on │ +│ • Recover handoff / tracker goal when available │ +│ • Prefer a matched goal-time briefing │ +│ • Add ranked learnings as supporting context │ +│ • Mark missing-goal sessions for prompt-time intake │ └─────────────────────────────────────────────────────────────┘ │ ▼ @@ -86,6 +86,8 @@ with `ao codex ensure-stop`. │ WORK │ │ "Add the validation layer" or just start coding │ ├─────────────────────────────────────────────────────────────┤ +│ • First substantive prompt can become factory intake │ +│ • Native hook path can build a briefing and route to /rpi │ │ • Implement features │ │ • Fix bugs │ │ • Research when needed │ @@ -285,6 +287,7 @@ Next time, just say "continue the caching work" and I'll pick up where you left | `ao codex start` | Start an explicit Codex hookless session | Start of work in Codex | | `ao codex stop` | Close out a Codex session without runtime hooks | End of work in Codex | | `ao codex status` | Inspect Codex lifecycle and flywheel health | Any time in Codex | +| Natural-language goal | Native factory intake when hooks are installed | Preferred operator entrypoint in Claude/OpenCode | | `/session-start` | Initialize session, load context | Start of work in hook-capable runtimes | | `/session-end` | Save progress, close gracefully | End of work in hook-capable runtimes | | `/progress-update` | Update progress files | During work | diff --git a/hooks/factory-router.sh b/hooks/factory-router.sh new file mode 100755 index 000000000..3407e378e --- /dev/null +++ b/hooks/factory-router.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# factory-router.sh - UserPromptSubmit hook: convert first substantive prompt into +# a goal-time briefing when SessionStart had no factory objective. + +[ "${AGENTOPS_HOOKS_DISABLED:-}" = "1" ] && exit 0 +[ "${AGENTOPS_FACTORY_ROUTER_DISABLED:-}" = "1" ] && exit 0 + +AO_TIMEOUT_BIN="timeout" +command -v "$AO_TIMEOUT_BIN" >/dev/null 2>&1 || AO_TIMEOUT_BIN="gtimeout" + +run_with_timeout() { + local seconds="$1" + shift + if command -v "$AO_TIMEOUT_BIN" >/dev/null 2>&1; then + "$AO_TIMEOUT_BIN" "$seconds" "$@" 2>/dev/null + return $? + fi + "$@" 2>/dev/null +} + +trim_text() { + printf '%s' "${1:-}" \ + | tr '\n' ' ' \ + | tr -s '[:space:]' ' ' \ + | sed 's/^ //; s/ $//' +} + +resolve_startup_context_mode() { + local mode + if [ "${AGENTOPS_STARTUP_LEGACY_INJECT:-}" = "1" ]; then + printf 'manual' + return 0 + fi + + mode=$(printf '%s' "${AGENTOPS_STARTUP_CONTEXT_MODE:-factory}" | tr '[:upper:]' '[:lower:]') + case "$mode" in + ""|factory) + printf 'factory' + ;; + manual|lean|legacy) + printf 'manual' + ;; + *) + printf 'factory' + ;; + esac +} + +build_factory_briefing() { + local goal="$1" + local output path + + [ -n "$goal" ] || return 0 + command -v ao >/dev/null 2>&1 || return 0 + command -v jq >/dev/null 2>&1 || return 0 + + output=$(run_with_timeout 8 ao knowledge brief --json --goal "$goal") || return 0 + [ -n "$output" ] || return 0 + + path=$(printf '%s' "$output" | jq -r '.output_path // empty' 2>/dev/null) + path=$(trim_text "$path") + [ -n "$path" ] || return 0 + [ -f "$path" ] || return 0 + printf '%s' "$path" +} + +read_factory_briefing() { + local path="$1" + local max_chars=2200 + local content trimmed + + [ -f "$path" ] || return 0 + content=$(cat "$path" 2>/dev/null || true) + [ -n "$content" ] || return 0 + + if [ "${#content}" -gt "$max_chars" ]; then + trimmed="${content:0:$max_chars}" + content="${trimmed% +*} + +*[briefing truncated by factory router]*" + fi + + printf '%s' "$content" +} + +extract_prompt() { + local input="$1" + if command -v jq >/dev/null 2>&1; then + printf '%s' "$input" | jq -r '.prompt // ""' 2>/dev/null + return 0 + fi + printf '%s' "$input" \ + | grep -o '"prompt"[[:space:]]*:[[:space:]]*"[^"]*"' \ + | head -1 \ + | sed 's/.*"prompt"[[:space:]]*:[[:space:]]*"//;s/"$//' +} + +normalize_factory_goal() { + local prompt goal + prompt="$(trim_text "$1")" + [ -n "$prompt" ] || return 0 + + case "$prompt" in + /session-start*|/session-end*|/quickstart*|/status*|/help*) + return 0 + ;; + /rpi*) + goal="$(trim_text "${prompt#/rpi}")" + goal="${goal#\"}" + goal="${goal%\"}" + prompt="$(trim_text "$goal")" + ;; + /*) + return 0 + ;; + esac + + case "$(printf '%s' "$prompt" | tr '[:upper:]' '[:lower:]')" in + ""|"help"|"status"|"where was i"|"where was i?"|"what should i work on"|"what should i work on?"|"continue"|"resume") + return 0 + ;; + esac + + if [ "${#prompt}" -lt 12 ]; then + return 0 + fi + + printf '%s' "$prompt" +} + +INPUT=$(cat) +PROMPT="$(extract_prompt "$INPUT")" +[ -n "$PROMPT" ] || exit 0 +[ "$(resolve_startup_context_mode)" = "factory" ] || exit 0 + +ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +STATE_DIR="$ROOT/.agents/ao" +INTAKE_FLAG="$STATE_DIR/.factory-intake-needed" +ROUTER_FLAG="$STATE_DIR/.factory-router-fired" +GOAL_FILE="$STATE_DIR/factory-goal.txt" +BRIEFING_FILE="$STATE_DIR/factory-briefing.txt" +mkdir -p "$STATE_DIR" 2>/dev/null + +[ -f "$INTAKE_FLAG" ] || exit 0 +[ ! -s "$GOAL_FILE" ] || exit 0 + +if [ -f "$ROUTER_FLAG" ] && find "$ROUTER_FLAG" -mmin -30 2>/dev/null | grep -q .; then + exit 0 +fi + +GOAL="$(normalize_factory_goal "$PROMPT")" +[ -n "$GOAL" ] || exit 0 + +BRIEFING_PATH="$(build_factory_briefing "$GOAL")" +printf '%s' "$GOAL" > "$GOAL_FILE" 2>/dev/null || true +rm -f "$INTAKE_FLAG" 2>/dev/null || true +printf '%s\n' "${CLAUDE_SESSION_ID:-unknown}" > "$ROUTER_FLAG" 2>/dev/null || true + +MESSAGE="Factory intake captured from the first substantive prompt. +- **Goal:** ${GOAL}" + +if [ -n "$BRIEFING_PATH" ]; then + printf '%s' "$BRIEFING_PATH" > "$BRIEFING_FILE" 2>/dev/null || true + BRIEFING_CONTENT="$(read_factory_briefing "$BRIEFING_PATH")" + MESSAGE="${MESSAGE} +- **Primary briefing:** ${BRIEFING_PATH} +- **Delivery lane:** Continue naturally, or run \`/rpi \"${GOAL}\"\`. + +Treat the briefing below as the primary dynamic surface for this session. + + +${BRIEFING_CONTENT} +" +else + rm -f "$BRIEFING_FILE" 2>/dev/null || true + MESSAGE="${MESSAGE} +- **Primary briefing:** not available yet +- **Delivery lane:** Run \`/rpi \"${GOAL}\"\` or continue with this objective. + +No matched knowledge briefing was available for the intake goal, so the captured goal above is the active factory objective." +fi + +if command -v jq >/dev/null 2>&1; then + jq -n --arg msg "$MESSAGE" '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":$msg}}' +else + safe_msg=${MESSAGE//\\/\\\\} + safe_msg=${safe_msg//\"/\\\"} + safe_msg=$(printf '%s' "$safe_msg" | tr '\n' ' ') + echo "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"$safe_msg\"}}" +fi + +exit 0 diff --git a/hooks/hooks.json b/hooks/hooks.json index f7cbfb92b..2a8c8f088 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -41,6 +41,11 @@ "UserPromptSubmit": [ { "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/factory-router.sh", + "timeout": 8 + }, { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/prompt-nudge.sh", diff --git a/hooks/session-start.sh b/hooks/session-start.sh index a9abfbd7d..f47a97dec 100755 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash -# AgentOps Session Start Hook (manual mode) -# Creates .agents/ directories, consumes handoffs, injects skill context. -# MEMORY.md is auto-loaded by Claude Code — no extract/inject needed. +# AgentOps Session Start Hook +# Creates .agents/ directories, consumes handoffs, and surfaces startup context. +# In default factory mode, SessionStart prefers a matched knowledge briefing when +# a goal is available; manual mode keeps a lighter startup surface. # Kill switches [ "${AGENTOPS_HOOKS_DISABLED:-}" = "1" ] && exit 0 @@ -20,12 +21,24 @@ ROOT="$(cd "$ROOT" 2>/dev/null && pwd -P 2>/dev/null || printf '%s' "$ROOT")" AO_DIR="$ROOT/.agents/ao" HOOK_ERROR_LOG="$AO_DIR/hook-errors.log" +AO_TIMEOUT_BIN="timeout" +command -v "$AO_TIMEOUT_BIN" >/dev/null 2>&1 || AO_TIMEOUT_BIN="gtimeout" log_hook_fail() { mkdir -p "$AO_DIR" 2>/dev/null || return 0 echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) HOOK_FAIL: $1" >> "$HOOK_ERROR_LOG" 2>/dev/null || true } +run_with_timeout() { + local seconds="$1" + shift + if command -v "$AO_TIMEOUT_BIN" >/dev/null 2>&1; then + "$AO_TIMEOUT_BIN" "$seconds" "$@" 2>/dev/null + return $? + fi + "$@" 2>/dev/null +} + trim_lookup_text() { printf '%s' "${1:-}" \ | tr '\n' ' ' \ @@ -33,6 +46,27 @@ trim_lookup_text() { | sed 's/^ //; s/ $//' } +resolve_startup_context_mode() { + local mode + if [ "${AGENTOPS_STARTUP_LEGACY_INJECT:-}" = "1" ]; then + printf 'manual' + return 0 + fi + + mode=$(printf '%s' "${AGENTOPS_STARTUP_CONTEXT_MODE:-factory}" | tr '[:upper:]' '[:lower:]') + case "$mode" in + ""|factory) + printf 'factory' + ;; + manual|lean|legacy) + printf 'manual' + ;; + *) + printf 'factory' + ;; + esac +} + derive_lookup_query() { if [ -n "${AGENTOPS_SESSION_LOOKUP_QUERY:-}" ]; then trim_lookup_text "$AGENTOPS_SESSION_LOOKUP_QUERY" @@ -53,7 +87,45 @@ detect_lookup_bead() { if ! command -v bd >/dev/null 2>&1; then return 0 fi - timeout 1 bd current 2>/dev/null | head -1 | tr -d '\r' | sed 's/^ //; s/ $//' + run_with_timeout 1 bd current | head -1 | tr -d '\r' | sed 's/^ //; s/ $//' +} + +build_factory_briefing() { + local goal="$1" + local output path + + [ -n "$goal" ] || return 0 + command -v ao >/dev/null 2>&1 || return 0 + command -v jq >/dev/null 2>&1 || return 0 + + output=$(run_with_timeout 8 ao knowledge brief --json --goal "$goal") || return 0 + [ -n "$output" ] || return 0 + + path=$(printf '%s' "$output" | jq -r '.output_path // empty' 2>/dev/null) + path=$(trim_lookup_text "$path") + [ -n "$path" ] || return 0 + [ -f "$path" ] || return 0 + printf '%s' "$path" +} + +read_factory_briefing() { + local path="$1" + local max_chars=2200 + local content trimmed + + [ -f "$path" ] || return 0 + content=$(cat "$path" 2>/dev/null || true) + [ -n "$content" ] || return 0 + + if [ "${#content}" -gt "$max_chars" ]; then + trimmed="${content:0:$max_chars}" + content="${trimmed% +*} + +*[briefing truncated by session-start hook]*" + fi + + printf '%s' "$content" } write_environment_manifest() { @@ -130,6 +202,10 @@ write_environment_manifest # Clear stale dedup flags from prior sessions (prevents cross-session suppression) rm -f "$ROOT/.agents/ao/.intent-echo-fired" 2>/dev/null +rm -f "$ROOT/.agents/ao/.factory-router-fired" \ + "$ROOT/.agents/ao/.factory-intake-needed" \ + "$ROOT/.agents/ao/factory-goal.txt" \ + "$ROOT/.agents/ao/factory-briefing.txt" 2>/dev/null # Auto-cleanup stale RPI runs (lightweight, <1s, dry-run only) if command -v ao &>/dev/null; then @@ -226,6 +302,10 @@ fi # Flywheel behavior INJECTED_KNOWLEDGE="" +STARTUP_CONTEXT_MODE="$(resolve_startup_context_mode)" +FACTORY_GOAL="" +FACTORY_BRIEFING_PATH="" +FACTORY_BRIEFING_CONTENT="" # Derive MEMORY.md path once (used for stale nudge) MEMORY_DIR="$HOME/.claude/projects" @@ -280,7 +360,7 @@ if [ -z "$PREDECESSOR_FILE" ] && [ -d "$ROOT/.agents/handoff" ]; then PREDECESSOR_FILE=$(ls -t "$ROOT/.agents/handoff/"*.md 2>/dev/null | head -1) fi -# Build injection context (manual mode — MEMORY.md auto-loaded by Claude Code) +# Build injection context (MEMORY.md is auto-loaded by Claude Code) INJECTED_KNOWLEDGE="MEMORY.md is auto-loaded by Claude Code for this project. For on-demand retrieval: \`ao search \"\"\` or \`ao lookup --query \"\"\`" if [ -n "$HANDOFF_CONTEXT" ]; then @@ -306,6 +386,46 @@ Knowledge artifacts are in \`.agents/\` (if populated). Use \`ao lookup --query \"topic\"\` for on-demand learnings retrieval." fi +if [ "$STARTUP_CONTEXT_MODE" = "factory" ]; then + FACTORY_GOAL="$(derive_lookup_query)" + if [ -n "$FACTORY_GOAL" ]; then + printf '%s' "$FACTORY_GOAL" > "$ROOT/.agents/ao/factory-goal.txt" 2>/dev/null || true + FACTORY_BRIEFING_PATH="$(build_factory_briefing "$FACTORY_GOAL")" + if [ -n "$FACTORY_BRIEFING_PATH" ]; then + printf '%s' "$FACTORY_BRIEFING_PATH" > "$ROOT/.agents/ao/factory-briefing.txt" 2>/dev/null || true + FACTORY_BRIEFING_CONTENT="$(read_factory_briefing "$FACTORY_BRIEFING_PATH")" + INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} + +### Factory Startup Surface +- **Goal:** ${FACTORY_GOAL} +- **Primary briefing:** ${FACTORY_BRIEFING_PATH} +- **Delivery lane:** Continue naturally, or run \`/rpi \"${FACTORY_GOAL}\"\` for the full factory cycle. + +Treat the matched briefing below as the primary dynamic startup surface for this session. Use ranked learnings only as supporting evidence. + + +${FACTORY_BRIEFING_CONTENT} +" + else + INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} + +### Factory Startup Surface +- **Goal:** ${FACTORY_GOAL} +- **Primary briefing:** not available yet +- **Delivery lane:** Run \`/rpi \"${FACTORY_GOAL}\"\` or continue with this objective. + +No matched knowledge briefing was available at session start. Treat the goal above as the active factory objective; supporting learnings below are advisory rather than the primary brief." + fi + else + : > "$ROOT/.agents/ao/.factory-intake-needed" 2>/dev/null || true + INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} + +### Factory Startup Surface +No startup goal was recovered from handoff or tracker state. +Treat the first substantive user prompt as factory intake: compile a goal-time briefing, then route into \`/rpi\`." + fi +fi + # Auto-retrieve and cite top learnings for this session (closes citation gap) if command -v ao &>/dev/null; then LOOKUP_QUERY="$(derive_lookup_query)" @@ -327,14 +447,14 @@ if command -v ao &>/dev/null; then LOOKUP_CONTEXT="task context unavailable" fi if [ -n "$LOOKUP_QUERY" ] || [ -n "$LOOKUP_BEAD" ]; then - FLYWHEEL_KNOWLEDGE=$(timeout 5 ao "${LOOKUP_ARGS[@]}" 2>/dev/null || true) + FLYWHEEL_KNOWLEDGE=$(run_with_timeout 5 ao "${LOOKUP_ARGS[@]}" || true) else FLYWHEEL_KNOWLEDGE="" fi if [ -n "$FLYWHEEL_KNOWLEDGE" ]; then INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} -### Prior Learnings (auto-retrieved: ${LOOKUP_CONTEXT}) +### Supporting Learnings (auto-retrieved: ${LOOKUP_CONTEXT}) ${FLYWHEEL_KNOWLEDGE}" fi fi diff --git a/tests/hooks/hook-output-schema.bats b/tests/hooks/hook-output-schema.bats index 773e388e9..9aed37293 100644 --- a/tests/hooks/hook-output-schema.bats +++ b/tests/hooks/hook-output-schema.bats @@ -76,6 +76,33 @@ assert_hook_schema() { # Only emits JSON when there is a nudge to give (ratchet gate active). # ═══════════════════════════════════════════════════════════════════════ +@test "factory-router: intake prompt output conforms to schema with hookEventName=UserPromptSubmit" { + local mock="$TMP_TEST_DIR/mock-factory-router" + mkdir -p "$mock/.agents/ao" "$mock/bin" + git -C "$mock" init -q >/dev/null 2>&1 + touch "$mock/.agents/ao/.factory-intake-needed" + cat > "$mock/bin/ao" <<'EOF' +#!/usr/bin/env bash +if [ "${1:-}" = "knowledge" ] && [ "${2:-}" = "brief" ]; then + briefing_path="${MOCK_FACTORY_BRIEFING_PATH:?}" + mkdir -p "$(dirname "$briefing_path")" + cat > "$briefing_path" <<'BRIEF' +# Briefing: fix auth bootstrap +BRIEF + jq -n --arg path "$briefing_path" '{"output_path":$path}' + exit 0 +fi +exit 0 +EOF + chmod +x "$mock/bin/ao" + local briefing_path="$mock/.agents/briefings/2026-04-02-fix-auth-bootstrap.md" + run bash -c 'cd "$1" && printf "%s" "$3" | PATH="$1/bin:$PATH" MOCK_FACTORY_BRIEFING_PATH="$2" AGENTOPS_STARTUP_CONTEXT_MODE=factory bash "$4" 2>&1' \ + -- "$mock" "$briefing_path" '{"prompt":"fix auth bootstrap"}' "$HOOKS_DIR/factory-router.sh" + [ "$status" -eq 0 ] + [ -n "$output" ] + assert_hook_schema "$output" "UserPromptSubmit" +} + @test "prompt-nudge: when nudge fires, output conforms to schema with hookEventName=UserPromptSubmit" { # Set up ratchet state so prompt-nudge has something to nudge about. # Simulate: research step done, plan step pending, user says "implement". @@ -340,6 +367,7 @@ AOEOF local -A hook_event_map hook_event_map=( ["session-start.sh"]="SessionStart" + ["factory-router.sh"]="UserPromptSubmit" ["prompt-nudge.sh"]="UserPromptSubmit" ["intent-echo.sh"]="UserPromptSubmit" ["commit-review-gate.sh"]="PreToolUse" diff --git a/tests/hooks/test-hook-lifecycle.sh b/tests/hooks/test-hook-lifecycle.sh index ca76f474d..b3144dea1 100755 --- a/tests/hooks/test-hook-lifecycle.sh +++ b/tests/hooks/test-hook-lifecycle.sh @@ -591,6 +591,7 @@ echo "=== Cross-Cutting: Kill Switch Verification ===" # Verify ALL hooks respect the AGENTOPS_HOOKS_DISABLED kill switch HOOKS_WITH_KILL_SWITCH=( "session-start.sh" + "factory-router.sh" "citation-tracker.sh" "git-worker-guard.sh" "ratchet-advance.sh" diff --git a/tests/hooks/test-hooks.bats b/tests/hooks/test-hooks.bats index bff6c8aad..ad4ddcf3b 100644 --- a/tests/hooks/test-hooks.bats +++ b/tests/hooks/test-hooks.bats @@ -52,6 +52,52 @@ teardown() { [ -d "$mock/.agents/research" ] && [ ! -d "$mock/subdir/.agents/research" ] } +@test "session-start: factory mode builds and injects a matched briefing" { + local mock="$TMP_TEST_DIR/mock-factory-start" + mkdir -p "$mock/.agents/handoff" "$mock/bin" + git -C "$mock" init -q >/dev/null 2>&1 + cat > "$mock/.agents/handoff/handoff-20260402T192000Z.json" <<'EOF' +{ + "schema_version": 1, + "id": "handoff-factory-test", + "created_at": "2026-04-02T19:20:00Z", + "type": "manual", + "goal": "stabilize auth startup", + "summary": "route this session into the factory lane" +} +EOF + cat > "$mock/bin/ao" <<'EOF' +#!/usr/bin/env bash +if [ -n "${AO_ARGS_FILE:-}" ]; then + printf '%s\n' "$*" >> "$AO_ARGS_FILE" +fi +if [ "${1:-}" = "knowledge" ] && [ "${2:-}" = "brief" ]; then + briefing_path="${MOCK_FACTORY_BRIEFING_PATH:?}" + mkdir -p "$(dirname "$briefing_path")" + cat > "$briefing_path" <<'BRIEF' +# Briefing: stabilize auth startup + +## Relevant Topics +- `auth-startup` (healthy) +BRIEF + jq -n --arg path "$briefing_path" '{"output_path":$path}' + exit 0 +fi +if [ "${1:-}" = "lookup" ]; then + printf '[lookup] supporting result\n' +fi +exit 0 +EOF + chmod +x "$mock/bin/ao" + local args_file="$mock/ao-args.log" + local briefing_path="$mock/.agents/briefings/2026-04-02-stabilize-auth-startup.md" + run bash -c 'cd "$1" && PATH="$1/bin:$PATH" AO_ARGS_FILE="$2" MOCK_FACTORY_BRIEFING_PATH="$3" AGENTOPS_STARTUP_CONTEXT_MODE=factory bash "$4" 2>&1' \ + -- "$mock" "$args_file" "$briefing_path" "$HOOKS_DIR/session-start.sh" + [ "$status" -eq 0 ] + grep -q '^knowledge brief --json --goal stabilize auth startup$' "$args_file" + echo "$output" | jq -r '.hookSpecificOutput.additionalContext // ""' | grep -q '' +} + @test "precompact-snapshot: emits additionalContext JSON" { local mock="$TMP_TEST_DIR/mock-precompact" mkdir -p "$mock/.agents" @@ -359,6 +405,46 @@ EOF rm -f "$REPO_ROOT/.agents/ao/.intent-echo-fired" 2>/dev/null } +# ═══════════════════════════════════════════════════════════════════════ +# factory-router.sh +# ═══════════════════════════════════════════════════════════════════════ + +@test "factory-router: first substantive prompt builds briefing and clears intake" { + local mock="$TMP_TEST_DIR/mock-factory-router" + mkdir -p "$mock/.agents/ao" "$mock/bin" + git -C "$mock" init -q >/dev/null 2>&1 + touch "$mock/.agents/ao/.factory-intake-needed" + cat > "$mock/bin/ao" <<'EOF' +#!/usr/bin/env bash +if [ -n "${AO_ARGS_FILE:-}" ]; then + printf '%s\n' "$*" >> "$AO_ARGS_FILE" +fi +if [ "${1:-}" = "knowledge" ] && [ "${2:-}" = "brief" ]; then + briefing_path="${MOCK_FACTORY_BRIEFING_PATH:?}" + mkdir -p "$(dirname "$briefing_path")" + cat > "$briefing_path" <<'BRIEF' +# Briefing: fix auth bootstrap + +## Relevant Topics +- `auth-bootstrap` (healthy) +BRIEF + jq -n --arg path "$briefing_path" '{"output_path":$path}' + exit 0 +fi +exit 0 +EOF + chmod +x "$mock/bin/ao" + local args_file="$mock/ao-args.log" + local briefing_path="$mock/.agents/briefings/2026-04-02-fix-auth-bootstrap.md" + run bash -c 'cd "$1" && printf "%s" "$4" | PATH="$1/bin:$PATH" AO_ARGS_FILE="$2" MOCK_FACTORY_BRIEFING_PATH="$3" AGENTOPS_STARTUP_CONTEXT_MODE=factory bash "$5" 2>&1' \ + -- "$mock" "$args_file" "$briefing_path" '{"prompt":"fix auth bootstrap"}' "$HOOKS_DIR/factory-router.sh" + [ "$status" -eq 0 ] + grep -q '^knowledge brief --json --goal fix auth bootstrap$' "$args_file" + [ ! -f "$mock/.agents/ao/.factory-intake-needed" ] + [ "$(cat "$mock/.agents/ao/factory-goal.txt" 2>/dev/null)" = "fix auth bootstrap" ] + echo "$output" | jq -r '.hookSpecificOutput.additionalContext // ""' | grep -q '' +} + # ═══════════════════════════════════════════════════════════════════════ # research-loop-detector.sh # ═══════════════════════════════════════════════════════════════════════ @@ -442,6 +528,10 @@ AOEOF jq -e '.hooks.UserPromptSubmit[].hooks[] | select(.command | contains("intent-echo.sh"))' "$HOOKS_DIR/hooks.json" >/dev/null 2>&1 } +@test "hooks.json: factory-router wired in UserPromptSubmit" { + jq -e '.hooks.UserPromptSubmit[].hooks[] | select(.command | contains("factory-router.sh"))' "$HOOKS_DIR/hooks.json" >/dev/null 2>&1 +} + @test "hooks.json: research-loop-detector wired in PostToolUse" { jq -e '.hooks.PostToolUse[].hooks[] | select(.command | contains("research-loop-detector.sh"))' "$HOOKS_DIR/hooks.json" >/dev/null 2>&1 } diff --git a/tests/hooks/test-hooks.sh b/tests/hooks/test-hooks.sh index 3a279bb1c..d40124481 100755 --- a/tests/hooks/test-hooks.sh +++ b/tests/hooks/test-hooks.sh @@ -171,6 +171,63 @@ else fail "session-start reports lookup scope in injected context" fi +# Test 14c: session-start prefers a factory briefing when startup goal exists +MOCK_FACTORY_START="$TMPDIR/mock-factory-start" +mkdir -p "$MOCK_FACTORY_START/.agents/handoff" "$MOCK_FACTORY_START/bin" +git -C "$MOCK_FACTORY_START" init -q >/dev/null 2>&1 +cat > "$MOCK_FACTORY_START/.agents/handoff/handoff-20260402T192000Z.json" <<'EOF' +{ + "schema_version": 1, + "id": "handoff-factory-test", + "created_at": "2026-04-02T19:20:00Z", + "type": "manual", + "goal": "stabilize auth startup", + "summary": "route this session into the factory lane" +} +EOF +cat > "$MOCK_FACTORY_START/bin/ao" <<'EOF' +#!/usr/bin/env bash +if [ -n "${AO_ARGS_FILE:-}" ]; then + printf '%s\n' "$*" >> "$AO_ARGS_FILE" +fi +if [ "${1:-}" = "knowledge" ] && [ "${2:-}" = "brief" ]; then + briefing_path="${MOCK_FACTORY_BRIEFING_PATH:?}" + mkdir -p "$(dirname "$briefing_path")" + cat > "$briefing_path" <<'BRIEF' +# Briefing: stabilize auth startup + +## Relevant Topics +- `auth-startup` (healthy) +BRIEF + jq -n --arg path "$briefing_path" '{"output_path":$path}' + exit 0 +fi +if [ "${1:-}" = "lookup" ]; then + printf '[lookup] supporting result\n' +fi +exit 0 +EOF +chmod +x "$MOCK_FACTORY_START/bin/ao" +AO_ARGS_FILE="$MOCK_FACTORY_START/ao-args.log" +MOCK_FACTORY_BRIEFING_PATH="$MOCK_FACTORY_START/.agents/briefings/2026-04-02-stabilize-auth-startup.md" +FACTORY_OUTPUT=$(cd "$MOCK_FACTORY_START" && \ + PATH="$MOCK_FACTORY_START/bin:$PATH" \ + AO_ARGS_FILE="$AO_ARGS_FILE" \ + MOCK_FACTORY_BRIEFING_PATH="$MOCK_FACTORY_BRIEFING_PATH" \ + AGENTOPS_STARTUP_CONTEXT_MODE=factory \ + bash "$HOOKS_DIR/session-start.sh" 2>/dev/null || true) +if grep -q '^knowledge brief --json --goal stabilize auth startup$' "$AO_ARGS_FILE"; then + pass "session-start builds a factory briefing from startup goal" +else + fail "session-start builds a factory briefing from startup goal" +fi +FACTORY_CONTEXT=$(echo "$FACTORY_OUTPUT" | jq -r '.hookSpecificOutput.additionalContext // ""' 2>/dev/null) +if echo "$FACTORY_CONTEXT" | grep -q '' && echo "$FACTORY_CONTEXT" | grep -q 'stabilize auth startup'; then + pass "session-start injects the factory briefing as primary startup surface" +else + fail "session-start injects the factory briefing as primary startup surface" +fi + # Test 15: precompact emits JSON when data exists MOCK_PRECOMPACT="$TMPDIR/mock-precompact" mkdir -p "$MOCK_PRECOMPACT/.agents" @@ -1714,6 +1771,66 @@ else fail "intent-echo.sh not found in hooks.json" fi +# ============================================================ +echo "=== factory-router.sh ===" +# ============================================================ + +FACTORY_ROUTER_MOCK="$TMPDIR/factory-router-mock" +mkdir -p "$FACTORY_ROUTER_MOCK/.agents/ao" "$FACTORY_ROUTER_MOCK/bin" +git -C "$FACTORY_ROUTER_MOCK" init -q >/dev/null 2>&1 +touch "$FACTORY_ROUTER_MOCK/.agents/ao/.factory-intake-needed" +cat > "$FACTORY_ROUTER_MOCK/bin/ao" <<'EOF' +#!/usr/bin/env bash +if [ -n "${AO_ARGS_FILE:-}" ]; then + printf '%s\n' "$*" >> "$AO_ARGS_FILE" +fi +if [ "${1:-}" = "knowledge" ] && [ "${2:-}" = "brief" ]; then + briefing_path="${MOCK_FACTORY_BRIEFING_PATH:?}" + mkdir -p "$(dirname "$briefing_path")" + cat > "$briefing_path" <<'BRIEF' +# Briefing: fix auth bootstrap + +## Relevant Topics +- `auth-bootstrap` (healthy) +BRIEF + jq -n --arg path "$briefing_path" '{"output_path":$path}' + exit 0 +fi +exit 0 +EOF +chmod +x "$FACTORY_ROUTER_MOCK/bin/ao" +AO_ARGS_FILE="$FACTORY_ROUTER_MOCK/ao-args.log" +MOCK_FACTORY_BRIEFING_PATH="$FACTORY_ROUTER_MOCK/.agents/briefings/2026-04-02-fix-auth-bootstrap.md" +ROUTER_OUTPUT=$(cd "$FACTORY_ROUTER_MOCK" && \ + printf '%s' '{"prompt":"fix auth bootstrap"}' | \ + PATH="$FACTORY_ROUTER_MOCK/bin:$PATH" \ + AO_ARGS_FILE="$AO_ARGS_FILE" \ + MOCK_FACTORY_BRIEFING_PATH="$MOCK_FACTORY_BRIEFING_PATH" \ + AGENTOPS_STARTUP_CONTEXT_MODE=factory \ + bash "$HOOKS_DIR/factory-router.sh" 2>/dev/null || true) +if grep -q '^knowledge brief --json --goal fix auth bootstrap$' "$AO_ARGS_FILE"; then + pass "factory-router builds briefing from first substantive prompt" +else + fail "factory-router builds briefing from first substantive prompt" +fi +if [ ! -f "$FACTORY_ROUTER_MOCK/.agents/ao/.factory-intake-needed" ] && \ + [ "$(cat "$FACTORY_ROUTER_MOCK/.agents/ao/factory-goal.txt" 2>/dev/null)" = "fix auth bootstrap" ]; then + pass "factory-router records the captured goal and clears intake state" +else + fail "factory-router records the captured goal and clears intake state" +fi +ROUTER_CONTEXT=$(echo "$ROUTER_OUTPUT" | jq -r '.hookSpecificOutput.additionalContext // ""' 2>/dev/null) +if echo "$ROUTER_CONTEXT" | grep -q '' && echo "$ROUTER_CONTEXT" | grep -q '/rpi "fix auth bootstrap"'; then + pass "factory-router emits factory routing guidance with briefing" +else + fail "factory-router emits factory routing guidance with briefing" +fi +if jq -e '.hooks.UserPromptSubmit[].hooks[] | select(.command | contains("factory-router.sh"))' "$HOOKS_DIR/hooks.json" >/dev/null 2>&1; then + pass "factory-router.sh wired in hooks.json" +else + fail "factory-router.sh not found in hooks.json" +fi + # ============================================================ echo "=== research-loop-detector.sh ===" # ============================================================ From 2fc523491122c23229f8034ff054cd1c14cbc02d Mon Sep 17 00:00:00 2001 From: Boden Fuller Date: Thu, 2 Apr 2026 15:52:33 -0400 Subject: [PATCH 2/2] refactor(hooks): keep Claude startup surface in CLAUDE.md --- CLAUDE.md | 11 + README.md | 9 +- cli/README.md | 13 +- cli/embedded/hooks/factory-router.sh | 51 +--- cli/embedded/hooks/session-start.sh | 218 +----------------- docs/ENV-VARS.md | 6 +- docs/SKILLS.md | 2 +- docs/cli-skills-map.md | 2 +- docs/contracts/hook-runtime-contract.md | 6 +- .../session-intelligence-trust-model.md | 4 +- docs/how-it-works.md | 4 +- docs/workflows/session-lifecycle.md | 14 +- hooks/factory-router.sh | 51 +--- hooks/session-start.sh | 218 +----------------- tests/hooks/hook-output-schema.bats | 24 +- tests/hooks/test-hook-lifecycle.sh | 19 +- tests/hooks/test-hooks.bats | 20 +- tests/hooks/test-hooks.sh | 80 +++---- tests/integration/test-hook-chain.sh | 35 +-- 19 files changed, 111 insertions(+), 676 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4ff184611..3363e1cf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,6 +194,17 @@ Research → Plan → Implement → Validate └──── Knowledge Flywheel ────┘ ``` +## Claude Code Startup Surface + +`CLAUDE.md` is the startup surface in Claude Code. Do not expect `SessionStart` +or first-prompt hooks to inject briefings into the conversation. + +- Use the goal stated in the user prompt or recovered handoff as the working objective. +- If you want the full software-factory lane, run `/rpi "goal"` explicitly. +- If you want a compiled goal-time briefing first, run `ao knowledge brief --goal "goal"`. +- Treat `.agents/ao/factory-goal.txt` and `.agents/ao/factory-briefing.txt` as + silent runtime state, not operator-facing instructions. + ### Session & Swarm Constraints - **Multi-phase work:** Route through `ao rpi` — it enforces 90-min phase timeouts and 10-min stall detection. Raw sessions have neither. diff --git a/README.md b/README.md index cc78e117f..a70e23a4a 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,10 @@ Or install via [release binaries](https://github.com/boshu2/agentops/releases) o Then type `/quickstart` in your agent chat. -In Claude Code, the native hook path now behaves like a software-factory -startup surface: `SessionStart` prefers a matched goal-time briefing when -handoff or tracker state already gives the runtime a goal, and when no goal is -recovered the first substantive prompt becomes intake and the plugin routes the -session toward `/rpi`. +In Claude Code, `CLAUDE.md` is the startup surface. Installed hooks stay +silent: `SessionStart` prepares runtime state and can stage factory goal or +briefing files, while `UserPromptSubmit` can capture first-prompt intake +without injecting additional context into the session. --- diff --git a/cli/README.md b/cli/README.md index 7ef55c1e3..fccb4fdec 100644 --- a/cli/README.md +++ b/cli/README.md @@ -18,19 +18,16 @@ ao init --hooks ao hooks test ``` -That's it. In Claude Code, the installed hooks now provide the native factory -startup lane: `SessionStart` prefers matched knowledge briefings, and the first -substantive prompt can become intake when no startup goal exists. +That's it. In Claude Code, `CLAUDE.md` remains the startup surface. The +installed hooks stay silent and only prepare runtime state for the factory lane. ## What It Does -**SessionStart**: Performs startup maintenance, then surfaces the best -available factory startup context. If a goal is already known, it prefers a -matched knowledge briefing and treats ranked learnings as supporting evidence. +**SessionStart**: Performs startup maintenance, recovers handoff state, and can +stage `factory-goal.txt` / `factory-briefing.txt` without injecting context. **UserPromptSubmit**: When startup lacked a goal, the first substantive prompt -is captured as factory intake, a goal-time briefing is built if possible, and -the runtime is nudged toward `/rpi`. +can be captured as silent factory intake and staged for later explicit use. **SessionEnd**: Extracts learnings and updates the feedback loop. diff --git a/cli/embedded/hooks/factory-router.sh b/cli/embedded/hooks/factory-router.sh index 3407e378e..a3ee5b7ff 100755 --- a/cli/embedded/hooks/factory-router.sh +++ b/cli/embedded/hooks/factory-router.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# factory-router.sh - UserPromptSubmit hook: convert first substantive prompt into -# a goal-time briefing when SessionStart had no factory objective. +# factory-router.sh - UserPromptSubmit hook: capture first substantive prompt as +# factory state when SessionStart had no goal. This hook stays silent. [ "${AGENTOPS_HOOKS_DISABLED:-}" = "1" ] && exit 0 [ "${AGENTOPS_FACTORY_ROUTER_DISABLED:-}" = "1" ] && exit 0 @@ -64,26 +64,6 @@ build_factory_briefing() { printf '%s' "$path" } -read_factory_briefing() { - local path="$1" - local max_chars=2200 - local content trimmed - - [ -f "$path" ] || return 0 - content=$(cat "$path" 2>/dev/null || true) - [ -n "$content" ] || return 0 - - if [ "${#content}" -gt "$max_chars" ]; then - trimmed="${content:0:$max_chars}" - content="${trimmed% -*} - -*[briefing truncated by factory router]*" - fi - - printf '%s' "$content" -} - extract_prompt() { local input="$1" if command -v jq >/dev/null 2>&1; then @@ -157,37 +137,10 @@ printf '%s' "$GOAL" > "$GOAL_FILE" 2>/dev/null || true rm -f "$INTAKE_FLAG" 2>/dev/null || true printf '%s\n' "${CLAUDE_SESSION_ID:-unknown}" > "$ROUTER_FLAG" 2>/dev/null || true -MESSAGE="Factory intake captured from the first substantive prompt. -- **Goal:** ${GOAL}" - if [ -n "$BRIEFING_PATH" ]; then printf '%s' "$BRIEFING_PATH" > "$BRIEFING_FILE" 2>/dev/null || true - BRIEFING_CONTENT="$(read_factory_briefing "$BRIEFING_PATH")" - MESSAGE="${MESSAGE} -- **Primary briefing:** ${BRIEFING_PATH} -- **Delivery lane:** Continue naturally, or run \`/rpi \"${GOAL}\"\`. - -Treat the briefing below as the primary dynamic surface for this session. - - -${BRIEFING_CONTENT} -" else rm -f "$BRIEFING_FILE" 2>/dev/null || true - MESSAGE="${MESSAGE} -- **Primary briefing:** not available yet -- **Delivery lane:** Run \`/rpi \"${GOAL}\"\` or continue with this objective. - -No matched knowledge briefing was available for the intake goal, so the captured goal above is the active factory objective." -fi - -if command -v jq >/dev/null 2>&1; then - jq -n --arg msg "$MESSAGE" '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":$msg}}' -else - safe_msg=${MESSAGE//\\/\\\\} - safe_msg=${safe_msg//\"/\\\"} - safe_msg=$(printf '%s' "$safe_msg" | tr '\n' ' ') - echo "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"$safe_msg\"}}" fi exit 0 diff --git a/cli/embedded/hooks/session-start.sh b/cli/embedded/hooks/session-start.sh index f47a97dec..5953a6888 100755 --- a/cli/embedded/hooks/session-start.sh +++ b/cli/embedded/hooks/session-start.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash # AgentOps Session Start Hook -# Creates .agents/ directories, consumes handoffs, and surfaces startup context. -# In default factory mode, SessionStart prefers a matched knowledge briefing when -# a goal is available; manual mode keeps a lighter startup surface. +# Creates .agents/ directories, consumes handoffs, and prepares runtime state. +# CLAUDE.md owns the operator-facing startup surface; this hook stays silent. # Kill switches [ "${AGENTOPS_HOOKS_DISABLED:-}" = "1" ] && exit 0 @@ -83,13 +82,6 @@ derive_lookup_query() { return 0 } -detect_lookup_bead() { - if ! command -v bd >/dev/null 2>&1; then - return 0 - fi - run_with_timeout 1 bd current | head -1 | tr -d '\r' | sed 's/^ //; s/ $//' -} - build_factory_briefing() { local goal="$1" local output path @@ -108,26 +100,6 @@ build_factory_briefing() { printf '%s' "$path" } -read_factory_briefing() { - local path="$1" - local max_chars=2200 - local content trimmed - - [ -f "$path" ] || return 0 - content=$(cat "$path" 2>/dev/null || true) - [ -n "$content" ] || return 0 - - if [ "${#content}" -gt "$max_chars" ]; then - trimmed="${content:0:$max_chars}" - content="${trimmed% -*} - -*[briefing truncated by session-start hook]*" - fi - - printf '%s' "$content" -} - write_environment_manifest() { local env_file="$AO_DIR/environment.json" local tmp_file git_branch head_sha git_dirty tools_json manifest_json @@ -300,27 +272,11 @@ rpi/* EOF fi -# Flywheel behavior -INJECTED_KNOWLEDGE="" STARTUP_CONTEXT_MODE="$(resolve_startup_context_mode)" FACTORY_GOAL="" FACTORY_BRIEFING_PATH="" -FACTORY_BRIEFING_CONTENT="" - -# Derive MEMORY.md path once (used for stale nudge) -MEMORY_DIR="$HOME/.claude/projects" -PROJECT_PATH=$(printf '%s' "$ROOT" | tr '/' '-') -MEMORY_FILE="$MEMORY_DIR/$PROJECT_PATH/memory/MEMORY.md" -MEMORY_AGE_DAYS=-1 -if [ -f "$MEMORY_FILE" ]; then - MTIME=$(stat -f %m "$MEMORY_FILE" 2>/dev/null || stat -c %Y "$MEMORY_FILE" 2>/dev/null || echo "") - if [ -n "$MTIME" ]; then - MEMORY_AGE_DAYS=$(( ($(date +%s) - MTIME) / 86400 )) - fi -fi # Structured handoff consumption (ao handoff JSON artifacts) -HANDOFF_CONTEXT="" if [ -d "$ROOT/.agents/handoff" ] && command -v jq &>/dev/null; then # Find newest unconsumed .json handoff (exclude .consumed.json and .consuming.json) HANDOFF_JSON=$(find "$ROOT/.agents/handoff" -maxdepth 1 -name 'handoff-*.json' \ @@ -340,52 +296,10 @@ if [ -d "$ROOT/.agents/handoff" ] && command -v jq &>/dev/null; then "$CONSUMING" > "${CONSUMING}.tmp" 2>/dev/null \ && mv "${CONSUMING}.tmp" "${HANDOFF_JSON%.json}.consumed.json" 2>/dev/null \ && rm -f "$CONSUMING" 2>/dev/null - # Build injection context - HANDOFF_CONTEXT="### Handoff Context (${H_TYPE})" - [ -n "$H_GOAL" ] && HANDOFF_CONTEXT="${HANDOFF_CONTEXT} -- **Goal:** ${H_GOAL}" - [ -n "$H_SUMMARY" ] && HANDOFF_CONTEXT="${HANDOFF_CONTEXT} -- **Summary:** ${H_SUMMARY}" - [ -n "$H_CONTINUATION" ] && HANDOFF_CONTEXT="${HANDOFF_CONTEXT} -- **Continue:** ${H_CONTINUATION}" - HANDOFF_CONTEXT="${HANDOFF_CONTEXT} -- **Source:** ${HANDOFF_JSON}" fi fi fi -# Predecessor handoff discovery -PREDECESSOR_FILE="${GT_PREDECESSOR_HANDOFF:-}" -if [ -z "$PREDECESSOR_FILE" ] && [ -d "$ROOT/.agents/handoff" ]; then - PREDECESSOR_FILE=$(ls -t "$ROOT/.agents/handoff/"*.md 2>/dev/null | head -1) -fi - -# Build injection context (MEMORY.md is auto-loaded by Claude Code) -INJECTED_KNOWLEDGE="MEMORY.md is auto-loaded by Claude Code for this project. -For on-demand retrieval: \`ao search \"\"\` or \`ao lookup --query \"\"\`" -if [ -n "$HANDOFF_CONTEXT" ]; then - INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} - -${HANDOFF_CONTEXT}" -fi -if [ -n "$PREDECESSOR_FILE" ] && [ -f "$PREDECESSOR_FILE" ]; then - INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} -Predecessor handoff: ${PREDECESSOR_FILE}" -fi - -# Point to knowledge signpost (agents search on demand) -if [ -f "${ROOT:-.}/.agents/AGENTS.md" ]; then - INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} - -Knowledge artifacts are in \`.agents/\`. See \`.agents/AGENTS.md\` for navigation. -Use \`ao lookup --query \"topic\"\` for learnings retrieval, or \`Grep\` in \`.agents/learnings/\` as fallback." -else - INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} - -Knowledge artifacts are in \`.agents/\` (if populated). -Use \`ao lookup --query \"topic\"\` for on-demand learnings retrieval." -fi - if [ "$STARTUP_CONTEXT_MODE" = "factory" ]; then FACTORY_GOAL="$(derive_lookup_query)" if [ -n "$FACTORY_GOAL" ]; then @@ -393,138 +307,12 @@ if [ "$STARTUP_CONTEXT_MODE" = "factory" ]; then FACTORY_BRIEFING_PATH="$(build_factory_briefing "$FACTORY_GOAL")" if [ -n "$FACTORY_BRIEFING_PATH" ]; then printf '%s' "$FACTORY_BRIEFING_PATH" > "$ROOT/.agents/ao/factory-briefing.txt" 2>/dev/null || true - FACTORY_BRIEFING_CONTENT="$(read_factory_briefing "$FACTORY_BRIEFING_PATH")" - INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} - -### Factory Startup Surface -- **Goal:** ${FACTORY_GOAL} -- **Primary briefing:** ${FACTORY_BRIEFING_PATH} -- **Delivery lane:** Continue naturally, or run \`/rpi \"${FACTORY_GOAL}\"\` for the full factory cycle. - -Treat the matched briefing below as the primary dynamic startup surface for this session. Use ranked learnings only as supporting evidence. - - -${FACTORY_BRIEFING_CONTENT} -" else - INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} - -### Factory Startup Surface -- **Goal:** ${FACTORY_GOAL} -- **Primary briefing:** not available yet -- **Delivery lane:** Run \`/rpi \"${FACTORY_GOAL}\"\` or continue with this objective. - -No matched knowledge briefing was available at session start. Treat the goal above as the active factory objective; supporting learnings below are advisory rather than the primary brief." + rm -f "$ROOT/.agents/ao/factory-briefing.txt" 2>/dev/null || true fi else : > "$ROOT/.agents/ao/.factory-intake-needed" 2>/dev/null || true - INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} - -### Factory Startup Surface -No startup goal was recovered from handoff or tracker state. -Treat the first substantive user prompt as factory intake: compile a goal-time briefing, then route into \`/rpi\`." fi fi -# Auto-retrieve and cite top learnings for this session (closes citation gap) -if command -v ao &>/dev/null; then - LOOKUP_QUERY="$(derive_lookup_query)" - LOOKUP_BEAD="$(detect_lookup_bead)" - LOOKUP_CONTEXT="" - LOOKUP_ARGS=(lookup --limit 5) - if [ -n "$LOOKUP_QUERY" ]; then - LOOKUP_ARGS+=(--query "$LOOKUP_QUERY") - LOOKUP_CONTEXT="query=\"$LOOKUP_QUERY\"" - fi - if [ -n "$LOOKUP_BEAD" ]; then - LOOKUP_ARGS+=(--bead "$LOOKUP_BEAD") - if [ -n "$LOOKUP_CONTEXT" ]; then - LOOKUP_CONTEXT="${LOOKUP_CONTEXT}, " - fi - LOOKUP_CONTEXT="${LOOKUP_CONTEXT}bead=${LOOKUP_BEAD}" - fi - if [ -z "$LOOKUP_CONTEXT" ]; then - LOOKUP_CONTEXT="task context unavailable" - fi - if [ -n "$LOOKUP_QUERY" ] || [ -n "$LOOKUP_BEAD" ]; then - FLYWHEEL_KNOWLEDGE=$(run_with_timeout 5 ao "${LOOKUP_ARGS[@]}" || true) - else - FLYWHEEL_KNOWLEDGE="" - fi - if [ -n "$FLYWHEEL_KNOWLEDGE" ]; then - INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} - -### Supporting Learnings (auto-retrieved: ${LOOKUP_CONTEXT}) -${FLYWHEEL_KNOWLEDGE}" - fi -fi - -# Keep startup context lean: inject only fresh flywheel knowledge and a short skill pointer. -SKILL_FILE="${PLUGIN_ROOT}/skills/using-agentops/SKILL.md" -if [ -f "$SKILL_FILE" ]; then - using_agentops_hint="AgentOps workflow context is available. Use the Skill tool to read ${SKILL_FILE} when needed." -else - using_agentops_hint="(AgentOps skill content unavailable at ${SKILL_FILE})" -fi - -# Truncation (static cap — no notebook mode logic needed) -MAX_INJECT_CHARS=4000 -if [ -n "$INJECTED_KNOWLEDGE" ] && [ "${#INJECTED_KNOWLEDGE}" -gt "$MAX_INJECT_CHARS" ]; then - # Truncate at last newline within budget (never mid-line) - trimmed="${INJECTED_KNOWLEDGE:0:$MAX_INJECT_CHARS}" - INJECTED_KNOWLEDGE="${trimmed% -*} - -*[truncated by session-start hook]*" -fi - -# Nudge agent if MEMORY.md is stale or missing -if [ -n "${MEMORY_FILE:-}" ] && [ -f "$MEMORY_FILE" ] && [ "${MEMORY_AGE_DAYS:-0}" -gt 7 ]; then - INJECTED_KNOWLEDGE="${INJECTED_KNOWLEDGE} - -*Note: Your MEMORY.md hasn't been updated in ${MEMORY_AGE_DAYS} days. Consider running \`ao notebook update\` or updating it manually.*" -fi - -if [ -n "$INJECTED_KNOWLEDGE" ]; then - full_content=$(cat < -${INJECTED_KNOWLEDGE} - -${using_agentops_hint} - -HOOKCTX -) -else - full_content=$(cat < -No prior flywheel knowledge was injected for this session. -${using_agentops_hint} - -HOOKCTX -) -fi - -if command -v jq &>/dev/null; then - additional_context=$(printf '%s' "$full_content" | jq -Rs '.') - cat <