@fusengine/harness 0.1.86 → 0.1.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,23 @@
1
1
  import { a as resolveMaxLines, l as parseEnvInt, s as splitTarget } from "./limits-DG5pl-5s.mjs";
2
- import { s as resolveTtlSec } from "./dotenv-DoqNlALG.mjs";
2
+ import { a as harnessHome, c as resolveTtlSec } from "./dotenv-BLBkBTww.mjs";
3
3
  import { r as projectLayout } from "./layout-KWoE_Mqn.mjs";
4
4
  import { i as walkUpFor, n as projectRoot, r as projectRootOrNull, t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
5
5
  import { t as detectHarness } from "./harness-BMuLJ9lm.mjs";
6
- import { D as isVendorPath, G as isFileSizeScoped, K as resolveSolidRefFramework, W as computeEditResultLines, b as interfaceDeclLevel, et as detectFramework, g as conventionsMode, lt as requiredArchSkill, n as FAIL_CLOSED, ot as detectModularArchitecture, rt as maskCommentsAndStrings, st as detectProjectType$1, t as evaluate } from "./evaluate-9bUzRquQ.mjs";
6
+ import { $ as matchPatterns, D as isVendorPath, G as isFileSizeScoped, J as GIT_BLOCKED, K as resolveSolidRefFramework, W as computeEditResultLines, b as interfaceDeclLevel, et as detectFramework, g as conventionsMode, lt as requiredArchSkill, n as FAIL_CLOSED, ot as detectModularArchitecture, rt as maskCommentsAndStrings, st as detectProjectType$1, t as evaluate } from "./evaluate-C1VSI5pb.mjs";
7
7
  import { a as sanitizeSessionId, c as sessionsDir, d as countFrameworkCodeLines, f as countLines, i as loadSessionState, l as PLUGINS_DIR, n as fuseHarnessHome, o as saveSessionState, p as evaluateFileSize, r as fusengineCache, t as claudeHome } from "./home-state-oUGFB4ds.mjs";
8
8
  import { a as writeJsonFile, i as readJsonFile, r as hashText, t as atomicWrite } from "./json-io-DisYd2fb.mjs";
9
9
  import { r as isDocConsulted } from "./doc-helpers-CWZegVdR.mjs";
10
- import { D as frameworkSolidGate, F as isExcludedSwiftPath, G as evaluateApex, H as PRE_AUTH_GATES, P as isExcludedJsPath, R as capVerbosity, S as parseField, T as usesTailwindUtilities, V as POST_AUTH_GATES, W as docConsultedGate, b as scanPlugin, c as EXCLUDE_DIRS$1, d as buildApexTaskInjection, g as buildClaudeMdContext, l as PROJECT_INDICATORS, n as missingSeoElements, o as parseEnrichment, p as apexAgentSteps, r as descFromText, s as parseEntry, t as isHtmlLike, v as apexDocName, w as skillTriggerGate, y as harnessHomeSegment, z as detectCreationIntent } from "./validate-C-TB06Mw.mjs";
11
- import { n as findMarketplacePlugins, r as readPluginMeta, t as resolveSkillPath } from "./skill-path-DhBGlQe3.mjs";
10
+ import { D as frameworkSolidGate, F as isExcludedSwiftPath, G as evaluateApex, H as PRE_AUTH_GATES, P as isExcludedJsPath, R as capVerbosity, S as parseField, T as usesTailwindUtilities, V as POST_AUTH_GATES, W as docConsultedGate, b as scanPlugin, c as EXCLUDE_DIRS$1, d as buildApexTaskInjection, g as buildClaudeMdContext, l as PROJECT_INDICATORS, n as missingSeoElements, o as parseEnrichment, p as apexAgentSteps, r as descFromText, s as parseEntry, t as isHtmlLike, v as apexDocName, w as skillTriggerGate, y as harnessHomeSegment, z as detectCreationIntent } from "./validate-DZFtAbLP.mjs";
11
+ import { n as findMarketplacePlugins, r as readPluginMeta, t as resolveSkillPath } from "./skill-path-DVML3zfp.mjs";
12
12
  import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
13
13
  import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as setStateField, t as addRoot } from "./registry-IhHk2KlT.mjs";
14
14
  import { d as loadIndex, i as cacheLookupMeta, n as webfetchCacheWrite, o as cacheLookupSubstringMeta, t as mcpCacheWrite, u as extractText } from "./mcp-store-BkBDmuxN.mjs";
15
15
  import { t as loadRefs } from "./loader-AGz4nK7d.mjs";
16
- import { C as apexAuthorizationGate, S as trivialCount, _ as recordBrainstormRequired, a as journalLogPath, b as recordTarget, c as appendEvent, d as signTrack, f as verifyTrack, g as recordAgent, h as emptyTrack, i as withTrack, l as withTrackLockSync, m as agentsFresh, o as readTrackSync, p as writeLastNonce, r as trackJournalEnabled, s as diffTrackEvents, t as loadTrack, u as LOCK_FAILED, v as recordDoc, x as recordTrivialEdit, y as recordRefRead } from "./store-Dw08Amqh.mjs";
17
- import { n as parseApplyPatch, t as isBypassPermissions } from "./permission-mode-BN3MNgbm.mjs";
18
- import { _ as commandToString, d as collectFiles, f as pathExists, g as writeText, h as spawnCapture, i as denyResponse, l as systemMessage, m as sleep, n as blockResponse, p as readText$1, r as contextResponse, s as informResponse, t as attachSystemMessage } from "./claude-CEWX-8m9.mjs";
19
- import { i as toKimiResponse, n as kimiDenyResponse } from "./kimi-CqLkpg1A.mjs";
20
- import { r as toHermesResponse } from "./hermes-H6qePmBC.mjs";
16
+ import { C as apexAuthorizationGate, S as trivialCount, _ as recordBrainstormRequired, a as journalLogPath, b as recordTarget, c as appendEvent, d as signTrack, f as verifyTrack, g as recordAgent, h as emptyTrack, i as withTrack, l as withTrackLockSync, m as agentsFresh, o as readTrackSync, p as writeLastNonce, r as trackJournalEnabled, s as diffTrackEvents, t as loadTrack, u as LOCK_FAILED, v as recordDoc, x as recordTrivialEdit, y as recordRefRead } from "./store-5-ZPKb0u.mjs";
17
+ import { n as canonicalizeCodexShellTool, r as parseApplyPatch, t as isBypassPermissions } from "./permission-mode-B0MUl7FT.mjs";
18
+ import { _ as commandToString, d as collectFiles, f as pathExists, g as writeText, h as spawnCapture, i as denyResponse, l as systemMessage, m as sleep, n as blockResponse, p as readText$1, r as contextResponse, s as informResponse, t as attachSystemMessage } from "./claude-5bC8TDAz.mjs";
19
+ import { i as toKimiResponse, n as kimiDenyResponse } from "./kimi-D6X7b8wF.mjs";
20
+ import { r as toHermesResponse } from "./hermes-kP6NUQlG.mjs";
21
21
  import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
22
22
  import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
23
23
  import { homedir } from "node:os";
@@ -90,7 +90,7 @@ function normalizeEvent(id, payload) {
90
90
  }
91
91
  const event = str(payload.hook_event_name) ?? "";
92
92
  const input = payload.tool_input ?? payload;
93
- const tool = str(payload.tool_name) ?? "";
93
+ const tool = canonicalizeCodexShellTool(id, str(payload.tool_name) ?? "");
94
94
  const base = {
95
95
  phase: /post|after/i.test(event) ? "post" : "pre",
96
96
  tool,
@@ -600,6 +600,33 @@ function clearActiveDesignAgent(cacheDir) {
600
600
  } catch {}
601
601
  }
602
602
  //#endregion
603
+ //#region src/runtime/prompt-text.ts
604
+ /** True for an object whose `text` property is a string (the only field this reads). */
605
+ function hasTextField(value) {
606
+ return typeof value === "object" && value !== null && typeof value.text === "string";
607
+ }
608
+ /**
609
+ * Coerce a hook payload's `prompt` field to plain text.
610
+ *
611
+ * - A string is returned AS-IS (same reference, no trim/normalize) — this is
612
+ * the identity branch that guarantees zero regression on Claude Code/Codex,
613
+ * whose `prompt` is already a string.
614
+ * - An array (Kimi's content-block shape) is flattened: every element with a
615
+ * string `.text` is kept, joined with `"\n"` — one block per line, since
616
+ * downstream consumers ({@link detectCreationIntent}, {@link detectMode})
617
+ * only run `\b`-anchored regexes with no line-start/end assumption, so a
618
+ * newline join is safe and reads like the multi-turn/multi-block source.
619
+ * - Anything else (`undefined`, `null`, a number, a plain object) yields `""`.
620
+ *
621
+ * @param value - The raw `prompt` field from a hook payload (unknown shape).
622
+ * @returns The prompt text, or `""` when it cannot be recovered.
623
+ */
624
+ function promptText(value) {
625
+ if (typeof value === "string") return value;
626
+ if (Array.isArray(value)) return value.filter(hasTextField).map((b) => b.text).join("\n");
627
+ return "";
628
+ }
629
+ //#endregion
603
630
  //#region src/runtime/design-lifecycle.ts
