Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions LifeOS/install/hooks/ConfigEvalFire.hook.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion LifeOS/install/hooks/ISASync.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,13 @@ async function main(): Promise<string | null> {
// 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`);
Expand Down
9 changes: 8 additions & 1 deletion LifeOS/install/hooks/PostToolObserver.hook.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -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. */
Expand All @@ -47,6 +48,12 @@ async function readStdin(): Promise<string> {
let input: Record<string, unknown>;
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 {}
Expand Down
30 changes: 25 additions & 5 deletions LifeOS/install/hooks/lib/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
Expand Down