diff --git a/LifeOS/install/hooks/ConfigEvalFire.hook.ts b/LifeOS/install/hooks/ConfigEvalFire.hook.ts index 172ea4debf..dbf8d4c671 100755 --- a/LifeOS/install/hooks/ConfigEvalFire.hook.ts +++ b/LifeOS/install/hooks/ConfigEvalFire.hook.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun /** - * @version 1.0.1 + * @version 1.1.0 * ConfigEvalFire — PostToolUse(Write|Edit) hook that fires the {{DA_NAME}} behavioural * regression suite when a behaviour-defining file changes. * @@ -41,13 +41,16 @@ function isSentinel(path: string): boolean { return /(^|\/)CLAUDE\.md$/.test(path); } -function readInput(): { filePath: string | null } { +function readInput(): { filePath: string | null; agent_id?: unknown; agent_type?: unknown } { try { const raw = readFileSync(0, 'utf8'); if (!raw.trim()) return { filePath: null }; const j = JSON.parse(raw); const fp = j?.tool_input?.file_path; - return { filePath: typeof fp === 'string' ? fp : null }; + // agent_id/agent_type are carried through for the subagent test: a fork + // editing a sentinel would otherwise spawn the whole behavioural suite, + // because the env-only test cannot see a fork. + return { filePath: typeof fp === 'string' ? fp : null, agent_id: j?.agent_id, agent_type: j?.agent_type }; } catch { return { filePath: null }; } @@ -76,9 +79,10 @@ function saveLastFire(iso: string): void { function main(): void { try { - if (isSubagent()) process.exit(0); + const input = readInput(); + if (isSubagent(input)) process.exit(0); - const { filePath } = readInput(); + const { filePath } = input; if (!filePath || !isSentinel(filePath)) process.exit(0); if (minutesSince(loadLastFire()) < DEBOUNCE_MINUTES) process.exit(0); if (!existsSync(RUNNER)) process.exit(0); diff --git a/LifeOS/install/hooks/ISASync.hook.ts b/LifeOS/install/hooks/ISASync.hook.ts index 79bab2f668..961252ac62 100755 --- a/LifeOS/install/hooks/ISASync.hook.ts +++ b/LifeOS/install/hooks/ISASync.hook.ts @@ -153,7 +153,13 @@ async function main(): Promise { // without a phase edit. Per-session dedupe file; subagents never emit (their // ISA edits would strip-spam their own contexts, which helps nobody). let stripDelta: string | null = null; - if (input.session_id && fm.slug && !isSubagentContext()) { + // Payload-aware: a fork's env carries no subagent marker and shares the + // parent's session_id, so the env-only test returned false inside a fork and + // this strip was injected into the fork's OWN transcript. Scope note: + // syncToWorkJson above is deliberately NOT behind this test and never was — a + // fork editing an ISA is real work on the run and its progress should land in + // work.json. Only the strip is per-context, so only the strip is gated. + if (input.session_id && fm.slug && !isSubagentContext(input)) { try { const stripDir = join(homedir(), '.claude/LIFEOS/MEMORY/STATE/ascent-strip'); const stripFile = join(stripDir, `${String(input.session_id).replace(/[^\w-]/g, '')}.json`); diff --git a/LifeOS/install/hooks/PostToolObserver.hook.ts b/LifeOS/install/hooks/PostToolObserver.hook.ts index 851308be7c..2767ce9bed 100755 --- a/LifeOS/install/hooks/PostToolObserver.hook.ts +++ b/LifeOS/install/hooks/PostToolObserver.hook.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun /** - * @version 1.0.3 + * @version 1.1.0 * TRIGGER: PostToolUse (catchall, matcher "") — must stay on the empty matcher so it fires on EVERY tool call. * PostToolObserver.hook.ts — the ONE sync catchall PostToolUse hook. * @@ -22,6 +22,7 @@ import { run as loopDetector } from "./LoopDetector.hook"; import { run as algorithmNudge } from "./AlgorithmNudge.hook"; import { run as systemChangeSurface } from "./SystemChangeSurface.hook"; +import { isSubagentContext, type SubagentHookInput } from "./lib/subagent"; /** The ⚙️ line follows the 🧠 contract: computed by the hook, echoed verbatim. * Stating that beside the line is what keeps it from being paraphrased. */ @@ -47,6 +48,12 @@ async function readStdin(): Promise { let input: Record; try { input = JSON.parse(raw); } catch { process.exit(0); } + // A fork's hook process carries NO subagent env markers and shares the + // parent's session_id, so the env-only test returned false and this + // composer injected main-session nudges and the ⚙️ SYSTEM line into forks. The + // payload's agent_id/agent_type is the only reliable signal; pass it through. + if (isSubagentContext(input as SubagentHookInput)) process.exit(0); + const parts: string[] = []; try { const m = loopDetector(input as never); if (m) parts.push(m); } catch {} try { const m = algorithmNudge(input as never); if (m) parts.push(m); } catch {} diff --git a/LifeOS/install/hooks/lib/subagent.ts b/LifeOS/install/hooks/lib/subagent.ts index b3a031e805..90013b04b2 100644 --- a/LifeOS/install/hooks/lib/subagent.ts +++ b/LifeOS/install/hooks/lib/subagent.ts @@ -19,17 +19,37 @@ * is unset, so the union cannot false-positive there. */ -/** True when this process is a subagent/delegate rather than the main session. */ -export function isSubagentContext(): boolean { +/** + * Hook payload fields that identify delegated work. PostToolUse carries these + * ONLY for subagent calls; they are absent in the main session. + */ +export interface SubagentHookInput { + agent_id?: unknown; + agent_type?: unknown; +} + +/** + * True when this process is a subagent/delegate rather than the main session. + * + * Pass the hook's parsed stdin payload whenever you have it. + * The environment union below CANNOT see a fork: measured live, + * a fork's PostToolUse hook process had every marker unset — including + * CLAUDE_CODE_FORK_SUBAGENT — and shared the parent's session_id byte for byte. + * The only signal that distinguished the two was agent_id/agent_type in the + * hook payload itself. Env markers are kept because they still identify the + * non-fork delegate families and standalone runs, where no payload exists. + */ +export function isSubagentContext(hookInput?: SubagentHookInput | null): boolean { + if (hookInput && (hookInput.agent_id || hookInput.agent_type)) return true; const projectDir = process.env.CLAUDE_PROJECT_DIR || ''; return Boolean( projectDir.includes('/.claude/Agents/') || process.env.CLAUDE_AGENT_TYPE || process.env.CLAUDE_CODE_SUBAGENT_NAME || process.env.CLAUDE_CODE_SUBAGENT_TYPE || - // Forked subagents set ONLY the fork marker — none of the above. - // Without it, 8 hook consumers re-inject main-session context into - // forks that inherited it via cache. (public issue #1831, @DRAZY) + // Kept for any runtime that DOES set this, but insufficient on its own: + // a fork was observed with no marker set at all, which is why the + // hookInput check above exists. (public issue #1831) process.env.CLAUDE_CODE_FORK_SUBAGENT === '1' || process.env.CLAUDE_AGENT_SDK === '1', );