604
631
  /**
605
632
  * Handle the design-agent SubagentStart/Stop lifecycle: init the pipeline state +
@@ -625,7 +652,7 @@ function designLifecycle(payload, cacheDir, cwd, stamp, now) {
625
652
  if (event === "SubagentStart") {
626
653
  if (!agentId) return false;
627
654
  const dsExists = existsSync(join(cwd, "design-system.md"));
628
- saveDesignState(cacheDir, initDesignState(agentId, detectMode(typeof payload.prompt === "string" ? payload.prompt : "", dsExists), dsExists));
655
+ saveDesignState(cacheDir, initDesignState(agentId, detectMode(promptText(payload.prompt), dsExists), dsExists));
629
656
  setActiveDesignAgent(cacheDir, agentId);
630
657
  return true;
631
658
  }
@@ -893,12 +920,35 @@ function claudeMdKey(prompt, ctx) {
893
920
  return `claude-md:${hashText(prompt)}:${hashText(ctx)}`;
894
921
  }
895
922
  /**
923
+ * Extract the APEX preamble portion of a `buildClaudeMdContext` result, i.e.
924
+ * everything BEFORE the `# <docName>\n` root-doc block it prepends on dev
925
+ * prompts. Returns "" when `ctx` has no preamble (plain prompt: `ctx` starts
926
+ * directly with the root-doc heading).
927
+ * @param ctx - The full `buildClaudeMdContext` return value.
928
+ * @param docName - Root doc file name (e.g. "AGENTS.md").
929
+ * @returns The preamble text, or "" when absent.
930
+ */
931
+ function extractApexPreamble(ctx, docName) {
932
+ const heading = `# ${docName}\n`;
933
+ if (ctx.startsWith(heading)) return "";
934
+ const sep = `\n\n${heading}`;
935
+ const idx = ctx.indexOf(sep);
936
+ return idx === -1 ? "" : ctx.slice(0, idx);
937
+ }
938
+ /**
896
939
  * UserPromptSubmit context injection: render the CLAUDE.md (+ optional APEX)
897
940
  * preamble as a Claude `additionalContext` response, or "" when nothing to emit.
898
941
  * Guarded by {@link oncePerWindow} via {@link claudeMdKey}: only a
899
942
  * near-simultaneous double-fire of the SAME turn (identical prompt AND identical
900
943
  * block, within {@link DEDUP_WINDOW_MS}) is suppressed. The invariant "CLAUDE.md
901
944
  * is emitted on EVERY message" is thus preserved.
945
+ *
946
+ * On kimi specifically, the root doc body is dropped from the emitted text:
947
+ * Kimi loads `<kimiHome>/AGENTS.md` natively at session start (`apex-target.ts`),
948
+ * so re-injecting its full body on every prompt is redundant terminal noise
949
+ * (kimi has no model-only hook channel — `runtime/inform.ts`). The APEX
950
+ * preamble, which is NOT part of `AGENTS.md`, is still emitted in full when the
951
+ * prompt is dev-shaped; a plain prompt leaves only the notice.
902
952
  * @param prompt - The raw user prompt.
903
953
  * @param cwd - Project root (for project-type detection).
904
954
  * @param id - Harness target id (defaults to "claude-code" — zero-regression default).
@@ -908,7 +958,12 @@ function promptSubmitContext(prompt, cwd, id = "claude-code") {
908
958
  const ctx = buildClaudeMdContext(prompt, cwd, id);
909
959
  if (!ctx) return "";
910
960
  if (!oncePerWindow(claudeMdKey(prompt, ctx), 3e3)) return "";
911
- return renderInform(id, "UserPromptSubmit", ctx, `${apexDocName(id)} injected`);
961
+ const notice = `${apexDocName(id)} injected`;
962
+ if (id === "kimi") {
963
+ const preamble = extractApexPreamble(ctx, apexDocName(id));
964
+ return preamble ? renderInform(id, "UserPromptSubmit", preamble, notice) : notice;
965
+ }
966
+ return renderInform(id, "UserPromptSubmit", ctx, notice);
912
967
  }
913
968
  /**
914
969
  * PreToolUse Task context injection: render the APEX sub-agent context as a
@@ -1092,6 +1147,45 @@ function sessionStartCore(cwd, home = homedir(), now = Date.now(), id = "claude-
1092
1147
  return ctx ? attachSystemMessage(contextResponse("SessionStart", ctx), `${apexDocName(id)} injected`) : "";
1093
1148
  }
1094
1149
  //#endregion
1150
+ //#region src/runtime/lifecycle/kimi-rules-native.ts
1151
+ /**
1152
+ * Guard for the kimi notice-only rules injection. Kimi Code CLI has no
1153
+ * model-only hook channel (`src/runtime/inform.ts`) — every `UserPromptSubmit`
1154
+ * stdout is BOTH appended to the model's context AND rendered raw in the
1155
+ * user's terminal. `injectRules` (`inject-rules.ts`) normally re-injects the
1156
+ * full ~18 Ko rules corpus on every prompt; when the installer has already
1157
+ * merged that same corpus into `<kimiHome>/AGENTS.md` — which Kimi loads
1158
+ * natively at session start via its documented `${agents_md}` mechanism,
1159
+ * independent of hooks — the re-injection is pure noise the user has to
1160
+ * scroll past. Confirmed present, fenced, in the local install:
1161
+ * `~/.kimi-code/AGENTS.md:87` / `:335`.
1162
+ *
1163
+ * Fail-safe semantics: fences absent, file absent, or any read error ⟹
1164
+ * `false` — the caller then falls back to the full corpus. A dump is
1165
+ * verbose but never wrong; a false `true` would silently drop the rules.
1166
+ */
1167
+ /** Opening fence the fusengine installer writes around the merged corpus. */
1168
+ const FENCE_START = "<!-- fusengine:kimi-rules:start -->";
1169
+ /** Closing fence the fusengine installer writes around the merged corpus. */
1170
+ const FENCE_END = "<!-- fusengine:kimi-rules:end -->";
1171
+ /**
1172
+ * Check whether the kimi rules corpus is already present, fenced, inside
1173
+ * `<kimiHome>/AGENTS.md`.
1174
+ * @param home - Kimi home dir override (defaults to `harnessHome("kimi")`,
1175
+ * which honors `KIMI_CODE_HOME`).
1176
+ * @returns `true` only when both fences are found in a readable file.
1177
+ */
1178
+ function rulesInAgentsMd(home) {
1179
+ try {
1180
+ const agentsMd = join(home ?? harnessHome("kimi"), "AGENTS.md");
1181
+ if (!existsSync(agentsMd)) return false;
1182
+ const content = readFileSync(agentsMd, "utf-8");
1183
+ return content.includes(FENCE_START) && content.includes(FENCE_END);
1184
+ } catch {
1185
+ return false;
1186
+ }
1187
+ }
1188
+ //#endregion
1095
1189
  //#region src/runtime/lifecycle/inject-rules.ts
1096
1190
  /** Read & concatenate all `*.md` files (sorted) under `rulesDir`. */
1097
1191
  function readRules(rulesDir) {
@@ -1115,6 +1209,13 @@ function readRules(rulesDir) {
1115
1209
  * with the *actual* `hookEventName` — the spec requires it to match the firing
1116
1210
  * event (a hardcoded "SessionStart" is non-conforming and may be dropped on
1117
1211
  * UserPromptSubmit/SubagentStart).
1212
+ *
1213
+ * On kimi's `UserPromptSubmit` specifically, when the corpus is already fenced
1214
+ * inside `<kimiHome>/AGENTS.md` (native load, {@link rulesInAgentsMd}), only the
1215
+ * notice is emitted — the re-injected corpus would otherwise dump into the
1216
+ * user's terminal on every single prompt for no model-context benefit.
1217
+ * `SessionStart`/`SubagentStart` always keep the full corpus (session bootstrap
1218
+ * / sub-agent contexts need it regardless of the native AGENTS.md load).
1118
1219
  * @param pluginRoot - `CLAUDE_PLUGIN_ROOT` of the claude-rules plugin.
1119
1220
  * @param event - The firing hook event name (e.g. "SessionStart").
1120
1221
  * @param id - Harness target id (defaults to "claude-code" — zero-regression default).
@@ -1122,7 +1223,9 @@ function readRules(rulesDir) {
1122
1223
  */
1123
1224
  function injectRules(pluginRoot, event, id = "claude-code") {
1124
1225
  const content = readRules(join(pluginRoot, "rules"));
1125
- return content ? renderInform(id, event, content, "rules 00-08 injected") : "";
1226
+ if (!content) return "";
1227
+ if (id === "kimi" && event === "UserPromptSubmit" && rulesInAgentsMd()) return "rules 00-08 injected";
1228
+ return renderInform(id, event, content, "rules 00-08 injected");
1126
1229
  }
1127
1230
  //#endregion
1128
1231
  //#region src/runtime/lifecycle/solid-detect.ts
@@ -2417,6 +2520,114 @@ function harvestSubagentTrack(payload, cwd, now, baseDir = defaultStateDir(cwd))
2417
2520
  }) === LOCK_FAILED) return;
2418
2521
  }
2419
2522
  //#endregion
