From 2fc523491122c23229f8034ff054cd1c14cbc02d Mon Sep 17 00:00:00 2001 From: Boden Fuller Date: Thu, 2 Apr 2026 15:52:33 -0400 Subject: [PATCH] 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 <