diff --git a/plugins/forge/.codex-plugin/plugin.json b/plugins/forge/.codex-plugin/plugin.json index f1540c2..5028bf5 100644 --- a/plugins/forge/.codex-plugin/plugin.json +++ b/plugins/forge/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "forge", - "version": "1.20.0", + "version": "1.20.3", "description": "Forge by ShipToday brings free, AI-powered product development lifecycle automation into Codex.", "author": { "name": "ShipToday", diff --git a/plugins/forge/hooks/checkpoint-claim.cjs b/plugins/forge/hooks/checkpoint-claim.cjs new file mode 100644 index 0000000..a43f5ad --- /dev/null +++ b/plugins/forge/hooks/checkpoint-claim.cjs @@ -0,0 +1,52 @@ +'use strict'; + +const fs = require('fs'); +const { isDeepStrictEqual } = require('util'); +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function matches(state, call) { + const sent = state.delivered_checkpoint; + const id = call.state_updates.codex_checkpoint_id; + if (!UUID_RE.test(String(id || '')) || id !== sent?.id || + id !== state.checkpoint_delivery?.id || + call.conversation_id !== sent.conversation_id || + call.completed_step !== sent.completed_step) return false; + // The guard may refresh cumulative tokens; all other frozen fields must match. + const { token_usage: incomingTokens, ...incoming } = call.state_updates; + const { token_usage: queuedTokens, ...queued } = sent.state_updates; + return isDeepStrictEqual(incoming, queued); +} + +function attemptPath(session, id) { + return `${session.stateFilePath}.${id}.attempt`; +} + +// Exclusive creation is the cross-process claim. Atomic state replacement alone +// is not a lock: two PreToolUse processes could both read an unattempted receipt. +function claim(session, call) { + const state = session.read(); + if (!matches(state, call)) return 'Unknown, stale, or modified passive checkpoint. Use only the current delivered payload; do not reconstruct or replay it.'; + if (state.checkpoint_delivery.attempted_at || state.checkpoint_delivery.processed_at) { + return 'This passive checkpoint was already attempted or recorded. Do not retry it, even if the previous result is unknown.'; + } + const id = call.state_updates.codex_checkpoint_id; + const at = new Date().toISOString(); + try { + fs.writeFileSync(attemptPath(session, id), at, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); + const current = session.read(); + if (!matches(current, call)) return 'The delivered checkpoint changed before submission. Do not retry this stale payload.'; + session.write({ checkpoint_delivery: { ...current.checkpoint_delivery, attempted_at: at } }); + return null; + } catch (error) { + return error.code === 'EEXIST' + ? 'This passive checkpoint was already attempted. Do not retry an ambiguous submission.' + : 'Could not persist the passive checkpoint claim. Submission is blocked to prevent duplicate recording.'; + } +} + +function attempted(session, state, call) { + return matches(state, call) && !!state.checkpoint_delivery.attempted_at && + fs.existsSync(attemptPath(session, call.state_updates.codex_checkpoint_id)); +} + +module.exports = { claim, attempted }; diff --git a/plugins/forge/hooks/hook-input.cjs b/plugins/forge/hooks/hook-input.cjs new file mode 100644 index 0000000..51a8798 --- /dev/null +++ b/plugins/forge/hooks/hook-input.cjs @@ -0,0 +1,19 @@ +'use strict'; + +// Normalize once before inspection or enrichment. Invalid input stays invalid; +// it must never be spread into character-indexed state or treated as completion. +function record(value) { + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { return null; } + } + return value && typeof value === 'object' && !Array.isArray(value) ? value : null; +} + +function stateCall(input) { + const call = record(input); + if (!call) return null; + const updates = call.state_updates === undefined ? {} : record(call.state_updates); + return updates ? { ...call, state_updates: updates } : null; +} + +module.exports = { record, stateCall }; diff --git a/plugins/forge/hooks/must-display.cjs b/plugins/forge/hooks/must-display.cjs index 3017ecf..14ed43c 100644 --- a/plugins/forge/hooks/must-display.cjs +++ b/plugins/forge/hooks/must-display.cjs @@ -301,20 +301,24 @@ async function main() { return; // Malformed input — exit silently. } - const sessionState = sessionStateModule.forSession(event.session_id); + // Codex: the dedup fingerprint lives in a sidecar next to the state file, + // not inside it. This hook and workflow-tracker.cjs fire on the SAME + // PostToolUse event, and two read-modify-write cycles on one file can lose + // updates. One writer per file. + const sidecar = `${sessionStateModule.forSession(event.session_id).stateFilePath}.display`; let last = null; try { - last = sessionState.read().last_display_fingerprint || null; + last = fs.readFileSync(sidecar, 'utf8').trim() || null; } catch { - last = null; // A missing/corrupt state file must not suppress the display. + last = null; // A missing/corrupt sidecar must not suppress the display. } const result = decide(event, last); if (!result.systemMessage) return; try { - sessionState.write({ last_display_fingerprint: result.fingerprint }); + fs.writeFileSync(sidecar, result.fingerprint, 'utf8'); } catch { // Persisting dedup state is best-effort; showing the block is not. } diff --git a/plugins/forge/hooks/passive-observation.cjs b/plugins/forge/hooks/passive-observation.cjs new file mode 100644 index 0000000..8bc1454 --- /dev/null +++ b/plugins/forge/hooks/passive-observation.cjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +'use strict'; + +// Codex-only delivery helpers. No network, no model turn, no implicit consent. +const path = require('path'); +const sessionStateModule = require('./session-state.cjs'); +const CHECKPOINT_INTERVAL = 8; +const DISPOSITIONS = ['observe', 'skip', 'defer', 'sleep']; +// Queued ids are always randomUUID() from stop-observer.cjs; anything else is +// dropped, never delivered. +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +// Arguments that reach the acknowledge command are validated first. +const SAFE_ARG_RE = /^[A-Za-z0-9._-]{1,128}$/; + +function additionalContext(text) { + return JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } }); +} + +function oneLine(text, max = 200) { + return String(text || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max); +} + +// The complete receipt command, built here from validated values so the model +// never assembles it from file contents. Only the final token is for the model. +function acknowledgeCommand(sessionId, id) { + if (!SAFE_ARG_RE.test(String(sessionId || '')) || !UUID_RE.test(String(id || ''))) return null; + return `node "${path.join(__dirname, 'passive-observation.cjs')}" acknowledge ${sessionId} ${id} <${DISPOSITIONS.join('|')}>`; +} + +// Returns developer-context TEXT for this prompt, or ''. prompt-router.cjs +// joins it with any routing text and wraps the result once, so routing and +// passive delivery never compete for the same prompt. +function deliver(session, state, event) { + const at = new Date().toISOString(); + if (state.active_workflow || state.pending_checkpoint) return ''; + if (event.turn_id && state.last_passive_prompt_turn === event.turn_id) return ''; + // A Forge clarification (start_workflow returned without a conversation) is + // being answered on the very next prompt. workflow-tracker.cjs stamps the + // turn; the window is one prompt wide by construction, so nothing has to + // clear it. (observer_blocked cannot carry this: prompt-router re-arms it + // before delivery runs.) + if (state.clarification_at_turn != null && (state.turn_count || 0) - state.clarification_at_turn <= 1) return ''; + if (state.passive_checkpoint_due) { + const pending = state.passive_checkpoint_due; + if (!UUID_RE.test(String(pending.id || ''))) { session.write({ passive_checkpoint_due: null }); return ''; } + // Upgrade an undelivered legacy queue safely; already-delivered id-less + // payloads are rejected by the guard because they cannot prove identity. + pending.state_updates = { ...pending.state_updates, codex_checkpoint_id: pending.id }; + session.write({ passive_checkpoint_due: null, delivered_checkpoint: pending, + last_passive_prompt_turn: event.turn_id || null, + skills_flushed_at_turn: pending.skills_through, + checkpoint_delivery: { id: pending.id, at, attempted_at: null, processed_at: null } }); + return `FORGE PASSIVE CHECKPOINT: Read the existing forge-autopilot skill's ` + + `Codex passive delivery instructions. Session state: ${JSON.stringify(session.stateFilePath)}. ` + + `Process delivered_checkpoint ${pending.id} once in this active turn; preserve the user's substantive final answer.`; + } + if (state.status === 'dismissed' || state.status === 'linked' || state.status === 'logged' || state.forge_observation_enabled === false) { + if (state.observation_due) session.write({ observation_due: null }); + return ''; + } + const due = state.observation_due; + // A snoozed session is checked against its wake condition on every prompt + // (as in the Claude Code plugin); the eight-turn re-offer queued by stop-observer.cjs + // takes precedence on the prompt it lands on. + if (state.status === 'snoozed' && !due && state.wake_condition) { + return `FORGE ROUTING: The tracking offer in this session is snoozed. Wake condition: ` + + `"${oneLine(state.wake_condition)}". If the user's current message clearly satisfies it, invoke the ` + + `"forge-autopilot" skill via the Skill tool with the input "observe session — start the observe_session ` + + `workflow for passive tracking" after completing the user's request. Otherwise continue normally and ` + + `do NOT mention this check to the user.`; + } + if (!due) return ''; + if (!UUID_RE.test(String(due.id || ''))) { session.write({ observation_due: null }); return ''; } + session.write({ observation_due: null, delivered_observation: due, + observer_fired: true, observer_blocked: true, last_observer_turn: state.turn_count, + last_passive_prompt_turn: event.turn_id || null, + observation_delivery: { id: due.id, at, processed_at: null, disposition: null } }); + const command = acknowledgeCommand(event.session_id || state.session_id, due.id); + return `FORGE PASSIVE OBSERVATION: Read the existing forge-autopilot skill's ` + + `Codex passive delivery instructions. Session state: ${JSON.stringify(session.stateFilePath)}. ` + + `Evaluate delivered_observation ${due.id} once in this active turn; preserve the user's substantive final answer. ` + + (command + ? `Record your evaluation by running exactly this command, replacing only the final token: ${command}` + : `No receipt command is available for this session; do not construct one.`); +} + +// A local evaluation receipt distinguishes delivered context from context the +// model actually processed. It neither logs work remotely nor grants consent. +function acknowledge(sessionId, id, disposition) { + if (!SAFE_ARG_RE.test(String(sessionId || '')) || !UUID_RE.test(String(id || '')) || + !DISPOSITIONS.includes(disposition)) return false; + const session = sessionStateModule.forSession(sessionId); + const state = session.read(); + if (state.observation_delivery?.id !== id || state.observation_delivery.processed_at) return false; + const updates = { observation_delivery: { ...state.observation_delivery, + processed_at: new Date().toISOString(), disposition } }; + // `defer` means "not now, but this session still wants the offer": re-arm the + // fire-once latch so stop-observer.cjs can queue it again after its cooldown. + // `skip` and `observe` keep the latch (asked and answered); `sleep` leaves a + // snoozed session on its own wake schedule. + if (disposition === 'defer') Object.assign(updates, { observer_fired: false, observer_blocked: false }); + session.write(updates); + return true; +} + +if (require.main === module) { + if (process.argv[2] !== 'acknowledge' || !acknowledge(...process.argv.slice(3))) process.exitCode = 1; +} +module.exports = { CHECKPOINT_INTERVAL, additionalContext, deliver, acknowledge, acknowledgeCommand }; diff --git a/plugins/forge/hooks/prompt-router.cjs b/plugins/forge/hooks/prompt-router.cjs index 6441856..c746633 100644 --- a/plugins/forge/hooks/prompt-router.cjs +++ b/plugins/forge/hooks/prompt-router.cjs @@ -1,48 +1,22 @@ #!/usr/bin/env node /** - * prompt-router.js — UserPromptSubmit hook for the ShipToday Forge plugin. - * - * Mostly stateful routing — content-based pattern matching for SDLC - * vocabulary (PRD, story breakdown, tech handoff, etc.) has been removed. - * The LLM decides whether to invoke `forge-autopilot` for those cases - * via its SKILL.md description. - * - * The hook fires for two things the LLM cannot reliably decide on its own: - * - * 1. **Epic key references** (e.g. "explore architecture of PROJ-615"). - * Skill discovery is a soft signal and Claude can choose to bypass - * Forge when it has alternative tools (Linear MCP, Read, Grep) that - * look usable. A regex match on a tracked work item id is a strong - * structural signal and gets an ADVISORY routing directive — a hint - * that surfaces the key and recommends Forge, but yields agency to - * Claude when the conversation context warrants a different route. - * The regex is purely structural — it knows nothing - * about workflows or skills, so adding new ones requires no changes - * here. - * - * 2. **Stateful routing** for things stored on disk by other hooks: - * - active workflow continuation (workflow-tracker writes this) - * - snoozed wake check (session_observer writes this) - * - * Execution order (first match wins): - * 0. Seed the git baseline (SHI-906) — silent, once per session, before - * any of the routing below and before this turn's work happens - * 1. Linked → silent (already tracked, no directive needed) - * 2. Active workflow → emit continuation directive - * 3. Epic key in prompt → emit epic-key routing directive - * 4. Snoozed → emit wake check - * 5. Otherwise → silent (let the LLM decide via SKILL.md description) - * - * @see plugin/hooks/session-state.cjs for state management - * @see plugin/hooks/stop-observer.cjs for passive observation - * @see plugin/skills/forge-autopilot/SKILL.md for routing logic + * Codex UserPromptSubmit routing. Required workflow continuation comes first. + * Explicit work-item routing and queued passive developer context are then + * delivered TOGETHER in one additionalContext payload, so a prompt that + * mentions a work item can never starve a queued checkpoint or offer. Linked + * sessions stay silent on key routing (as in the Claude Code plugin — the work + * is already tracked) but still receive their passive context. Stop schedules + * observation/checkpoints locally; a snoozed session gets a short per-prompt + * wake check from passive-observation.cjs. + * Preserve this Codex adaptation during plugin synchronization. */ 'use strict'; const sessionStateModule = require('./session-state.cjs'); const { readHeadRef } = require('./git-head.cjs'); +const { deliver, additionalContext } = require('./passive-observation.cjs'); // -- Detection patterns ------------------------------------------------------ @@ -53,18 +27,7 @@ const EPIC_KEY_RE = /\b[A-Z][A-Z0-9]+-\d+\b/; // -- Directive builders ------------------------------------------------------ -function emitWakeConditionCheck(wakeCondition) { - process.stdout.write( - `FORGE ROUTING: This session is snoozed. ` + - `Wake condition: "${wakeCondition}". ` + - `Evaluate if the user's current message signals readiness. ` + - `If yes, you MUST invoke the "forge-autopilot" skill via the Skill tool BEFORE responding ` + - `with the input: "observe session — start the observe_session workflow for passive tracking". ` + - `If no, continue normally and do NOT mention this check to the user.` - ); -} - -function emitEpicKeyRouting(key) { +function epicKeyRouting(key) { // Advisory tone (was forced "MUST invoke"). The server now handles // cited-reference disambiguation via `needsKeyConfirmation`, // so the hook no longer needs to force the routing path. The hint @@ -72,7 +35,7 @@ function emitEpicKeyRouting(key) { // from grabbing the work item directly via tracker MCP tools when // Forge is the appropriate orchestrator — but final agency stays with // Claude when the conversation context warrants a different choice. - process.stdout.write( + return ( `FORGE ROUTING (advisory): The user's message references work item "${key}". ` + `Consider invoking the "forge-autopilot" skill via the Skill tool — Forge orchestrates ` + `the SDLC actions (planning, implementation, review, status) for tracked work items, ` + @@ -169,39 +132,37 @@ async function main() { state.observer_blocked = false; // keep local copy in sync for downstream checks } - // Step 1: Linked sessions need no directives — already tracked - if (state.status === 'linked') return; - // Step 2: Active workflow → tell Claude to continue, not start fresh if (state.active_workflow) { emitWorkflowContinuation(state.conversation_id, state.current_skill); return; } - // Step 3: Epic key in prompt → forced routing directive (wins over - // snoozed/dismissed because the user is explicitly referencing tracked work). - // This is the only content-based signal the hook acts on. It catches the - // case where Claude would otherwise bypass Forge in favor of fetching the - // work item directly via Linear/Jira/etc. - if (prompt) { + const parts = []; + + // Step 3: Epic key in prompt → advisory routing directive. This is the only + // content-based signal the hook acts on. It catches the case where Claude + // would otherwise bypass Forge in favor of fetching the work item directly + // via Linear/Jira/etc. A linked session is already tracked, so it gets no + // nudge (as in the Claude Code plugin) — its passive context still flows. + if (prompt && state.status !== 'linked') { const keyMatch = prompt.match(EPIC_KEY_RE); if (keyMatch) { sessionState.write({ routing_emitted: true }); - emitEpicKeyRouting(keyMatch[0]); - return; + parts.push(epicKeyRouting(keyMatch[0])); } } - // Step 4: Snoozed → ask Claude to re-evaluate against the wake condition - if (state.status === 'snoozed') { - const wake = state.wake_condition || 'user signals readiness to move forward'; - emitWakeConditionCheck(wake); - return; - } + // Codex localization: Stop only queues work. Deliver it once as developer + // context on a real prompt — alongside routing, never instead of it, so a + // work-item mention can't hold back a queued checkpoint whose time is + // already reserved. + const passive = deliver(sessionState, state, event); + if (passive) parts.push(passive); // Step 5: No state worth acting on → silent. The LLM reads the - // forge-autopilot SKILL.md description and decides whether to invoke - // it. stop-observer.cjs handles passive observation after the response. + // forge-autopilot SKILL.md description and decides whether to invoke it. + if (parts.length) process.stdout.write(additionalContext(parts.join('\n\n'))); } main().catch(() => { diff --git a/plugins/forge/hooks/session-state.cjs b/plugins/forge/hooks/session-state.cjs index 0b6532e..c1d7052 100644 --- a/plugins/forge/hooks/session-state.cjs +++ b/plugins/forge/hooks/session-state.cjs @@ -116,7 +116,34 @@ function statePath(sessionId) { function ensureDir() { if (!fs.existsSync(STATE_DIR)) { - fs.mkdirSync(STATE_DIR, { recursive: true }); + // Codex: owner-private where the platform honours modes (POSIX; ignored on + // Windows). + fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }); + } +} + +// Codex: rename-over on Windows fails with EPERM (EACCES/EBUSY on some +// filesystems) while another process has the target open — and the two +// PostToolUse hooks run concurrently on every tool call, so that is common, +// not rare. A dropped write here silently loses a workflow-completion reset +// and leaves a stale allowlist enforcing for hours. The contention window is +// microseconds; retry with a short bounded backoff (≤ ~180 ms total). +const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']); +const RENAME_ATTEMPTS = 8; + +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function renameWithRetry(from, to) { + for (let attempt = 0; ; attempt++) { + try { + fs.renameSync(from, to); + return; + } catch (err) { + if (!RENAME_RETRY_CODES.has(err.code) || attempt >= RENAME_ATTEMPTS) throw err; + sleepSync(5 * (attempt + 1)); + } } } @@ -145,6 +172,17 @@ function freshState(sessionId) { routing_emitted: false, active_workflow: false, observer_blocked: false, + // Codex-only passive queue/delivery state. Missing fields in older files + // are treated as empty; delivered does not mean processed or authorized. + observation_due: null, + delivered_observation: null, + observation_delivery: null, + passive_checkpoint_due: null, + delivered_checkpoint: null, + checkpoint_delivery: null, + last_stop_turn_id: null, + last_passive_prompt_turn: null, + last_checkpoint_turn: 0, last_observer_turn: null, last_checkpoint_at: null, conversation_id: null, // Forge conversation ID for active workflow @@ -219,7 +257,10 @@ function cleanupStale() { const files = fs.readdirSync(STATE_DIR); const now = Date.now(); for (const file of files) { - if (!file.endsWith('.json')) continue; + // .tmp: a write interrupted before its rename. .display: must-display's + // per-session breadcrumb sidecar (kept out of the state file so the two + // PostToolUse hooks never contend for one write). + if (!file.endsWith('.json') && !file.endsWith('.tmp') && !file.endsWith('.display') && !file.endsWith('.attempt')) continue; const fp = path.join(STATE_DIR, file); const stat = fs.statSync(fp); if (now - stat.mtimeMs > CLEANUP_AGE_MS) { @@ -243,9 +284,17 @@ function cleanupStale() { function forSession(sessionId) { const fp = statePath(sessionId); + // Codex: atomic replace. The temp file is owner-private (0600 where honoured) + // and renamed over the state file, so a reader never sees a torn write. function writeRaw(state) { ensureDir(); - fs.writeFileSync(fp, JSON.stringify(state, null, 2), 'utf8'); + const temporary = `${fp}.${process.pid}.${crypto.randomUUID()}.tmp`; + try { + fs.writeFileSync(temporary, JSON.stringify(state, null, 2), { encoding: 'utf8', mode: 0o600 }); + renameWithRetry(temporary, fp); + } finally { + try { fs.unlinkSync(temporary); } catch { /* renamed or absent */ } + } } /** diff --git a/plugins/forge/hooks/stop-observer.cjs b/plugins/forge/hooks/stop-observer.cjs index 3c23302..c14414f 100644 --- a/plugins/forge/hooks/stop-observer.cjs +++ b/plugins/forge/hooks/stop-observer.cjs @@ -1,299 +1,30 @@ #!/usr/bin/env node - -/** - * stop-observer.js — Stop hook for passive session observation. - * - * Fires after Claude finishes responding. If the session is untracked, - * blocks Claude's exit and provides a continuation turn where Claude can - * evaluate the session and invoke session_observer for passive tracking. - * - * This replaces the old two-step handoff (AssistantResponse sets flag → - * UserPromptSubmit emits nudge). The Stop hook lets Claude answer the - * user's question FIRST, then evaluate for tracking — no interruption. - * - * Execution: - * 1. Exit if stop_hook_active (prevent infinite loop) - * 2. Increment turn_count (tracks conversation progress) - * 3. For linked/logged: checkpoint when EITHER CHECKPOINT_INTERVAL turns - * have passed OR TIME_FLOOR_MS of wall-clock has elapsed since the last - * checkpoint — whichever comes first (FLUSH_INTERVAL turns when skill - * invocations are pending). The time floor is the load-bearing part: - * turns are a poor proxy for engineering time, so a pure turn count can - * leave large un-banked gaps on long-turn sessions. The silent audit - * event captures elapsed engineering time as a DELTA since the last - * checkpoint; the dashboard SUMs deltas, so firing more often only - * changes granularity, not the aggregate total. Runs regardless of - * forge_observation_enabled because the gate is about the observation - * NUDGE, not about engineering-time tracking for already-tracked - * sessions. - * 3b. Exit silently if the per-session cache says the org - * admin has disabled observation (forge_observation_enabled = - * false). Steady-state zero-roundtrip — no MCP call until the - * next Claude Code session starts (which begins with a fresh - * cache). Field is written into the cache by the session_observer - * gated path when it first detects the admin opt-out. - * 4. For snoozed: re-fire observer every CHECKPOINT_INTERVAL turns - * (re-prompts user to track) - * 5. Exit if dismissed (terminal — never re-fires) - * 5b. Exit if status is set (observer already ran — defensive guard) - * 6. Exit if active_workflow (workflow tracks its own time) - * 7. Exit if observer_blocked (already evaluated this session) - * 8. Set observer_blocked = true in session state - * 9. Block with reason directing Claude to evaluate and invoke session_observer - * - * Design principles: - * - Fires after Claude responds — user gets their answer first. - * - Blocks only once per session for initial observation (observer_blocked flag). - * - Re-fires periodically for linked/logged sessions to capture - * engineering time that would otherwise go untracked. - * - Re-fires periodically for snoozed sessions to re-prompt the user. - * - active_workflow is set by forge-autopilot after a workflow actually - * starts — not by prompt-router at detection time. This means if - * Claude ignores a routing directive, active_workflow stays false - * and this hook will still fire for passive observation. - * - Checkpoint baseline (last_checkpoint_at) advances when the directive is - * EMITTED, not when the AI confirms the forge__update_state write. This is - * deliberate: the delta is baked into the directive at emit time, so the - * baseline must advance by exactly that delta to keep consecutive deltas - * non-overlapping. If it only advanced on a confirmed write, a re-emit - * (the time floor tripping again before a slow/ignored write lands) would - * re-measure the same interval and, if both writes land, DOUBLE-COUNT — - * inflating customer-facing engineering-time/ROI totals. Over-counting is - * worse than under-counting, and the loss from one ignored checkpoint is - * now bounded by TIME_FLOOR_MS (it was effectively unbounded before the - * time floor — a single 92.8-min delta was observed in the wild). - * - No end-of-session flush. The SessionEnd hook event is observability-only: - * it cannot block or drive a model tool call (verified against the Claude - * Code hook docs), and Codex/Cursor expose no model-driving session-end - * event either — so a final checkpoint cannot be forced at exit. The - * residual un-banked tail on a clean exit is therefore bounded by - * TIME_FLOOR_MS (plus the final turn's duration); the time floor IS the - * portable end-of-session safety net. - * - The reason text tells Claude to invoke forge-autopilot for tracking. - * - * @see plugin/hooks/prompt-router.cjs for active PDLC/epic detection - * @see plugin/hooks/session-state.cjs for state management - * @see plugin/skills/forge-autopilot/SKILL.md for routing logic - */ - 'use strict'; +// Codex localization: passive Stop work is saved locally, never returned as a +// blocking reason (Codex turns that reason into a synthetic user message). +// Preserve this file during plugin sync. Claude's Stop behavior is unchanged. const sessionStateModule = require('./session-state.cjs'); const { resolveSessionRecords, captureTokenUsageFromResolved } = require('./token-usage.cjs'); const { activeMsFromResolved } = require('./active-time.cjs'); const { readHeadRef } = require('./git-head.cjs'); - -// -- Constants ---------------------------------------------------------------- - -const CHECKPOINT_INTERVAL = 8; // turns between checkpoint audit events -const FLUSH_INTERVAL = 3; // turns between checkpoints when skill invocations are pending - -// Wall-clock cap between checkpoints, independent of turn cadence. Turns are a -// poor proxy for engineering time: long research/implementation turns can run -// ~10 min each, so a pure turn count of CHECKPOINT_INTERVAL could leave a -// ~90-min gap (an unbroken 92.8-min delta was observed in the wild). A -// checkpoint fires when EITHER the turn interval OR this time floor is reached, -// so a handful of very long turns can't leave a large un-banked gap. Tunable: -// smaller = better crash resilience / tighter granularity, larger = fewer -// silent forced-continuation turns (lower token overhead). -const TIME_FLOOR_MS = 10 * 60 * 1000; // 10 minutes - -// SHI-906: eligibility floor for the FIRST nudge of a session. The observer -// used to become eligible at the end of turn 1, before there was any signal -// about what the session was even about, so it read as onboarding noise — -// and because a dismissal was terminal, that one bad impression was also the -// last one. These hold it back until the session has actually produced -// something to talk about. Tunable: smaller = the offer arrives sooner but -// on thinner evidence, larger = fewer interruptions but more missed moments. -// Deliberately lower than the checkpoint constants above: this is "has -// anything happened yet", not "how often should we bank time". -const TURN_FLOOR = 4; // turns before the first nudge is eligible -const ACTIVE_FLOOR_MS = 5 * 60 * 1000; // ...or this much ACTIVE working time - -/** - * Has HEAD moved since this session was first observed? - * - * The baseline is normally seeded by prompt-router.cjs on the session's - * FIRST PROMPT — before any work has happened — so a commit made during - * turn 1 is already a difference by the time this runs. Seeding here is the - * fallback for a session whose prompt hook never ran (older state, a hook - * that failed): the first sighting SEEDS and reports no milestone, because - * treating it as one would fire the nudge on turn 1 of every repo session — - * reintroducing exactly the noise the eligibility floor removes. - * - * The read itself lives in git-head.cjs, shared with the prompt hook. - */ -function milestoneReached(state, sessionState) { - const head = readHeadRef(process.cwd()); - if (!head) return false; - if (!state.git_head_baseline) { - sessionState.write({ git_head_baseline: head }); - return false; - } - if (head === state.git_head_baseline) return false; - // ADVANCE the baseline when a milestone is consumed. Leaving it stale - // would make one commit justify every subsequent check for the rest of - // the session — harmless while the nudge fires only once, but SHI-907 - // lets a soft decline bring the offer back, and a permanently-true - // milestone would re-fire it on every Stop from then on. That is the - // over-prompting the error-handling NFR explicitly ranks as worse than - // a missed offer. - sessionState.write({ git_head_baseline: head }); - return true; -} - -// -- Directives --------------------------------------------------------------- - -/** - * Build a silent checkpoint directive. Tells Claude to call forge__update_state - * with the engineering-time delta — no user interaction, no visible output. - * `durationMs` is the ACTIVE time since the last checkpoint (idle excluded, see - * active-time.cjs) when a session log is available, else wall-clock elapsed. - * - * The directive embeds `last_observer_conversation_id` — the conversation - * the observe_session run completed on. Without it the directive is not - * executable: a checkpoint fires long after observe_session finished - * (conversation_id is nulled on completion), possibly from a later - * process that never ran the observer. Returns '' when that id is absent - * (state file predates the field) so no un-executable directive is sent. - */ -function buildCheckpointResponse(durationMs, state, stateFilePath, event, resolved) { - const conversationId = state.last_observer_conversation_id; - if (!conversationId) return ''; - - // Collect skill invocations that haven't been flushed yet - const flushedAt = state.skills_flushed_at_turn || 0; - const allInvocations = state.skill_invocations || []; - const unreported = allInvocations.filter((_, i) => i >= flushedAt); - const skillPayload = unreported.length > 0 - ? `, skill_invocations: ${JSON.stringify(unreported.map(inv => inv.name))}` - : ''; - - // Piggyback per-session token capture on the SAME checkpoint - // directive — no new hook, no extra round-trip. The caller resolved the - // session log ONCE (main + sub-agent files; review #10) and the same parsed - // records fed the active-time delta above — here they yield the CUMULATIVE - // raw token components, which the orchestrator writes to a separate - // `event_type: token_usage` row. The snapshot is cumulative, so the read - // side's latest-per-session dedup makes a re-emitted or skipped checkpoint - // safe. Capture failures return '' worth of payload — token capture must - // never perturb the engineering-time checkpoint (NFR: error handling). - let tokenPayload = ''; - // The Claude coding-session id, carried on the SAME directive so the read side - // collapses this session's snapshots across all its conversations (on the - // server). Set UNCONDITIONALLY — the checkpoint also writes a non-token - // observation_outcome row (the engineering-time bank) that must carry - // client_session_id so the Overview "Total Sessions" count keys on the coding - // session. The token branch below adds it to the token_usage row too; this - // line ensures it lands even when capture returns nothing. The workflow-guard - // hook can't add it here — the directive already carries this payload, so the - // guard's non-clobber check skips this checkpoint. - let clientSessionPayload = event.session_id - ? `, client_session_id: "${event.session_id}"` - : ''; - try { - const tokens = captureTokenUsageFromResolved(resolved); - if (tokens) { - // Emit one component bag PER model (tokens.byModel) so - // the orchestrator writes a per-model token_usage row — a delegated - // session (Opus main + Sonnet sub-agent) is then weighted per model at - // read. Fall back to the combined single bag if an adapter lacks byModel. - const models = Array.isArray(tokens.byModel) && tokens.byModel.length - ? tokens.byModel - : [tokens]; - const tu = models.map((m) => ({ - input: m.input, - cache_read: m.cacheRead, - cache_creation_5m: m.cacheCreation5m, - cache_creation_1h: m.cacheCreation1h, - cache_creation_flat: m.cacheCreationFlat, - output: m.output, - model_name: m.modelName, - })); - tokenPayload = `, token_usage: ${JSON.stringify(tu)}`; - } - } catch { - tokenPayload = ''; - } - - return JSON.stringify({ - decision: 'block', - reason: - `FORGE CHECKPOINT: This is an automatic, silent checkpoint to record engineering time. ` + - `Do NOT mention this to the user. Do NOT ask any questions. ` + - `Session state file: ${stateFilePath} ` + - `Immediately invoke the "forge-autopilot" skill via the Skill tool with the input: ` + - `"session checkpoint — call forge__update_state with ` + - `conversation_id: ${conversationId}, completed_step: session_observer, ` + - `state_updates: { outcome: checkpoint, duration_ms: ${durationMs}, ` + - `event_type: observation_outcome, ` + - `work_item_key: ${state.work_item_key || 'null'}, sdlc_stage: ${state.sdlc_stage || 'other'}${skillPayload}${tokenPayload}${clientSessionPayload} }". ` + - `After calling the tool, continue normally without any additional output about this checkpoint.`, - }); -} - -// Concise directive. The hook only needs the model to make the -// invoke/skip decision and call observe_session — the full SDLC taxonomy and -// false-negative classification live server-side in the session_observer -// skill. Keeping this short matters -// because some clients (Codex, Cursor) surface the Stop-hook block reason to -// the user verbatim, where the old ~30-line block read as noise. -function buildBlockResponse(stateFilePath, turnCount = null, activeMs = null, declinedOnce = false) { - // SHI-906 AC3: the nudge's "why now" must match how long the session has - // ACTUALLY been running. The eligibility gate already computed both - // figures to make its decision, so they are threaded through here rather - // than re-derived — a separately-computed number could disagree with the - // one that opened the gate, which is precisely the inconsistency the AC - // is about. Omitted (null) from the snooze re-fire path, where the - // session's elapsed figures are not the reason the offer is returning. - // Some clients surface this copy verbatim, so it has to read as prose: - // singular at 1 (a git milestone can open the gate on turn 1), and no - // "0 minutes" clause when active time rounds down to nothing. - const minutes = Number.isFinite(activeMs) && activeMs > 0 ? Math.round(activeMs / 60000) : 0; - const elapsed = [ - Number.isFinite(turnCount) ? `${turnCount} ${turnCount === 1 ? 'turn' : 'turns'}` : null, - minutes >= 1 ? `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} of active work` : null, - ].filter(Boolean).join(' and '); - const context = elapsed - ? `This session has been going for ${elapsed}. Say so if you explain why you are checking in now. ` - : ''; - // SHI-907 AC4: a re-offer after a soft decline must not repeat the first - // one verbatim. The user already said no once; asking again in identical - // words reads as not having listened — and since a soft decline and a - // snooze now behave alike, this copy is the only difference they can - // actually perceive. - const reoffer = declinedOnce - ? `The user already declined this once in this session, so do NOT repeat the earlier offer word for word: ` - + `open by acknowledging that, say briefly what has changed since, and keep it shorter than the first. ` - : ''; - return JSON.stringify({ - decision: 'block', - reason: - `FORGE OBSERVATION: ${context}${reoffer}Before stopping, decide whether this session involved any product ` + - `or engineering work across the software development lifecycle (SDLC) — defining, planning, ` + - `building, testing, reviewing, or discussing code or features (reading or analyzing code to ` + - `understand it counts). If it did, you MUST invoke the "forge-autopilot" skill via the Skill ` + - `tool with the input: "observe session — start the observe_session workflow for passive tracking". ` + - `Skip ONLY if the session was purely general-knowledge Q&A, tool help, or casual chat with no ` + - `project context; when in doubt, invoke. Do NOT mention this check to the user. ` + - `Session state file: ${stateFilePath}`, - }); -} - -/** - * Continuation directive for the required-skill stall. - * - * Fired when a Forge workflow step is mid-flight, the model invoked a local - * skill (e.g. a required security-review whose prompt says "reply with only - * its output"), and then ended its turn WITHOUT calling forge__update_state. - * Blocks the stop and tells the model that a skill's "nothing else" - * instruction governs the skill's OUTPUT FORMAT — not the turn boundary — and - * that forge__update_state is mandatory before the turn may end. Fires at most - * once per stall (event.stop_hook_active guards a second block), so it can - * never loop. Concise on purpose — some clients surface the block reason to - * the user verbatim. - */ -function buildSkillContinuationResponse(state) { +const { randomUUID } = require('crypto'); +const { CHECKPOINT_INTERVAL } = require('./passive-observation.cjs'); + +const FLUSH_INTERVAL = 3; +const TIME_FLOOR_MS = 10 * 60 * 1000; +// SHI-906: eligibility floor for the FIRST offer of a session — turns OR +// active time OR a git milestone. Its placement below is load-bearing: it runs +// BEFORE anything latches the fire-once flags, so an ineligible Stop never +// spends the session's one offer without the user ever seeing it. +const TURN_FLOOR = 4; +const ACTIVE_FLOOR_MS = 5 * 60 * 1000; + +// The only blocking Stop response left on Codex, worded as in the Claude Code +// plugin: it names the step to send as completed_step and says the call is +// mandatory before the turn may end. Fires at most once per stall — +// event.stop_hook_active guards a second block — so it can never loop. +function skillContinuation(state) { const convo = state.conversation_id || ''; const step = state.current_step_skill || state.current_skill || 'the current step'; return JSON.stringify({ @@ -309,253 +40,106 @@ function buildSkillContinuationResponse(state) { }); } -// -- Main -------------------------------------------------------------------- - async function main() { - // Parse Stop hook event from stdin - let event = {}; let input = ''; - for await (const chunk of process.stdin) { - input += chunk; - } - try { - event = JSON.parse(input); - } catch { - // Malformed input — exit silently - return; - } - - // Step 1: Prevent infinite loop — already in a forced-continuation state + for await (const chunk of process.stdin) input += chunk; + const event = JSON.parse(input); if (event.stop_hook_active) return; - - // Read session state — scoped to this Claude Code session. - const sessionState = sessionStateModule.forSession(event.session_id); - const state = sessionState.read(); - - // Step 1b: required-skill continuation backstop. - // A workflow is active and the model invoked a local skill mid-step (e.g. a - // required security-review), then ended its turn WITHOUT calling - // forge__update_state — and we are NOT at a relayed-question / confirmation - // checkpoint (those are legitimate pauses for user input). The step stalled. - // Block ONCE and direct the model to relay the findings and call - // forge__update_state. event.stop_hook_active (checked above) guarantees a - // second consecutive stop is NOT re-blocked, so this can never loop: one - // nudge, then if the model still stops the workflow simply pauses and the - // user can resume by saying "continue". Disarm the flag so the single nudge - // is not repeated for the same stall. + const session = sessionStateModule.forSession(event.session_id); + const state = session.read(); + if (event.turn_id && state.last_stop_turn_id === event.turn_id) return; + const now = Date.now(); + const at = new Date(now).toISOString(); + // Writes are batched: every write() is a read, a temp file and a rename, and + // on Windows each extra rename is another chance to collide with a hook that + // still has the file open (see session-state.cjs). The Stop turn identity + // rides along with whichever write happens first. + const seen = event.turn_id ? { last_stop_turn_id: event.turn_id } : {}; if (state.active_workflow && state.pending_skill_continuation && !state.pending_checkpoint) { - sessionState.write({ pending_skill_continuation: false }); - process.stdout.write(buildSkillContinuationResponse(state)); + session.write({ ...seen, pending_skill_continuation: false }); + process.stdout.write(skillContinuation(state)); return; } - - // Step 2: Increment turn count (but not for forced continuations) - sessionState.increment('turn_count'); - state.turn_count = (state.turn_count || 0) + 1; // keep local copy in sync - - // Step 3: Linked/logged sessions — silent checkpoint every CHECKPOINT_INTERVAL - // turns (or FLUSH_INTERVAL if there are pending skill invocations to report), - // OR every TIME_FLOOR_MS of wall-clock — whichever comes first. - // Also entered when the ORG has observation switched off. The gate used to be - // `status === 'linked' || 'logged'` alone — a user-engagement state — which - // made the checkpoint, and the token capture riding on it, unreachable for - // such an org: the nudge below never runs, so the user is never asked to link - // or log, so `status` stays null forever and this branch never opened. That - // org could not have captured a token here even in principle. - // - // Keyed on `forge_observation_enabled === false` specifically, NOT on - // `status == null`. Null conflates two cases that need opposite handling: - // "not asked YET" (must fall through to the nudge) and "will never be asked" - // (nothing to starve, so capture here). Only the org flag distinguishes them. - // Widening to null instead silently converted every not-yet-nudged session - // into a checkpoint-only session and suppressed the nudge permanently. - // - // Sessions reaching this branch via the org flag return at the `!directive` - // check below when there is no observer conversation to attach to — the same - // outcome as the `forge_observation_enabled === false` short-circuit further - // down, so no behaviour is lost either way. - const observationDisabledForOrg = state.forge_observation_enabled === false; - if (state.status === 'linked' || state.status === 'logged' || observationDisabledForOrg) { - if (state.active_workflow) return; // Forge skills track their own time - const turnsSinceLast = state.turn_count - (state.last_observer_turn || 0); - // Use shorter interval when local skill invocations are pending - const hasPendingSkills = (state.skill_invocations || []).length > (state.skills_flushed_at_turn || 0); - const interval = hasPendingSkills ? FLUSH_INTERVAL : CHECKPOINT_INTERVAL; - // Elapsed duration since last checkpoint (or link/log moment). Computed - // BEFORE the early-return so it can gate the return alongside the turn - // count: fire on turn cadence OR when the wall-clock floor is exceeded. - const lastCheckpoint = state.last_checkpoint_at || state.session_start; - const lastCheckpointMs = new Date(lastCheckpoint).getTime(); - const elapsedMs = Date.now() - lastCheckpointMs; - if (turnsSinceLast < interval && elapsedMs < TIME_FLOOR_MS) return; - // R1: bank ACTIVE engineering time (idle excluded) as the checkpoint delta, - // not wall-clock. The firing gate ABOVE deliberately still uses wall-clock - // `elapsedMs` — we want periodic checkpoints on a wall-clock cadence — but - // the recorded duration is the active time since the last checkpoint, so a - // long idle gap between turns (e.g. 3h away, then one quick prompt) is not - // banked as engineering time. Falls back to `elapsedMs` when no session log - // is available (Cursor / unreadable transcript) or when the log was - // tail-truncated past the window start (review #8). A pure-idle window - // legitimately yields ~0, which sums harmlessly. - // - // The session log is resolved ONCE here and shared with the token capture - // inside buildCheckpointResponse (review #10 — no double read/parse). + state.turn_count = (state.turn_count || 0) + 1; + session.write({ ...seen, turn_count: state.turn_count }); + if (state.active_workflow || state.pending_checkpoint) return; + + if (state.status === 'linked' || state.status === 'logged' || state.forge_observation_enabled === false) { + if (!state.last_observer_conversation_id) return; + const since = Date.parse(state.last_checkpoint_at || state.session_start); + if (!Number.isFinite(since)) return; + const skills = state.skill_invocations || []; + const reserved = state.skills_reserved_through ?? state.skills_flushed_at_turn ?? 0; + const interval = skills.length > reserved ? FLUSH_INTERVAL : CHECKPOINT_INTERVAL; + if (state.turn_count - (state.last_checkpoint_turn || 0) < interval && now - since < TIME_FLOOR_MS) return; + const old = state.passive_checkpoint_due; + // One bounded aggregate. Never overwrite another conversation's payload. + if (old && (old.conversation_id !== state.last_observer_conversation_id || + old.state_updates.sdlc_stage !== (state.sdlc_stage || 'other') || + old.state_updates.work_item_key !== (state.work_item_key || null))) return; const resolved = resolveSessionRecords(event); - const activeMs = activeMsFromResolved(resolved, lastCheckpointMs); - const durationMs = Number.isFinite(activeMs) ? activeMs : elapsedMs; - // Build the directive first. It returns '' when last_observer_conversation_id - // is absent (a state file predating that field). In that case emit nothing - // AND leave the baseline untouched, so the accumulated time is captured the - // moment a conversation id becomes available rather than being dropped here. - const directive = buildCheckpointResponse(durationMs, state, sessionState.stateFilePath, event, resolved); - if (!directive) return; - // Update state for next checkpoint and mark skills as flushed. The baseline - // (last_checkpoint_at) advances at emit time by design — see the - // "Checkpoint baseline" design principle in the file header. + const active = activeMsFromResolved(resolved, since, now); + const delta = Math.max(0, Number.isFinite(active) ? active : now - since); + const checkpointId = old?.id || randomUUID(); const updates = { - last_observer_turn: state.turn_count, - last_checkpoint_at: new Date().toISOString(), + codex_checkpoint_id: checkpointId, + outcome: 'checkpoint', event_type: 'observation_outcome', + duration_ms: (old?.state_updates.duration_ms || 0) + delta, + work_item_key: state.work_item_key || null, sdlc_stage: state.sdlc_stage || 'other', + ...(event.session_id ? { client_session_id: event.session_id } : {}), }; - if (hasPendingSkills) { - updates.skills_flushed_at_turn = (state.skill_invocations || []).length; - } - sessionState.write(updates); - // Block with silent checkpoint directive - process.stdout.write(directive); + const names = [...(old?.state_updates.skill_invocations || []), ...skills.slice(reserved).map(s => s.name)]; + if (names.length) updates.skill_invocations = [...new Set(names)].slice(-64); + // Snapshot taken at queue time is the FALLBACK for a Codex build that + // cannot rewrite tool input; where it can, workflow-guard.cjs replaces it + // with a capture taken when the call is actually made (cumulative, so the + // server keeps whichever is larger). + const tokens = captureTokenUsageFromResolved(resolved); + if (tokens) updates.token_usage = (tokens.byModel?.length ? tokens.byModel : [tokens]).map(m => ({ + input: m.input, cache_read: m.cacheRead, cache_creation_5m: m.cacheCreation5m, + cache_creation_1h: m.cacheCreation1h, cache_creation_flat: m.cacheCreationFlat, + output: m.output, model_name: m.modelName, + })); + else if (old?.state_updates.token_usage) updates.token_usage = old.state_updates.token_usage; + // Reserve interval and payload together; this is NOT a remote receipt. + // Ambiguous calls aren't retried: the server adds deltas without dedup. + session.write({ + passive_checkpoint_due: { id: checkpointId, queued_at: old?.queued_at || at, + through: at, skills_through: skills.length, conversation_id: state.last_observer_conversation_id, + completed_step: 'session_observer', state_updates: updates }, + last_checkpoint_at: at, last_checkpoint_turn: state.turn_count, skills_reserved_through: skills.length, + }); return; } - - // Step 3b: per-session observation gate cache. - // - // When the MCP-side session_observer skill runs and detects that the - // org admin has disabled observation (Clerk publicMetadata. - // forgeObservationEnabled = false, surfaced by the orchestrator's - // org-settings hydrator), its gated payload tells the parent to - // write forge_observation_enabled: false into this session's state - // file. On every subsequent Stop in the same Claude Code session, - // this check short-circuits silently so the hook does NOT re-invoke - // session_observer — saving one MCP round-trip per turn for the - // steady state. Strict `=== false` so cache misses (null / - // undefined / true / non-boolean) fall through to the normal - // directive — the hook never pre-suppresses: it fires once and lets the - // server-side gate make the authoritative opt-in decision (the org default - // is now `false`, resolved on the server). - // - // Placed AFTER the linked/logged checkpoint branch so that - // engineering-time tracking on already-tracked sessions continues - // independently of the observation toggle — the toggle gates the - // initial nudge, not silent checkpoints on linked/logged work. - // Field name shared verbatim with the Cursor build (parity). - // - // This cache is intentionally per-session (not cross-session): each new - // Claude Code / Codex / Cursor session starts with a fresh state file, so - // an admin toggling observation on/off in the dashboard is picked up on - // the very next session start. The directive fires once on the first Stop - // of each session; the gate then writes this flag (via workflow-tracker.cjs) - // so the rest of THAT session stays silent. - if (state.forge_observation_enabled === false) return; - - // Step 4: Snoozed sessions — re-fire observer every CHECKPOINT_INTERVAL turns + if (state.status === 'dismissed' || state.observation_due) return; + const turnsSince = state.turn_count - (state.last_observer_turn || 0); if (state.status === 'snoozed') { - const turnsSinceLast = state.turn_count - (state.last_observer_turn || 0); - if (turnsSinceLast < CHECKPOINT_INTERVAL) return; - // Reset state so observer can re-prompt the user. Also reset - // observer_fired so the per-session "fire once" counter restarts — - // the user explicitly asked to be re-prompted by snoozing. - // - // SHI-907: `declined_once` is deliberately NOT cleared here. It is what - // lets the returning offer acknowledge that the user already said no - // rather than repeating itself verbatim (AC4). A soft decline reaches - // this same branch — that reuse is the whole point of the design, since - // both planes already speak the snooze contract end to end and adding a - // parallel status would fail invisibly across the plane boundary. - sessionState.write({ - observer_blocked: false, - observer_fired: false, - status: null, - last_observer_turn: state.turn_count, - }); - // Block with the observer directive. The elapsed figures are omitted: - // the reason this offer is BACK is the earlier decline, not how long - // the session has run, so quoting a duration here would answer a - // question the user did not ask. - process.stdout.write(buildBlockResponse( - sessionState.stateFilePath, null, null, Boolean(state.declined_once), - )); + // SHI-907: `declined_once` is deliberately NOT cleared on the re-offer — + // it is what lets the returning offer acknowledge the earlier "no". + if (turnsSince < CHECKPOINT_INTERVAL) return; + session.write({ observation_due: { id: randomUUID(), reason: 'wake', queued_at: at, + turn_count: state.turn_count, wake_condition: state.wake_condition, + declined_once: !!state.declined_once } }); return; } - - // Step 5: Dismissed sessions — terminal, never re-fire - if (state.status === 'dismissed') return; - - // Step 5b: Defensive guard — if session has ANY known status, the observer - // already ran. Don't re-fire the initial observation. This catches edge cases - // where status was set (by workflow-tracker) but observer_blocked was reset. - if (state.status) return; - - // Step 6: Workflow actually started (set by forge-autopilot, not prompt-router) - if (state.active_workflow) return; - - // Step 7: Already blocked once this session — don't re-block - if (state.observer_blocked) return; - - // Step 7b: Eligibility floor — real signal must exist before the FIRST - // nudge of a session (SHI-906 AC1/AC2). - // - // PLACEMENT IS LOAD-BEARING. This sits AFTER Step 7's observer_blocked - // check and BEFORE Step 8's write. Moved below that write, the one-shot - // latch trips on turn 1 and the session is permanently spent WITHOUT ever - // nudging — strictly worse than the bug this fixes, and silent: the user - // simply never sees the offer again and nothing is logged anywhere. The - // test `suppressing a turn must NOT consume the session's one-shot - // eligibility` in stop-observer-eligibility.test.js exists to catch that. - // - // Fires on turns OR active time OR a git milestone, mirroring the - // either/or shape of the checkpoint gate in Step 3 above. - // - // The active-time read deliberately does NOT fall back to wall-clock the - // way Step 3 does. Where the transcript is unreadable — which is always - // the case on Cursor — a session left open for hours with a single turn - // would otherwise become eligible on elapsed time alone, which is exactly - // the noise AC1 removes. It degrades to TURNS ONLY. - // - // Everything here is inside main()'s catch, so a fault in the new logic - // fails toward silence rather than toward prompting — the asymmetry the - // error-handling NFR asks for. - const hasTurns = state.turn_count >= TURN_FLOOR; - const eligibilityActiveMs = activeMsFromResolved( - resolveSessionRecords(event), - new Date(state.session_start).getTime(), - ); - const hasActiveTime = Number.isFinite(eligibilityActiveMs) - && eligibilityActiveMs >= ACTIVE_FLOOR_MS; - if (!hasTurns && !hasActiveTime && !milestoneReached(state, sessionState)) return; - - // Step 8: Mark as blocked so we don't fire again on the same turn, AND - // mark observer_fired so prompt-router.cjs preserves the "fire once" UX - // on subsequent turns (the workflow-completion clear path keys off - // !observer_fired so it only re-arms in the genuine "workflow ran but - // observer never fired" case, not the "observer fired, user ignored it" case). - sessionState.write({ observer_blocked: true, observer_fired: true }); - - // Step 9: Block Claude's exit and direct it to evaluate the session. - // Pass the SAME figures the eligibility gate used (SHI-906 AC3). - // - // `declined_once` has to be threaded here too, not only on Step 4's - // re-fire path. Step 4 writes `status: null` when it re-offers, so the - // NEXT Stop no longer matches Step 4 and arrives HERE instead. Omitting - // the flag meant the second and every later re-offer silently reverted to - // the original first-offer wording — the exact "asked again as if it had - // never asked" behaviour SHI-907 AC4 exists to prevent, and invisible - // because the copy still reads perfectly well on its own. - process.stdout.write(buildBlockResponse( - sessionState.stateFilePath, - state.turn_count, - eligibilityActiveMs, - Boolean(state.declined_once), - )); + if (state.status || state.observer_blocked || state.observer_fired) return; + // A deferred offer (passive-observation.cjs `defer`) re-arms the fire-once + // flags but comes back only after a cooldown, never on the very next Stop. + if (state.last_observer_turn != null && turnsSince < CHECKPOINT_INTERVAL) return; + const active = activeMsFromResolved(resolveSessionRecords(event), Date.parse(state.session_start), now); + const head = readHeadRef(process.cwd()); + const milestone = !!head && !!state.git_head_baseline && head !== state.git_head_baseline; + // Seed the baseline on first sight; advance it whenever a milestone is consumed. + const baseline = head && (!state.git_head_baseline || milestone) ? { git_head_baseline: head } : {}; + if (state.turn_count < TURN_FLOOR && !(Number.isFinite(active) && active >= ACTIVE_FLOOR_MS) && !milestone) { + if (head && !state.git_head_baseline) session.write(baseline); + return; + } + session.write({ + observation_due: { id: randomUUID(), reason: 'initial', queued_at: at, + turn_count: state.turn_count, active_ms: active, declined_once: !!state.declined_once }, + ...baseline, + }); } -main().catch(() => { - // Fail silently — never block the response -}); +main().catch(() => { /* Passive capture must never interfere with the answer. */ }); diff --git a/plugins/forge/hooks/workflow-guard.cjs b/plugins/forge/hooks/workflow-guard.cjs index ae3507e..7920b22 100644 --- a/plugins/forge/hooks/workflow-guard.cjs +++ b/plugins/forge/hooks/workflow-guard.cjs @@ -41,7 +41,8 @@ * token columns on ad_hoc/checkpoint rows). * * Hook contract: PreToolUse hooks may emit a JSON payload on stdout — - * `{decision: "deny", reason: "..."}` to refuse the tool, or + * `{hookSpecificOutput: {hookEventName: "PreToolUse", + * permissionDecision: "deny", permissionDecisionReason: "..."}}` to refuse it, or * `{hookSpecificOutput: {permissionDecision: "allow", updatedInput: {…}}}` * to rewrite the tool input (Claude Code >= 2.0.10). Anything else (silence, * exit code 0) allows the call to proceed unchanged. @@ -65,6 +66,16 @@ 'use strict'; const sessionStateModule = require('./session-state.cjs'); +const { stateCall } = require('./hook-input.cjs'); +const { claim } = require('./checkpoint-claim.cjs'); + +// Codex does not accept top-level decision:"deny". Keep every denial on the +// same supported contract so a policy decision cannot become a failed-open hook. +function deny(reason) { + process.stdout.write(JSON.stringify({ hookSpecificOutput: { + hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, + } })); +} const { resolveSessionRecords, captureTokenUsageFromResolved, resolveCodexRolloutPath } = require('./token-usage.cjs'); const { activeMsFromEvent, activeMsFromResolved } = require('./active-time.cjs'); const fs = require('fs'); @@ -170,13 +181,14 @@ function codexSupportsUpdatedInput(event) { let supported = false; let version = null; try { - // shell:true on Windows so the `codex.cmd` npm shim resolves; the - // arguments are a fixed literal, so there is no injection surface. - const out = String(execFileSync('codex', ['--version'], { - timeout: 2000, - shell: process.platform === 'win32', - stdio: ['ignore', 'pipe', 'ignore'], - })); + // shell:true on Windows so the `codex.cmd` npm shim resolves. The whole + // command is one fixed literal there (no args array): Node 24 deprecates + // shell:true combined with an args array (DEP0190) and prints the warning + // to stderr, which in a hook is noise on every tool call. Either form has + // no injection surface — nothing here comes from input. + const out = String(process.platform === 'win32' + ? execFileSync('codex --version', [], { timeout: 2000, shell: true, stdio: ['ignore', 'pipe', 'ignore'] }) + : execFileSync('codex', ['--version'], { timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] })); const v = parseVersionTriple(out); if (v) { version = v.join('.'); @@ -450,15 +462,27 @@ async function main() { // (graceful no-capture, no breakage). Fail-soft: any parse/IO error leaves // the call unchanged — token capture must never block forge__update_state. if (bare === 'forge__update_state') { + const normalized = stateCall(event.tool_input || {}); + // Leave malformed ordinary calls for server validation, without rewriting + // them into an apparently valid but corrupted object. + if (!normalized) return; + if (normalized.completed_step === 'session_observer' && normalized.state_updates.outcome === 'checkpoint') { + let reason; + try { reason = claim(sessionState, normalized); } + catch { reason = 'Could not validate the passive checkpoint claim. Do not retry this submission.'; } + if (reason) { + deny(reason); + return; + } + } // Codex build: the stamp is delivered via updatedInput, which Codex only // honors from rust-v0.131.0 — bail BEFORE any capture work (rollout // parsing is wasted when the rewrite can't be delivered). See the gate's // comment block above. if (!codexSupportsUpdatedInput(event)) return; try { - let toolInput = event.tool_input || {}; - if (typeof toolInput === 'string') toolInput = JSON.parse(toolInput); - const stateUpdates = { ...(toolInput.state_updates || {}) }; + const toolInput = normalized; + const stateUpdates = { ...toolInput.state_updates }; // Resolve the session log ONCE per invocation — token capture and the // active-time stamp below consume the same parsed records instead of // each re-reading multi-MiB transcript files (review #10). @@ -473,10 +497,16 @@ async function main() { // rows across all its Forge conversations — this workflow + the // observer — instead of counting one per conversation), and // - duration_ms (R1 idle-excluded active time; active-workflow steps only). - let changed = false; + let changed = typeof event.tool_input === 'string' || typeof event.tool_input?.state_updates === 'string'; // Never clobber a token_usage the caller already set (defensive — the - // model does not set it today, but a future client might). - if (tokens && !stateUpdates.token_usage) { + // model does not set it today, but a future client might). The one + // exception is a Codex passive checkpoint: its token_usage is a snapshot + // frozen when stop-observer.cjs queued it, possibly many turns ago, so a + // capture taken now is strictly fresher (cumulative — the server keeps + // the larger value either way). + const passiveCheckpoint = toolInput.completed_step === 'session_observer' + && stateUpdates.outcome === 'checkpoint'; + if (tokens && (!stateUpdates.token_usage || passiveCheckpoint)) { // Stamp one component bag PER model so the orchestrator // writes a per-model token_usage row — a delegated session (Opus main + // Sonnet sub-agent) is then weighted per model at read. Fall back to the @@ -604,10 +634,7 @@ async function main() { // Layer 1: CHECKPOINT enforcement. if (state.pending_checkpoint) { - process.stdout.write(JSON.stringify({ - decision: 'deny', - reason: buildCheckpointDenyReason(state, bare), - })); + deny(buildCheckpointDenyReason(state, bare)); return; } @@ -615,10 +642,7 @@ async function main() { if (Array.isArray(state.current_step_tools) && state.current_step_tools.length > 0) { const category = categoryFor(bare); if (category && !state.current_step_tools.includes(category)) { - process.stdout.write(JSON.stringify({ - decision: 'deny', - reason: buildStepPermissionDenyReason(state, bare, category), - })); + deny(buildStepPermissionDenyReason(state, bare, category)); return; } } diff --git a/plugins/forge/hooks/workflow-tracker.cjs b/plugins/forge/hooks/workflow-tracker.cjs index 74fe67d..efbf8a4 100644 --- a/plugins/forge/hooks/workflow-tracker.cjs +++ b/plugins/forge/hooks/workflow-tracker.cjs @@ -27,6 +27,8 @@ 'use strict'; const sessionStateModule = require('./session-state.cjs'); +const { stateCall } = require('./hook-input.cjs'); +const { attempted } = require('./checkpoint-claim.cjs'); // -- Tool name patterns (MCP names include dynamic server UUIDs) -------------- @@ -341,6 +343,10 @@ async function main() { const toolName = event.tool_name || ''; const toolResponse = event.tool_response || ''; + // Codex: failed tools must not acknowledge passive delivery or clear guards. + if (toolResponse?.isError || event.error || + (typeof toolResponse === 'object' && toolResponse.error)) return; + // Track local skill invocations via the Skill tool (Claude Code). // The PostToolUse hook fires for ALL tool calls — including the built-in // Skill tool. We record which local skills the AI invoked so the @@ -389,6 +395,8 @@ async function main() { // reminders on the next turn. if (isAbandon && isWorkflowAbandoned(toolResponse)) { sessionState.write({ + // Codex: the turn-cadence baseline moves with the time baseline below. + last_checkpoint_turn: sessionState.read().turn_count || 0, active_workflow: false, observer_blocked: true, conversation_id: null, @@ -435,6 +443,10 @@ async function main() { // overwritten by a chained follow-up workflow. if (currentSkill === 'observe_session') { updates.last_observer_conversation_id = conversationId; + updates.observation_due = null; + const delivery = sessionState.read().observation_delivery; + if (delivery) updates.observation_delivery = { ...delivery, + processed_at: new Date().toISOString(), disposition: 'observe' }; } sessionState.write(updates); return; @@ -462,7 +474,10 @@ async function main() { // duration. A hard start error lands here too and is benign — the re-arm clears // the block on the next turn. if (isWorkflowStart) { - sessionState.write({ observer_blocked: true }); + // Codex: passive delivery (passive-observation.cjs) suppresses the next + // prompt from this turn stamp, because prompt-router re-arms + // observer_blocked before delivery runs. + sessionState.write({ observer_blocked: true, clarification_at_turn: sessionState.read().turn_count || 0 }); return; } @@ -471,6 +486,37 @@ async function main() { // use it for checkpoint logic. Claude is instructed to write this itself, // but it inconsistently forgets — this hook makes it reliable. if (isStateUpdate) { + const call = stateCall(event.tool_input || {}); + if (!call) return; + const passive = call.state_updates; + const text = responseText(toolResponse); + // Only recognized successful responses may update local workflow state. + // In particular, a rejected update must not clear a pending question. + if (!isWorkflowComplete(toolResponse) && !extractPendingCheckpointStep(toolResponse) && + !isRelayedQuestionReentry(toolResponse) && !/\*\*NEXT STEP\*\*/.test(text)) return; + if (passive.outcome === 'checkpoint' && call.completed_step === 'session_observer') { + const state = sessionState.read(); + if (attempted(sessionState, state, call) && !state.checkpoint_delivery.processed_at && + /Checkpoint recorded/.test(text)) { + sessionState.write({ checkpoint_delivery: { ...state.checkpoint_delivery, + processed_at: new Date().toISOString() } }); + } + // A completed-observer checkpoint is NOT completion of another active + // workflow. It must not reset the already-reserved time boundary. + return; + } + if (isWorkflowComplete(toolResponse) && (passive.event_type === 'observation_outcome' || + passive.outcome === 'observation_disabled')) { + const state = sessionState.read(); + const final = passive.final_session_state || {}; + sessionState.write({ observation_due: null, last_observer_turn: state.turn_count, + ...(typeof final.wake_condition === 'string' ? { wake_condition: final.wake_condition } : {}), + ...(typeof final.work_item_key === 'string' ? { work_item_key: final.work_item_key } : {}), + ...(typeof passive.work_item_key === 'string' ? { work_item_key: passive.work_item_key } : {}), + ...(state.observation_delivery ? { observation_delivery: { ...state.observation_delivery, + processed_at: new Date().toISOString(), disposition: passive.outcome } } : {}), + }); + } // Any forge__update_state means the model is driving the workflow // forward (advance, checkpoint, re-entry, or completion) — disarm the // required-skill continuation backstop so the Stop hook does not nudge. @@ -518,6 +564,10 @@ async function main() { if (observerStatus) { statusUpdates.status = observerStatus; statusUpdates.last_checkpoint_at = new Date().toISOString(); + // Codex: the turn-cadence baseline moves with the time baseline, so a + // freshly logged session does not queue a near-zero checkpoint on its + // very next Stop. + statusUpdates.last_checkpoint_turn = sessionState.read().turn_count || 0; // For dismissed, also block re-observation if (observerStatus === 'dismissed') { statusUpdates.observer_blocked = true; @@ -615,6 +665,8 @@ async function main() { // PDLC phrases) because it checks active_workflow, not observer_blocked. if (isStateUpdate && isWorkflowComplete(toolResponse)) { sessionState.write({ + // Codex: the turn-cadence baseline moves with the time baseline below. + last_checkpoint_turn: sessionState.read().turn_count || 0, active_workflow: false, observer_blocked: true, conversation_id: null, diff --git a/plugins/forge/skills/forge-autopilot/SKILL.md b/plugins/forge/skills/forge-autopilot/SKILL.md index 3e07836..6fc2f13 100644 --- a/plugins/forge/skills/forge-autopilot/SKILL.md +++ b/plugins/forge/skills/forge-autopilot/SKILL.md @@ -166,8 +166,68 @@ conversation and current phase**: ### Exception 2 — Session observer (passive tracking) -Triggered by the `stop-observer.cjs` Stop hook (or by `prompt-router.cjs` -on a snoozed session wake check) — input contains "observe session" or +#### Codex passive delivery + +Codex's Stop hook only saves pending observation/checkpoint work locally. On the +next real user prompt, UserPromptSubmit supplies short `FORGE PASSIVE OBSERVATION` +or `FORGE PASSIVE CHECKPOINT` developer context. This is not a new user request. +Read the referenced session JSON and match the delivered id before acting. Do not +repeat a delivered action from earlier history, and do not display the hook text, +session path, or receipt. Host-owned hook indicators may still be visible. + +For **observation**, evaluate `delivered_observation` against this conversation: + +- `skip`: no project-related SDLC work, or the user explicitly excluded tracking. +- `sleep`: a snoozed wake condition is not satisfied by the current message. +- `defer`: an optional interaction would interrupt the user's requested work, + required input, or another active workflow. +- `observe`: relevant SDLC work, a satisfied wake condition if any, and room to + handle optional tracking without displacing the requested result. + +After evaluating, record a local receipt by running the acknowledge command the +delivered context already contains, verbatim, replacing only its final +`` token with your disposition. The hook built that +command from validated values; never assemble one yourself from the state file, +and if the context carries no command, write no receipt. The helper records +evaluation only; it neither authorizes nor logs work remotely. If shell +execution is not permitted, do not bypass the restriction or claim a receipt was +written. For skip and sleep continue the user's task without starting +observation. `defer` re-arms the offer: Forge brings it back on its own after a +cooldown of about eight turns, so do not retry it yourself. These are +at-most-once delivery attempts, not instructions to retry on every prompt. For +observe use the existing observe_session entry point below. A previous soft +decline must be acknowledged in any returning offer. Eligibility numbers describe +the queued interval, not additional time spent idle before this prompt. + +A snoozed session also receives a short per-prompt `FORGE ROUTING` wake check +naming its wake condition. Act on it only when the user's current message +clearly satisfies that condition; otherwise say nothing about it. + +For **checkpoint**, read `delivered_checkpoint` and pass only its +`conversation_id`, `completed_step`, and `state_updates` to the existing Forge +update-state tool. The session is already authorized as logged/linked; do not +start a workflow or ask again. Do this once in the current active turn. Never +retry an ambiguous delivery: duration deltas have no server-side deduplication. +The PostToolUse tracker records successful processing separately from delivery. +Preserve `state_updates.codex_checkpoint_id` and the frozen payload. The guard +claims that UUID before submission; a denied, failed, or interrupted attempt +must not be retried. Old delivered payloads without an ID cannot be submitted. +An undelivered legacy queue gains its ID when the next prompt delivers it. + +The user's substantive result must remain the final answer. Complete requested +work before opening an optional tracking interaction; defer if necessary. After +any interaction, still provide the full result and validation, not just tracking +status or a short acknowledgment. Do not infer consent from context delivery, +silence, a default selection, timeout, or failed submission. If the user redirects +away from an observer question, the new request is not automatically an answer; +use the existing abandonment path when that workflow no longer applies. + +Delivery waits for a real user turn. If none arrives, or the session expires or +delivery is interrupted, observation and queued timing may remain unrecorded. +No background model turn or guaranteed end-of-session flush is provided. + +Triggered by the Codex passive delivery evaluation above (or an explicit request) +— input contains "observe session" or "observe_session workflow". → `forge__start_workflow(feature_request: "Passive session observation", connected_tools, workflow: "observe_session", local_skills: )` @@ -215,15 +275,14 @@ unhonored. Always read `follow_up` before resuming. ### Exception 3 — Session checkpoint (passive time tracking) -Triggered by the `stop-observer.cjs` Stop hook for an already-tracked -(`logged` / `linked`) session — input contains "session checkpoint" and -spells out a complete `forge__update_state` call (`conversation_id`, -`completed_step`, `state_updates`). +Triggered by Codex passive delivery for an already-tracked (`logged` / `linked`) +session. Read the frozen call from `delivered_checkpoint` in the referenced state. -→ Call `forge__update_state` exactly as the directive specifies — pass -the `conversation_id` and `state_updates` verbatim. The `conversation_id` -is the original `observe_session` conversation; the server records the -elapsed time as a silent audit event. +→ Call `forge__update_state` exactly as `delivered_checkpoint` specifies — pass +its `conversation_id`, `completed_step` and `state_updates` verbatim, once. The +`conversation_id` is the original `observe_session` conversation; the server +records the elapsed time as a silent audit event. If the call is denied as +already recorded, do not retry it. Do NOT start a workflow, do NOT classify this as a build/bug/architecture request, and do NOT surface anything to the user — it is a passive,