2523
+ //#region src/runtime/confirm/confirm-subagent.ts
2524
+ /** Env var overriding the G0 cool-down window below (seconds). Unset = the 300s/5min default. */
2525
+ const CONFIRM_WINDOW_ENV_KEY = "FUSE_CONFIRM_SUBAGENT_WINDOW_SEC";
2526
+ /** Default G0 cool-down (seconds) when {@link CONFIRM_WINDOW_ENV_KEY} is unset/invalid — 300s (5min). */
2527
+ const DEFAULT_CONFIRM_WINDOW_SEC = 300;
2528
+ /**
2529
+ * G0 cool-down after the last SubagentStart/SubagentStop seen for this
2530
+ * session — {@link DEFAULT_CONFIRM_WINDOW_SEC} by default, overridable via
2531
+ * `FUSE_CONFIRM_SUBAGENT_WINDOW_SEC` (seconds) without a rebuild. Read this
2532
+ * doc before ever touching this value: it is NOT G0's primary protection,
2533
+ * and it is not sized to "safely cover a sub-agent's whole runtime".
2534
+ *
2535
+ * G0's PRIMARY protection is P0, measured live under Codex in a prior
2536
+ * session: a sub-agent never receives its own `UserPromptSubmit` — only a
2537
+ * human typing into the top-level session does — so a sub-agent is
2538
+ * STRUCTURALLY unable to place a CONFIRM token at all, regardless of this
2539
+ * window's value. This timestamp is a SECOND belt, covering only the
2540
+ * residual case where that structural property does not hold on some future
2541
+ * harness (Kimi: not yet measured either way).
2542
+ *
2543
+ * The default is a deliberate, owner-decided trade-off between over-refusal
2544
+ * and usability, NOT a safety-maximizing constant: sub-agents in this
2545
+ * ecosystem share the lead session's `session_id`, and the owner runs
2546
+ * sub-agents continuously — an earlier 30-minute window froze CONFIRM for
2547
+ * the entire session on every single sub-agent call, which neutralizes the
2548
+ * feature it protects (a guard that disables what it guards is not a good
2549
+ * guard). 5 minutes comfortably covers the case that actually matters — a
2550
+ * sub-agent genuinely in flight at the exact moment a human types a CONFIRM
2551
+ * code — without pinning a busy session shut. Do not read this value as "the
2552
+ * time G0 needs to be safe" — that safety comes from P0.
2553
+ *
2554
+ * This deliberately reuses {@link parseEnvInt} (`src/config/env.ts`) rather
2555
+ * than `resolveTtlSec`/`FUSE_ENFORCE_TTL_SEC` (`src/config/ttl.ts`): that TTL
2556
+ * governs APEX evidence freshness (120s default) — an unrelated concern
2557
+ * accidentally coupling the two would make lengthening the research-evidence
2558
+ * window also lengthen the confirmation cool-down. `resolveTtlSec` also
2559
+ * hardcodes its own fallback (`DEFAULT_TTL_SEC` = 120) regardless of which
2560
+ * env key is passed, so it cannot express a 300s default either — this key
2561
+ * gets its OWN env var and its OWN default via the same underlying
2562
+ * `parseEnvInt` primitive, with no new parsing logic.
2563
+ *
2564
+ * Mechanically this is still a single monotone "last seen" timestamp, NOT a
2565
+ * start/stop pair — a symmetric increment/decrement depth counter was tried
2566
+ * and rejected: the harness dispatches ONE real SubagentStart/SubagentStop
2567
+ * through MULTIPLE concurrent sibling plugin processes (the repo's own
2568
+ * "~11-process multi-plugin fan-out"), all doing UNLOCKED read-modify-write
2569
+ * on the same session-state file — a duplicated decrement can walk the
2570
+ * counter to 0 while a sub-agent is still running, and G0 would then wrongly
2571
+ * OPEN. Two ever-growing `starts`/`stops` counters were also rejected: if the
2572
+ * fan-out dispatches SubagentStart via N processes and SubagentStop via
2573
+ * M ≠ N, `starts > stops` stays true FOREVER and the session is frozen for
2574
+ * good — worse than any window.
2575
+ *
2576
+ * `Math.max(prevSeenAt, now)` avoids both failure modes: an LWW
2577
+ * (last-writer-wins) register merged with `max`, which is idempotent,
2578
+ * commutative, and associative — under ANY interleaving of concurrent
2579
+ * writers (lost updates included), the stored value converges to the
2580
+ * largest `now` any writer supplied, and every writer's `now` is a real
2581
+ * wall-clock read taken within milliseconds of the true event, so it can
2582
+ * never regress below a timestamp it already held. BOTH SubagentStart and
2583
+ * SubagentStop bump the SAME field this way — there is no decrement
2584
+ * anywhere in this file, so there is nothing for a duplicated event to
2585
+ * desynchronize; the flag falls only once this window elapses with no
2586
+ * further sighting.
2587
+ * @param env - Env map to resolve the override from (defaults to `process.env`; tests inject a plain object).
2588
+ */
2589
+ function subagentWindowMs(env = process.env) {
2590
+ return parseEnvInt(env[CONFIRM_WINDOW_ENV_KEY], DEFAULT_CONFIRM_WINDOW_SEC) * 1e3;
2591
+ }
2592
+ /**
2593
+ * G0: true within {@link subagentWindowMs} of the last SubagentStart/
2594
+ * SubagentStop seen for this session. An invalid/missing session id is
2595
+ * treated as active too — fail-closed, never pose a token when in doubt.
2596
+ */
2597
+ function isSubagentActive(sessionIdRaw, now, home = homedir(), env = process.env) {
2598
+ const sid = sanitizeSessionId(sessionIdRaw);
2599
+ if (!sid) return true;
2600
+ const seenAt = loadSessionState(sid, home).subagentSeenAt;
2601
+ return typeof seenAt === "number" && now - seenAt < subagentWindowMs(env);
2602
+ }
2603
+ /**
2604
+ * Record a SubagentStart OR SubagentStop sighting for this session as a
2605
+ * monotone max-write (see {@link subagentWindowMs}'s doc for why this can
2606
+ * never desync unsafely under concurrent racy writers). Both events call
2607
+ * this the SAME way — there is no separate "clear" path.
2608
+ *
2609
+ * Called unconditionally, BEFORE scope branching, on every SubagentStart/Stop
2610
+ * across every plugin (dispatch.ts) — never allowed to throw: a disk fault
2611
+ * here (full disk, unwritable home) must degrade to "sighting not recorded",
2612
+ * never crash sub-agent lifecycle dispatch for scopes that have nothing to do
2613
+ * with CONFIRM. Safe to swallow: G0's PRIMARY protection is P0 (a sub-agent
2614
+ * structurally never receives its own UserPromptSubmit, confirm-gate.ts's
2615
+ * doc) — this window is only a second belt, so losing one sighting degrades
2616
+ * that belt, it does not open the gate.
2617
+ */
2618
+ function markSubagentSeen(sessionIdRaw, now, home = homedir()) {
2619
+ try {
2620
+ const sid = sanitizeSessionId(sessionIdRaw);
2621
+ if (!sid) return;
2622
+ const state = loadSessionState(sid, home);
2623
+ const prev = typeof state.subagentSeenAt === "number" ? state.subagentSeenAt : 0;
2624
+ saveSessionState(sid, {
2625
+ ...state,
2626
+ subagentSeenAt: Math.max(prev, now)
2627
+ }, home);
2628
+ } catch {}
2629
+ }
2630
+ //#endregion
2420
2631
  //#region src/cli/doctor.ts
2421
2632
  /**
2422
2633
  * `harness doctor` — diagnose which `@fusengine/harness` is actually running.
@@ -5826,6 +6037,7 @@ function dispatchLifecycle(input) {
5826
6037
  if (input.scope === "lessons") return dispatchLessons("UserPromptSubmit", input.payload, input.cwd, input.now, input.id ?? "claude-code");
5827
6038
  return null;
5828
6039
  case "SubagentStart":
6040
+ markSubagentSeen(input.payload.session_id, input.now);
5829
6041
  if (input.scope === "rules") return injectRules(resolveRulesRoot(input.id ?? "claude-code", input.cwd), input.event, input.id ?? "claude-code");
5830
6042
  if (input.scope === "aipilot") return "";
5831
6043
  if (input.scope === "lessons") return dispatchLessons("SubagentStart", input.payload, input.cwd, input.now, input.id ?? "claude-code");
@@ -5834,6 +6046,7 @@ function dispatchLifecycle(input) {
5834
6046
  if (input.scope === "lessons") return dispatchLessons("Stop", input.payload, input.cwd, input.now, input.id ?? "claude-code");
5835
6047
  return input.scope === "core" ? stopCore(input.payload, input.cwd, input.now) : null;
5836
6048
  case "SubagentStop":
6049
+ markSubagentSeen(input.payload.session_id, input.now);
5837
6050
  if (input.scope === "aipilot") return "";
5838
6051
  harvestSubagentTrack(input.payload, input.cwd, input.now);
5839
6052
  return trackAgentMemory(input.payload, void 0, input.now);
@@ -8212,6 +8425,23 @@ function designSystemWriteGate(filePath, state, corpusRequired = false) {
8212
8425
  if (state.screenshotsCount < needed) return deny(`BLOCKED: ${state.screenshotsCount}/${needed} screenshots for mode '${state.mode}'. RECOVERY: 1) Read the refs-design corpus (README.md + tokens-*.md) if not done 2) Take ${needed - state.screenshotsCount} more screenshots of REAL sector sites with mcp__fuse-browser__browser_screenshot (browser_shots_batch/browser_site_shots also count, 1 credit per call) 3) Use browser_open + browser_navigate + browser_screenshot fullPage:true 4) Then write design-system.md`);
8213
8426
  return null;
8214
8427
  }
8428
+ /**
8429
+ * Gate writing .html/.css: PIPELINE gate (never `uiDesignSkillGate`/`UI_FILE_RE`
8430
+ * — owner-scoped out of the skill gate's remit). Requires phase >= 3 AND
8431
+ * designSystemValid (same two-condition defense as {@link geminiCreateGate})
8432
+ * AND `designSystemFileExists` (caller-computed via `findDesignSystem` — a
8433
+ * state can say phase 3 after the file was later deleted, or the file can
8434
+ * exist while a fresh agent's state is still phase 0).
8435
+ */
8436
+ function htmlCssPipelineGate(filePath, state, designSystemFileExists) {
8437
+ if (!/\.(html|css)$/.test(filePath)) return null;
8438
+ if (state.currentPhase >= 3 && state.designSystemValid && designSystemFileExists) return null;
8439
+ const gaps = [];
8440
+ if (state.currentPhase < 3) gaps.push(`phase ${state.currentPhase}/3`);
8441
+ if (!state.designSystemValid) gaps.push("design-system.md not validated");
8442
+ if (!designSystemFileExists) gaps.push("design-system.md not found on disk");
8443
+ return deny(`BLOCKED: cannot write '${filePath}' before the design-system pipeline is complete (${gaps.join(", ")}). RECOVERY: 1) Read identity templates from skills/design-system/ 2) Read design-inspiration.md 3) Read the refs-design corpus with the Read tool 4) Screenshot real sector sites with mcp__fuse-browser__browser_screenshot on a LIVE session 5) Write a valid design-system.md 6) Then write .html/.css`);
8444
+ }
8215
8445
  /** Gate Gemini create_frontend: requires phase >= 3 and a validated design system. */
8216
8446
  function geminiCreateGate(state) {
8217
8447
  if (state.currentPhase < 3) return deny("BLOCKED: Cannot call Gemini create_frontend before phase 3. RECOVERY: 1) Complete the inspiration phase (refs-design corpus + sector screenshots) 2) Write a valid design-system.md 3) Then call mcp__gemini-design__create_frontend");
@@ -8246,12 +8476,28 @@ function browserNavigateGate(state, url) {
8246
8476
  function substituteLiteral(s, from, to, all) {
8247
8477
  return all ? s.split(from).join(to) : s.replace(from, () => to);
8248
8478
  }
8249
- /** Gate every file of a multi-file write primitive; the first violation blocks the envelope. */
8250
- function designFilesGate(files, state, pluginsRoot, corpusRoot, corpusRequired, cwd) {
8479
+ /**
8480
+ * Gate every file of a multi-file write primitive; the first violation blocks
8481
+ * the envelope. `designSystemFileExists` is caller-computed (`findDesignSystem`
8482
+ * lives in `design-helpers.ts`, which itself imports `substituteLiteral` from
8483
+ * THIS module — importing it back here would cycle) so both write paths
8484
+ * (Write/Edit in `design.ts`, apply_patch here) consume the SAME disk read.
8485
+ */
8486
+ function designFilesGate(files, state, pluginsRoot, corpusRoot, corpusRequired, cwd, designSystemFileExists) {
8251
8487
  for (const f of files) {
8252
8488
  const hit = pluginsWriteGuard(f.filePath, pluginsRoot, cwd) ?? stateFileGate(f.filePath);
8253
8489
  if (hit) return hit;
8254
8490
  if (f.op === "delete") continue;
8491
+ const htmlCssHit = htmlCssOnlyGate(f.filePath);
8492
+ if (htmlCssHit) return htmlCssHit;
8493
+ const pipelineHit = htmlCssPipelineGate(f.filePath, state, designSystemFileExists);
8494
+ if (pipelineHit) return pipelineHit;
8495
+ if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(f.filePath)) return {
8496
+ kind: "block",
8497
+ title: "Design pipeline",
8498
+ reason: "BLOCKED: generate the frontend via create_frontend before hand-writing HTML/CSS.",
8499
+ actions: ["Call mcp__gemini-design__create_frontend first"]
8500
+ };
8255
8501
  if (!f.filePath.endsWith("design-system.md")) continue;
8256
8502
  const gate = designSystemWriteGate(f.filePath, state, corpusRequired) ?? (f.op === "add" ? designSystemContentGate({
8257
8503
  filePath: f.filePath,
@@ -8347,6 +8593,125 @@ function designSystemContentGate(i) {
8347
8593
  return deny(`BLOCKED: design-system.md too generic. Missing: ${introduced.join(", ")}. RECOVERY: fix the content (read cited refs-design references with the Read tool), then retry.`);
8348
8594
  }
8349
8595
  //#endregion
8596
+ //#region src/policy/shell-read-refs.ts
8597
+ /**
8598
+ * @module shell-read-refs
8599
+ * Codex teammates frequently read skill/SOLID reference `.md` files through a
8600
+ * shell Bash call (`cat`, `head`, …) instead of a native `Read` — the
8601
+ * refsRead/ref-journal freshness gates only ever credited a native `Read`
8602
+ * (`src/runtime/activity.ts`'s `READ_TOOLS` branch), so a shell-read skill
8603
+ * consultation was invisible to every SOLID/skill gate. This detects the
8604
+ * `.md` paths a KNOWN read-only command targets in a Bash `command`, for a
8605
+ * caller to fold into the SAME `{kind:"ref", path, ts}` activity
8606
+ * `src/runtime/record.ts` already persists (no new store, no new gate).
8607
+ *
8608
+ * Fail-open by construction: only a whitelisted read-only command name
8609
+ * credits its `.md` arguments — a non-read command (`echo`, `mv`, `tee`, …)
8610
+ * sharing a chained/piped segment is never credited, and `sed -i`/
8611
+ * `--in-place` (a MUTATION despite the `sed` name) is explicitly excluded.
8612
+ * @packageDocumentation
8613
+ */
8614
+ /** Read-only shell commands that can target a `.md` reference by path. */
8615
+ const READ_COMMANDS = /* @__PURE__ */ new Set([
8616
+ "cat",
8617
+ "head",
8618
+ "tail",
8619
+ "sed",
8620
+ "rg",
8621
+ "ripgrep",
8622
+ "less",
8623
+ "more",
8624
+ "bat"
8625
+ ]);
8626
+ /** POSIX shells whose `-c`-style argv (or inline string) wraps a real script. */
8627
+ const SHELL_BINS = /* @__PURE__ */ new Set([
8628
+ "bash",
8629
+ "sh",
8630
+ "zsh",
8631
+ "dash"
8632
+ ]);
8633
+ /** `sed -i` / `sed --in-place` mutates the file in place — never a read. */
8634
+ const SED_INPLACE = /(^|\s)(-i\b|--in-place\b)/;
8635
+ /** Split a command string on `&&`, `||`, `;`, `|`, and newlines — each side scanned independently. */
8636
+ function segments(command) {
8637
+ return command.split(/&&|\|\||[;|\n]/);
8638
+ }
8639
+ /** Strip a redirection (`>`, `>>`, `<`, `2>`, `&>`, …) and everything after — its target is WRITTEN, not read. */
8640
+ function beforeRedirect(segment) {
8641
+ const m = segment.match(/\s(?:\d*>{1,2}|<|&>)\s*\S/);
8642
+ return m ? segment.slice(0, m.index) : segment;
8643
+ }
8644
+ /** Naive shell tokenizer: whitespace-split, stripping one matching layer of quotes per token. */
8645
+ function tokenize(segment) {
8646
+ return (segment.match(/(?:"[^"]*"|'[^']*'|\S+)/g) ?? []).map((t) => /^(['"]).*\1$/.test(t) ? t.slice(1, -1) : t);
8647
+ }
8648
+ /** Index of the first token that isn't an env-assignment prefix (`KEY=VAL cmd …`). */
8649
+ function firstCommandIndex(tokens) {
8650
+ let i = 0;
8651
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i] ?? "")) i++;
8652
+ return i;
8653
+ }
8654
+ /** The inner script of a `sh|bash|zsh|dash -c <script>` wrapper (as ONE token, since {@link tokenize} keeps a quoted phrase whole), or undefined when `tokens` isn't that shape. */
8655
+ function unwrapShellC(tokens) {
8656
+ const i = firstCommandIndex(tokens);
8657
+ const bin = tokens[i]?.slice(tokens[i].lastIndexOf("/") + 1);
8658
+ if (!bin || !SHELL_BINS.has(bin)) return void 0;
8659
+ const flag = tokens[i + 1];
8660
+ if (!flag || !/^-[a-z]*c$/.test(flag)) return void 0;
8661
+ return tokens[i + 2];
8662
+ }
8663
+ /** Credit the `.md` arguments of one segment if its command is a whitelisted read; recurses once into a `sh -c` wrapper. */
8664
+ function scanSegment(raw, out) {
8665
+ const segment = beforeRedirect(raw);
8666
+ const tokens = tokenize(segment);
8667
+ const inner = unwrapShellC(tokens);
8668
+ if (inner !== void 0) {
8669
+ for (const s of segments(inner)) scanSegment(s, out);
8670
+ return;
8671
+ }
8672
+ const i = firstCommandIndex(tokens);
8673
+ const name = tokens[i]?.slice(tokens[i].lastIndexOf("/") + 1);
8674
+ if (!name || !READ_COMMANDS.has(name)) return;
8675
+ if (name === "sed" && SED_INPLACE.test(segment)) return;
8676
+ for (const t of tokens.slice(i + 1)) if (t.endsWith(".md") && !t.startsWith("-")) out.push(t);
8677
+ }
8678
+ /**
8679
+ * The `.md` paths a read-only shell command reads in `command` (a Bash
8680
+ * `tool_input.command` — plain string or Codex's argv-array form, both
8681
+ * normalized via {@link commandToString}). Empty for a non-read command,
8682
+ * `sed -i` in-place, or an unparseable/absent command — fail-open, never a
8683
+ * false credit.
8684
+ * @param command - Raw `tool_input.command` value (string | string[] | unknown).
8685
+ */
8686
+ function shellReadRefPaths(command) {
8687
+ const str = commandToString(command);
8688
+ if (!str) return [];
8689
+ const out = [];
8690
+ for (const seg of segments(str)) scanSegment(seg, out);
8691
+ return out;
8692
+ }
8693
+ //#endregion
8694
+ //#region src/runtime/design-read-credit.ts
8695
+ /**
8696
+ * @module design-read-credit
8697
+ * Shared credit path for a design-corpus/ref read, reused by both the native
8698
+ * `Read` branch and the shell-read branch (Codex Code Mode `exec_command`,
8699
+ * canonicalized to `"Bash"` by `codex-shell-tool.ts`) in
8700
+ * `design-helpers.ts::recordPost`. Split out to keep that file within the
8701
+ * SOLID size budget — zero new parser, zero new store: both callers hit the
8702
+ * SAME `recordCorpusRead`/`recordRead` transitions.
8703
+ * @packageDocumentation
8704
+ */
8705
+ /** Credit one read path as a corpus read (anchored + on-disk) or a plain ref read. */
8706
+ function creditRead(cacheDir, state, corpusRoot, corpusRequired, fp) {
8707
+ if (classifyCorpusRead(fp, corpusRoot) && existsSync(fp)) saveDesignState(cacheDir, recordCorpusRead(state, fp.slice(corpusRoot.length + 1), corpusRequired));
8708
+ else saveDesignState(cacheDir, recordRead(state, fp, corpusRequired));
8709
+ }
8710
+ /** Credit every `.md` path a read-only shell `command` targets, same path as {@link creditRead}. */
8711
+ function creditShellReads(cacheDir, state, corpusRoot, corpusRequired, command) {
8712
+ for (const fp of shellReadRefPaths(command)) creditRead(cacheDir, state, corpusRoot, corpusRequired, fp);
8713
+ }
8714
+ //#endregion
8350
8715
  //#region src/runtime/design-helpers.ts
8351
8716
  /**
8352
8717
  * @module design-helpers
@@ -8383,11 +8748,9 @@ function recordPost(event, cacheDir, state, corpusRoot = "", corpusRequired = fa
8383
8748
  ...state,
8384
8749
  geminiCalls: state.geminiCalls + 1
8385
8750
  });
8386
- else if (event.tool === "Read") {
8387
- const fp = event.filePath ?? "";
8388
- if (classifyCorpusRead(fp, corpusRoot) && existsSync(fp)) saveDesignState(cacheDir, recordCorpusRead(state, fp.slice(corpusRoot.length + 1), corpusRequired));
8389
- else saveDesignState(cacheDir, recordRead(state, fp, corpusRequired));
8390
- } else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) {
8751
+ else if (event.tool === "Read") creditRead(cacheDir, state, corpusRoot, corpusRequired, event.filePath ?? "");
8752
+ else if (event.tool === "Bash" && event.command) creditShellReads(cacheDir, state, corpusRoot, corpusRequired, event.command);
8753
+ else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) {
8391
8754
  const fp = event.filePath ?? "";
8392
8755
  let content;
8393
8756
  try {
@@ -8435,6 +8798,14 @@ function designGate(payload, event, cacheDir, cwd, corpusRootOverride, pluginsRo
8435
8798
  const skillBlock = uiDesignSkillGate(event.tool, event.filePath ?? "", event.content ?? "", collectDesignEvidence(event.sessionId, cwd));
8436
8799
  if (skillBlock) return skillBlock;
8437
8800
  }
8801
+ if (event.phase !== "post" && event.files && event.files.length > 0) {
8802
+ const evidence = collectDesignEvidence(event.sessionId, cwd);
8803
+ for (const f of event.files) {
8804
+ if (f.op === "delete") continue;
8805
+ const skillBlock = uiDesignSkillGate(f.op === "add" ? "Write" : "Edit", f.filePath, f.content, evidence);
8806
+ if (skillBlock) return skillBlock;
8807
+ }
8808
+ }
8438
8809
  const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
8439
8810
  if (!agentId) return null;
8440
8811
  const active = activeDesignAgent(cacheDir);
@@ -8465,7 +8836,7 @@ function designGate(payload, event, cacheDir, cwd, corpusRootOverride, pluginsRo
8465
8836
  }
8466
8837
  if (event.tool === "Write" || event.tool === "Edit") {
8467
8838
  const fp = event.filePath ?? "";
8468
- const base = pluginsWriteGuard(fp, pluginsRoot) ?? stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state, corpusRequired) ?? designSystemContentGate({
8839
+ const base = pluginsWriteGuard(fp, pluginsRoot) ?? stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? htmlCssPipelineGate(fp, state, findDesignSystem(cwd) !== "") ?? designSystemWriteGate(fp, state, corpusRequired) ?? designSystemContentGate({
8469
8840
  filePath: fp,
8470
8841
  tool: event.tool,
8471
8842
  content: event.content ?? "",
@@ -8484,7 +8855,7 @@ function designGate(payload, event, cacheDir, cwd, corpusRootOverride, pluginsRo
8484
8855
  };
8485
8856
  return null;
8486
8857
  }
8487
- if (event.files && event.files.length > 0) return designFilesGate(event.files, state, pluginsRoot, corpusRoot, corpusRequired, cwd);
8858
+ if (event.files && event.files.length > 0) return designFilesGate(event.files, state, pluginsRoot, corpusRoot, corpusRequired, cwd, findDesignSystem(cwd) !== "");
8488
8859
  if (event.tool === NAV) return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : "");
8489
8860
  if (event.tool === SHOT) return screenshotScrollGate(state);
8490
8861
  if (event.tool === GEMINI) {
@@ -8830,6 +9201,191 @@ function applyPatchGate(files, cwd) {
8830
9201
  return null;
8831
9202
  }
8832
9203
  //#endregion
9204
+ //#region src/runtime/confirm/confirm-code.ts
9205
+ /**
9206
+ * Full SHA-256 hex digest (64 chars) of an action string (the exact command
9207
+ * a deny prompt showed). This is the ONLY value ever compared to authorize a
9208
+ * confirmation (G3, confirm-state.ts) — collision-resistant, unlike the
9209
+ * 4-char {@link displayCodeForAction} below.
9210
+ * @param action - The command/content string a deny prompt showed.
9211
+ */
9212
+ function hashForAction(action) {
9213
+ return createHash("sha256").update(action).digest("hex");
9214
+ }
9215
+ /**
9216
+ * 4-hex-char PREFIX of {@link hashForAction}, for DISPLAY ONLY — never a
9217
+ * security boundary. It exists purely so a human has something short to
9218
+ * retype ("CONFIRM 4f2a"); at 16 bits it collides constantly across
9219
+ * unrelated actions (birthday bound ~a few hundred actions/session), and the
9220
+ * agent ALWAYS sees it too — `systemMessage` never reaches the user or model
9221
+ * under Codex (measured 2026-08-03: 0 occurrence in the rollout, nothing on
9222
+ * screen), so `permissionDecisionReason` is the only channel, and the model
9223
+ * reads its own code back in the very tool result that got blocked.
9224
+ *
9225
+ * What actually scopes a confirmation to ONE specific action is the full
9226
+ * hash recorded at deny time and re-checked at consume time (see
9227
+ * confirm-pending.ts / confirm-state.ts). What prevents an agent from typing
9228
+ * its own code back to self-approve is G0 (fail-closed while a sub-agent is
9229
+ * active) — never the code's secrecy, because it has none.
9230
+ * @param action - The command/content string to derive the display code from.
9231
+ */
9232
+ function displayCodeForAction(action) {
9233
+ return hashForAction(action).slice(0, 4);
9234
+ }
9235
+ //#endregion
9236
+ //#region src/runtime/confirm/confirm-irreversible.ts
9237
+ /**
9238
+ * A single flag token carrying BOTH `r` and `f` in either order (`-rf`,
9239
+ * `-fr`, `-rfv`, …) — same lookahead technique as patterns.ts's
9240
+ * `CLEAN_FD_FLAG`, reused here rather than re-derived (DRY).
9241
+ */
9242
+ const RM_COMBINED_FLAG = /\s-(?=[a-zA-Z]*r)(?=[a-zA-Z]*f)[a-zA-Z]+(?:\s|=|$)/;
9243
+ /** Split flags `-r ... -f` (either order) within the SAME command segment (no `;&|` between). */
9244
+ const RM_SPLIT_FLAG = /\brm\b[^;&|\n]*\s-r\b[^;&|\n]*\s-f\b|\brm\b[^;&|\n]*\s-f\b[^;&|\n]*\s-r\b/;
9245
+ /**
9246
+ * Generic `rm -rf`/`-fr` (ANY target, not just `/`/`~`) — deliberately
9247
+ * broader than `src/codex-rules/rules/rm-variants.ts`'s root/system-path-only
9248
+ * forbidden list: G4 must treat every recursive force-delete as
9249
+ * never-confirmable, not only the ones aimed at a system path.
9250
+ */
9251
+ function isRmRf(cmd) {
9252
+ return /\brm\b/.test(cmd) && (RM_COMBINED_FLAG.test(cmd) || RM_SPLIT_FLAG.test(cmd));
9253
+ }
9254
+ /**
9255
+ * G4: commands NEVER unlockable by a CONFIRM token, regardless of a valid,
9256
+ * fresh, correctly-scoped one. Reuses {@link GIT_BLOCKED} (push --force,
9257
+ * reset --hard, clean -fd, branch -D, rebase --force — DRY, same list the
9258
+ * rest of the policy already enforces) plus a generic `rm -rf` check.
9259
+ * @param cmd - The command a prompt is about (empty/undefined = not irreversible).
9260
+ */
9261
+ function isIrreversible(cmd) {
9262
+ if (!cmd) return false;
9263
+ return matchPatterns(cmd, GIT_BLOCKED) || isRmRf(cmd);
9264
+ }
9265
+ //#endregion
9266
+ //#region src/runtime/confirm/confirm-pending.ts
9267
+ /**
9268
+ * Record the action a deny prompt just showed (overwrites any previous
9269
+ * pending deny for this session — only the LAST blocked action can be
9270
+ * confirmed). Pure bookkeeping, no gate: every ask-turned-deny records one,
9271
+ * whether or not the user ever confirms it.
9272
+ * @param sessionIdRaw - Raw session id from the payload.
9273
+ * @param hash - Full {@link import("./confirm-code").hashForAction} of the command.
9274
+ * @param code - The short display code shown alongside it.
9275
+ * @param now - Epoch ms.
9276
+ * @param home - Test-only OS home override.
9277
+ */
9278
+ function recordPendingDeny(sessionIdRaw, hash, code, now, home = homedir()) {
9279
+ const sid = sanitizeSessionId(sessionIdRaw);
9280
+ if (!sid) return;
9281
+ saveSessionState(sid, {
9282
+ ...loadSessionState(sid, home),
9283
+ pendingDeny: {
9284
+ hash,
9285
+ code,
9286
+ ts: now
9287
+ }
9288
+ }, home);
9289
+ }
9290
+ /** Read back the pending deny for a session (`undefined` when none/invalid session id). */
9291
+ function getPendingDeny(sessionIdRaw, home = homedir()) {
9292
+ const sid = sanitizeSessionId(sessionIdRaw);
9293
+ if (!sid) return void 0;
9294
+ return loadSessionState(sid, home).pendingDeny;
9295
+ }
9296
+ //#endregion
9297
+ //#region src/runtime/confirm/confirm-state.ts
9298
+ /** Confirmation freshness window (G2) — 5 minutes, matches the validated prototype. */
9299
+ const TTL_MS = 300 * 1e3;
9300
+ /**
9301
+ * Pose a confirmation token for this session's pending action. No-op while
9302
+ * {@link isSubagentActive} (G0, confirm-subagent.ts) — the sole gate that
9303
+ * stops an agent, which always sees the display code in its own blocked tool
9304
+ * result, from typing it back to self-approve.
9305
+ */
9306
+ function placeConfirmToken(sessionIdRaw, hash, now, home = homedir(), env = process.env) {
9307
+ const sid = sanitizeSessionId(sessionIdRaw);
9308
+ if (!sid || isSubagentActive(sid, now, home, env)) return;
9309
+ saveSessionState(sid, {
9310
+ ...loadSessionState(sid, home),
9311
+ confirmToken: {
9312
+ hash,
9313
+ ts: now
9314
+ }
9315
+ }, home);
9316
+ }
9317
+ /** Invalidate any pending token for this session (G5: an explicit refusal). */
9318
+ function dropConfirmToken(sessionIdRaw, home = homedir()) {
9319
+ const sid = sanitizeSessionId(sessionIdRaw);
9320
+ if (!sid) return;
9321
+ const state = loadSessionState(sid, home);
9322
+ if (state.confirmToken === void 0) return;
9323
+ const { confirmToken: _drop, ...rest } = state;
9324
+ saveSessionState(sid, rest, home);
9325
+ }
9326
+ /**
9327
+ * Consume a token that matches `hash` exactly (G3), whether or not it's
9328
+ * fresh — a mismatched hash leaves the token untouched (it may still be
9329
+ * valid for the action it actually confirms). G1 (one-shot) + G2 (5-min TTL)
9330
+ * both apply only once the hash matches.
9331
+ * @returns true = allow (token consumed); false = deny (nothing changed, or
9332
+ * the matching token had expired and was dropped).
9333
+ */
9334
+ function consumeConfirmToken(sessionIdRaw, hash, now, home = homedir()) {
9335
+ const sid = sanitizeSessionId(sessionIdRaw);
9336
+ if (!sid) return false;
9337
+ const tok = loadSessionState(sid, home).confirmToken;
9338
+ if (!tok || tok.hash !== hash) return false;
9339
+ if (now - tok.ts > TTL_MS) {
9340
+ dropConfirmToken(sid, home);
9341
+ return false;
9342
+ }
9343
+ dropConfirmToken(sid, home);
9344
+ return true;
9345
+ }
9346
+ //#endregion
9347
+ //#region src/runtime/confirm/confirm-gate.ts
9348
+ /**
9349
+ * Harnesses where `respond.ts` silently downgrades `kind: "ask"` to a hard
9350
+ * deny (Codex: `case "codex"`; Kimi: `toKimiResponse` maps `ask` to the same
9351
+ * `permissionDecision:"deny"` envelope as `block`). Claude Code keeps native
9352
+ * interactive `ask` — this mechanism NEVER applies there.
9353
+ */
9354
+ const DEGRADES_ASK_TO_DENY = /* @__PURE__ */ new Set(["codex", "kimi"]);
9355
+ /**
9356
+ * Whether/how a CONFIRM token changes an `ask` prompt about to be downgraded
9357
+ * to a deny. Returns `null` when this mechanism doesn't apply AT ALL — any
9358
+ * harness other than codex/kimi, any prompt kind other than `ask`, no
9359
+ * command to key a hash off, or an irreversible command (G4) — in which case
9360
+ * the caller's ORIGINAL prompt/response path runs completely unchanged. That
9361
+ * `null` fast-path, hit on every claude-code call and every non-`ask` prompt,
9362
+ * IS the non-regression property.
9363
+ * @param id - Harness target id.
9364
+ * @param prompt - The prompt `gate()` returned.
9365
+ * @param command - `event.command` for the tool-use under judgment.
9366
+ * @param sessionId - `event.sessionId`.
9367
+ * @param now - Epoch ms.
9368
+ * @param home - Test-only OS home override.
9369
+ */
9370
+ function confirmGate(id, prompt, command, sessionId, now, home) {
9371
+ if (prompt.kind !== "ask" || !DEGRADES_ASK_TO_DENY.has(id) || !command || isIrreversible(command)) return null;
9372
+ try {
9373
+ const hash = hashForAction(command);
9374
+ if (consumeConfirmToken(sessionId, hash, now, home)) return { allow: true };
9375
+ const code = displayCodeForAction(command);
9376
+ recordPendingDeny(sessionId, hash, code, now, home);
9377
+ return {
9378
+ allow: false,
9379
+ prompt: {
9380
+ ...prompt,
9381
+ reason: `${prompt.reason}\nPour autoriser, réponds : CONFIRM ${code}`
9382
+ }
9383
+ };
9384
+ } catch {
9385
+ return null;
9386
+ }
9387
+ }
9388
+ //#endregion
8833
9389
  //#region src/runtime/handle-pre.ts
8834
9390
  /**
8835
9391
  * Run the PreToolUse pipeline: MCP/WebFetch cache intercept, design gate, APEX Task context injection, then the stateless+APEX gate chain, returning the native hook outcome (deny/ask/inject or allow).
@@ -8838,6 +9394,7 @@ function applyPatchGate(files, cwd) {
8838
9394
  */
8839
9395
  async function handlePre(ctx) {
8840
9396
  const { id, payload, event, framework, mcpDir, file, opts } = ctx;
9397
+ const designCacheDir = ctx.designCacheDir ?? mcpDir;
8841
9398
  const intercept = mcpPreIntercept(id, event.tool, event.input, mcpDir, MCP_TTL_MS, opts.now);
8842
9399
  if (intercept !== null) {
8843
9400
  if (intercept.docSource) await recordActivity(file, {
@@ -8851,7 +9408,7 @@ async function handlePre(ctx) {
8851
9408
  exit: 0
8852
9409
  };
8853
9410
  }
8854
- const designBlock = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot);
9411
+ const designBlock = designGate(payload, event, designCacheDir, opts.cwd, opts.corpusRoot);
8855
9412
  if (designBlock) return {
8856
9413
  stdout: withDenyNotice(id, respond(id, designBlock), designBlock, event.sessionId, dirname(file), opts.now),
8857
9414
  exit: 0
@@ -8894,11 +9451,20 @@ async function handlePre(ctx) {
8894
9451
  transcriptPath: typeof payload.transcript_path === "string" ? payload.transcript_path : void 0,
8895
9452
  neverApproval: id === "codex" && isBypassPermissions(event.permissionMode)
8896
9453
  });
8897
- if (prompt) return {
8898
- stdout: withDenyNotice(id, respond(id, prompt), prompt, event.sessionId, dirname(file), opts.now),
8899
- exit: 0
8900
- };
8901
- return allowOutcome(id, event, payload, mcpDir, opts.cwd, {
9454
+ if (prompt) {
9455
+ const confirm = confirmGate(id, prompt, event.command, event.sessionId, opts.now, opts.home);
9456
+ if (confirm?.allow) return allowOutcome(id, event, payload, designCacheDir, opts.cwd, {
9457
+ trackFile: file,
9458
+ windowMs: opts.windowMs,
9459
+ now: opts.now
9460
+ }, opts.corpusRoot);
9461
+ const finalPrompt = confirm ? confirm.prompt : prompt;
9462
+ return {
9463
+ stdout: withDenyNotice(id, respond(id, finalPrompt), finalPrompt, event.sessionId, dirname(file), opts.now),
9464
+ exit: 0
9465
+ };
9466
+ }
9467
+ return allowOutcome(id, event, payload, designCacheDir, opts.cwd, {
8902
9468
  trackFile: file,
8903
9469
  windowMs: opts.windowMs,
8904
9470
  now: opts.now
@@ -8984,104 +9550,6 @@ function docFramework(input, fallback) {
8984
9550
  return frameworkFromQuery(docQueryOf(input)) ?? fallback;
8985
9551
  }
8986
9552
  //#endregion
8987
- //#region src/policy/shell-read-refs.ts
8988
- /**
8989
- * @module shell-read-refs
8990
- * Codex teammates frequently read skill/SOLID reference `.md` files through a
8991
- * shell Bash call (`cat`, `head`, …) instead of a native `Read` — the
8992
- * refsRead/ref-journal freshness gates only ever credited a native `Read`
8993
- * (`src/runtime/activity.ts`'s `READ_TOOLS` branch), so a shell-read skill
8994
- * consultation was invisible to every SOLID/skill gate. This detects the
8995
- * `.md` paths a KNOWN read-only command targets in a Bash `command`, for a
8996
- * caller to fold into the SAME `{kind:"ref", path, ts}` activity
8997
- * `src/runtime/record.ts` already persists (no new store, no new gate).
8998
- *
8999
- * Fail-open by construction: only a whitelisted read-only command name
9000
- * credits its `.md` arguments — a non-read command (`echo`, `mv`, `tee`, …)
9001
- * sharing a chained/piped segment is never credited, and `sed -i`/
9002
- * `--in-place` (a MUTATION despite the `sed` name) is explicitly excluded.
9003
- * @packageDocumentation
9004
- */
9005
- /** Read-only shell commands that can target a `.md` reference by path. */
9006
- const READ_COMMANDS = /* @__PURE__ */ new Set([
9007
- "cat",
9008
- "head",
9009
- "tail",
9010
- "sed",
9011
- "rg",
9012
- "ripgrep",
9013
- "less",
9014
- "more",
9015
- "bat"
9016
- ]);
9017
- /** POSIX shells whose `-c`-style argv (or inline string) wraps a real script. */
9018
- const SHELL_BINS = /* @__PURE__ */ new Set([
9019
- "bash",
9020
- "sh",
9021
- "zsh",
9022
- "dash"
9023
- ]);
9024
- /** `sed -i` / `sed --in-place` mutates the file in place — never a read. */
9025
- const SED_INPLACE = /(^|\s)(-i\b|--in-place\b)/;
9026
- /** Split a command string on `&&`, `||`, `;`, `|`, and newlines — each side scanned independently. */
9027
- function segments(command) {
9028
- return command.split(/&&|\|\||[;|\n]/);
9029
- }
9030
- /** Strip a redirection (`>`, `>>`, `<`, `2>`, `&>`, …) and everything after — its target is WRITTEN, not read. */
9031
- function beforeRedirect(segment) {
9032
- const m = segment.match(/\s(?:\d*>{1,2}|<|&>)\s*\S/);
9033
- return m ? segment.slice(0, m.index) : segment;
9034
- }
9035
- /** Naive shell tokenizer: whitespace-split, stripping one matching layer of quotes per token. */
9036
- function tokenize(segment) {
9037
- return (segment.match(/(?:"[^"]*"|'[^']*'|\S+)/g) ?? []).map((t) => /^(['"]).*\1$/.test(t) ? t.slice(1, -1) : t);
9038
- }
9039
- /** Index of the first token that isn't an env-assignment prefix (`KEY=VAL cmd …`). */
9040
- function firstCommandIndex(tokens) {
9041
- let i = 0;
9042
- while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i] ?? "")) i++;
9043
- return i;
9044
- }
9045
- /** The inner script of a `sh|bash|zsh|dash -c <script>` wrapper (as ONE token, since {@link tokenize} keeps a quoted phrase whole), or undefined when `tokens` isn't that shape. */
9046
- function unwrapShellC(tokens) {
9047
- const i = firstCommandIndex(tokens);
9048
- const bin = tokens[i]?.slice(tokens[i].lastIndexOf("/") + 1);
9049
- if (!bin || !SHELL_BINS.has(bin)) return void 0;
9050
- const flag = tokens[i + 1];
9051
- if (!flag || !/^-[a-z]*c$/.test(flag)) return void 0;
9052
- return tokens[i + 2];
9053
- }
9054
- /** Credit the `.md` arguments of one segment if its command is a whitelisted read; recurses once into a `sh -c` wrapper. */
9055
- function scanSegment(raw, out) {
9056
- const segment = beforeRedirect(raw);
9057
- const tokens = tokenize(segment);
9058
- const inner = unwrapShellC(tokens);
9059
- if (inner !== void 0) {
9060
- for (const s of segments(inner)) scanSegment(s, out);
9061
- return;
9062
- }
9063
- const i = firstCommandIndex(tokens);
9064
- const name = tokens[i]?.slice(tokens[i].lastIndexOf("/") + 1);
9065
- if (!name || !READ_COMMANDS.has(name)) return;
9066
- if (name === "sed" && SED_INPLACE.test(segment)) return;
9067
- for (const t of tokens.slice(i + 1)) if (t.endsWith(".md") && !t.startsWith("-")) out.push(t);
9068
- }
9069
- /**
9070
- * The `.md` paths a read-only shell command reads in `command` (a Bash
9071
- * `tool_input.command` — plain string or Codex's argv-array form, both
9072
- * normalized via {@link commandToString}). Empty for a non-read command,
9073
- * `sed -i` in-place, or an unparseable/absent command — fail-open, never a
9074
- * false credit.
9075
- * @param command - Raw `tool_input.command` value (string | string[] | unknown).
9076
- */
9077
- function shellReadRefPaths(command) {
9078
- const str = commandToString(command);
9079
- if (!str) return [];
9080
- const out = [];
9081
- for (const seg of segments(str)) scanSegment(seg, out);
9082
- return out;
9083
- }
9084
- //#endregion
9085
9553
  //#region src/runtime/activity.ts
9086
9554
  /** Quality thresholds (chars): lead agent calls vs direct explore/research (parity Python `> 50`). */
9087
9555
  const AGENT_QUALITY_MIN = 500;
@@ -9400,17 +9868,29 @@ function firstFileMatch(files, check) {
9400
9868
  /**
9401
9869
  * Run the PostToolUse pipeline: store the MCP response, emit a design warning,
9402
9870
  * record the activity into the session track, apply per-scope side-effects (SEO
9403
- * deny, aipilot task cache), then inject the post-edit context. Codex
9404
- * `apply_patch` is fanned into per-file events ({@link fanOutFiles}) before the
9405
- * per-file gates (tracking, SOLID size, Tailwind, post-edit context) run.
9871
+ * deny, aipilot task cache), then inject the post-edit context.
9872
+ *
9873
+ * POST is advisory-only for the design pipeline: it can never undo a tool
9874
+ * that already ran (the hard block lives in the PreToolUse `designFilesGate`).
9875
+ * `designGate` therefore stays on the RAW, un-fanned event — its
9876
+ * `recordPost` apply_patch branch is promote-only and resolves relative
9877
+ * `design-system.md` paths via `join(cwd, …)` (design-helpers.ts); fanning
9878
+ * that call would instead route the file through the Write/Edit branch,
9879
+ * which reads `event.filePath` UNRESOLVED (breaking cwd-relative promotion)
9880
+ * and can DEGRADE the state — both forbidden by the apply_patch promote-only
9881
+ * doctrine (see `design-files-gate.ts` module doc). Only `designPassNotice`
9882
+ * (pure formatting, no disk access, no state write) is fanned via
9883
+ * {@link fanOutFiles}, so `apply_patch` gets one notice line per real file
9884
+ * instead of none.
9406
9885
  * @param ctx - The resolved context (same shape as the pre pipeline).
9407
9886
  * @returns The native hook outcome.
9408
9887
  */
9409
9888
  async function handlePost(ctx) {
9410
9889
  const { id, payload, event, framework, mcpDir, file, opts } = ctx;
9890
+ const designCacheDir = ctx.designCacheDir ?? mcpDir;
9411
9891
  const response = payload.tool_response ?? payload.tool_output;
9412
9892
  mcpPostStore(event.tool, event.input, response, mcpDir);
9413
- const designWarn = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot);
9893
+ const designWarn = designGate(payload, event, designCacheDir, opts.cwd, opts.corpusRoot);
9414
9894
  const activities = activityFor({
9415
9895
  tool: event.tool,
9416
9896
  input: event.input,
@@ -9462,14 +9942,25 @@ async function handlePost(ctx) {
9462
9942
  extra = await postEditContext(opts.scope ?? "core", f, opts.now, id);
9463
9943
  if (extra) break;
9464
9944
  }
9465
- const notice = designPassNotice({
9466
- agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
9467
- tool: event.tool,
9468
- filePath: event.filePath ?? "",
9469
- content: event.content ?? "",
9470
- url: "",
9471
- phase: "post"
9472
- }, mcpDir);
9945
+ const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
9946
+ const noticeLines = [];
9947
+ for (const f of files) {
9948
+ const n = designPassNotice({
9949
+ agentId,
9950
+ tool: f.tool,
9951
+ filePath: f.filePath ?? "",
9952
+ content: f.content ?? "",
9953
+ url: "",
9954
+ phase: "post"
9955
+ }, designCacheDir);
9956
+ if (n?.userMessage) noticeLines.push(n.userMessage);
9957
+ }
9958
+ const notice = noticeLines.length ? {
9959
+ kind: "inform",
9960
+ title: "Design pipeline",
9961
+ reason: "",
9962
+ userMessage: noticeLines.join("\n")
9963
+ } : null;
9473
9964
  const refNotice = refCreditNoticeFor(activities, event.sessionId, opts.now, defaultStateDir(opts.cwd));
9474
9965
  const userMessage = [notice?.userMessage, refNotice].filter(Boolean).join("\n") || void 0;
9475
9966
  if (designWarn) return {
@@ -9532,6 +10023,53 @@ async function asyncScopeStdout(scope, event, payload, cwd, now, id = "claude-co
9532
10023
  return null;
9533
10024
  }
9534
10025
  //#endregion
10026
+ //#region src/runtime/design-cache-resolve.ts
10027
+ /** `~/.fuse-harness/design-sessions/<sid>` — session-anchored design-state dir. */
10028
+ function sessionCacheDir(sid, home) {
10029
+ return join(fuseHarnessHome(home), "design-sessions", sid);
10030
+ }
10031
+ /** True when `dir` already holds the design-agent flag or a `.design-state-*.json` snapshot. */
10032
+ function hasDesignState(dir) {
10033
+ if (existsSync(join(dir, "design-agent-active"))) return true;
10034
+ try {
10035
+ return readdirSync(dir).some((name) => name.startsWith(".design-state-"));
10036
+ } catch {
10037
+ return false;
10038
+ }
10039
+ }
10040
+ /**
10041
+ * Resolve the cache dir the design pipeline (active-agent flag +
10042
+ * `.design-state-<agentId>.json`) should read AND write for this hook call.
10043
+ *
10044
+ * Anchored on the session id, not the invoking process's `cwd`: a design
10045
+ * agent's `SubagentStart` and its later tool writes can run with different
10046
+ * `process.cwd()` values, and a cwd-keyed cache dir then silently misses the
10047
+ * flag/state — every design gate fails open (see MEMORY/LESSON.md).
10048
+ *
10049
+ * Contract (non-regression): `resolved = sessionDirIfPopulated ??
10050
+ * cwdDirIfPopulated ?? sessionDirDefault`. Before this fix the session-keyed
10051
+ * dir never existed, so for every session whose state already lives under
10052
+ * `cwdCacheDir` the FIRST branch is always empty and resolution falls through
10053
+ * to `cwdCacheDir` — byte-identical to the pre-fix value. Only a session with
10054
+ * NO state anywhere (today's silent-fail-open case, e.g. cwd drifted between
10055
+ * `SubagentStart` and the write) diverges, anchoring on the session dir
10056
+ * instead of silently missing the flag. Read and write always resolve through
10057
+ * this same function against the same two inputs, so they can never target
10058
+ * different locations mid-session.
10059
+ * @param sessionId - The hook event's session id (`NormalizedEvent.sessionId`).
10060
+ * @param cwdCacheDir - The legacy, cwd-derived cache dir (`projectLayout(cwd).cacheDir`).
10061
+ * @param home - OS home override (tests only).
10062
+ * @returns The cache dir to pass to the design flag/state/gate functions.
10063
+ */
10064
+ function resolveDesignCacheDir(sessionId, cwdCacheDir, home) {
10065
+ const sid = sanitizeSessionId(sessionId);
10066
+ if (!sid) return cwdCacheDir;
10067
+ const sessDir = sessionCacheDir(sid, home);
10068
+ if (hasDesignState(sessDir)) return sessDir;
10069
+ if (hasDesignState(cwdCacheDir)) return cwdCacheDir;
10070
+ return sessDir;
10071
+ }
10072
+ //#endregion
9535
10073
  //#region src/runtime/lifecycle/codex-resync/lock.ts
9536
10074
  /** Stale-after window (ms) — survives a session crashing before its `finally`. */
9537
10075
  const STALE_MS = 3e4;
@@ -9918,6 +10456,39 @@ function attachBudgetRecap(stdout, rawEvent, sessionId, cwd, now) {
9918
10456
  return attachSystemMessage(stdout, budgetReport(fragments));
9919
10457
  }
9920
10458
  //#endregion
10459
+ //#region src/runtime/confirm/confirm-submit.ts
10460
+ /** Common explicit-refusal words (fr/en), any of which invalidates a pending token (G5). */
10461
+ const REFUSAL_RE = /\b(non|no|stop|annule|cancel|abort|nope|laisse tomber|pas maintenant)\b/i;
10462
+ /** `CONFIRM <4-hex-chars>`, case-insensitive, tolerant of `confirm4f2a` / `Confirm-4f2a` / `confirm_4f2a`. */
10463
+ const CONFIRM_RE = /confirm[\s_-]*([0-9a-f]{4})\b/i;
10464
+ /**
10465
+ * Parse a submitted user prompt for `CONFIRM <code>` or an explicit refusal.
10466
+ * A refusal always wins (checked first) and drops any pending token (G5),
10467
+ * even if the same text also happens to contain a code. A confirm only
10468
+ * places a token when its 4-char code matches this session's LAST pending
10469
+ * deny (see confirm-pending.ts) — the code is looked up back to the full
10470
+ * hash that deny recorded, never compared as a code-to-code match (that
10471
+ * would reopen the collision {@link import("./confirm-code").displayCodeForAction}
10472
+ * warns about). Never throws — this runs inside a hook.
10473
+ * @param sessionId - The normalized event's session id.
10474
+ * @param text - The prompt text ({@link import("../prompt-text").promptText} output).
10475
+ * @param now - Epoch ms.
10476
+ * @param home - Test-only OS home override.
10477
+ */
10478
+ function handleConfirmSubmit(sessionId, text, now, home) {
10479
+ try {
10480
+ if (REFUSAL_RE.test(text)) {
10481
+ dropConfirmToken(sessionId, home);
10482
+ return;
10483
+ }
10484
+ const typedCode = text.match(CONFIRM_RE)?.[1];
10485
+ if (!typedCode) return;
10486
+ const pending = getPendingDeny(sessionId, home);
10487
+ if (!pending || pending.code.toLowerCase() !== typedCode.toLowerCase()) return;
10488
+ placeConfirmToken(sessionId, pending.hash, now, home);
10489
+ } catch {}
10490
+ }
10491
+ //#endregion
9921
10492
  //#region src/runtime/handle.ts
9922
10493
  /** Raw Claude hook event name from a payload (empty when absent). */
9923
10494
  function rawEventName(payload) {
@@ -9935,8 +10506,9 @@ async function handleHook(id, payload, opts) {
9935
10506
  const layout = projectLayout(opts.cwd);
9936
10507
  const file = trackFile(event.sessionId, defaultStateDir(opts.cwd));
9937
10508
  const mcpDir = layout.cacheDir;
10509
+ const designCacheDir = resolveDesignCacheDir(event.sessionId, mcpDir, opts.home);
9938
10510
  const framework = detectFramework(event.filePath ?? "", event.content ?? "", opts.cwd);
9939
- if ((id === "claude-code" || id === "codex") && designLifecycle(payload, mcpDir, opts.cwd, String(opts.now), opts.now)) return {
10511
+ if ((id === "claude-code" || id === "codex") && designLifecycle(payload, designCacheDir, opts.cwd, String(opts.now), opts.now)) return {
9940
10512
  stdout: "",
9941
10513
  exit: 0
9942
10514
  };
@@ -9951,8 +10523,10 @@ async function handleHook(id, payload, opts) {
9951
10523
  stdout: id === "claude-code" ? attachBudgetRecap(life, rawEventName(payload), event.sessionId, opts.cwd, opts.now) : life,
9952
10524
  exit: 0
9953
10525
  };
9954
- const userPrompt = typeof payload.prompt === "string" ? payload.prompt : void 0;
10526
+ const rawPrompt = payload.prompt;
10527
+ const userPrompt = typeof rawPrompt === "string" || Array.isArray(rawPrompt) ? promptText(rawPrompt) : void 0;
9955
10528
  if (userPrompt !== void 0) {
10529
+ handleConfirmSubmit(event.sessionId, userPrompt, opts.now, opts.home);
9956
10530
  await withTrack(file, (track) => recordBrainstormRequired(track, detectCreationIntent(userPrompt)));
9957
10531
  return {
9958
10532
  stdout: promptSubmitContext(userPrompt, opts.cwd, id),
@@ -9965,6 +10539,7 @@ async function handleHook(id, payload, opts) {
9965
10539
  event,
9966
10540
  framework,
9967
10541
  mcpDir,
10542
+ designCacheDir,
9968
10543
  file,
9969
10544
  opts
9970
10545
  });
@@ -9974,6 +10549,7 @@ async function handleHook(id, payload, opts) {
9974
10549
  event,
9975
10550
  framework,
9976
10551
  mcpDir,
10552
+ designCacheDir,
9977
10553
  file,
9978
10554
  opts
9979
10555
  });