@fusengine/harness 0.1.87 → 0.1.89

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.
@@ -3,21 +3,21 @@ 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-DYPT8NY-.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
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
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 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";
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
  }
@@ -2493,6 +2520,114 @@ function harvestSubagentTrack(payload, cwd, now, baseDir = defaultStateDir(cwd))
2493
2520
  }) === LOCK_FAILED) return;
2494
2521
  }
2495
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
2496
2631
  //#region src/cli/doctor.ts
2497
2632
  /**
2498
2633
  * `harness doctor` — diagnose which `@fusengine/harness` is actually running.
@@ -5902,6 +6037,7 @@ function dispatchLifecycle(input) {
5902
6037
  if (input.scope === "lessons") return dispatchLessons("UserPromptSubmit", input.payload, input.cwd, input.now, input.id ?? "claude-code");
5903
6038
  return null;
5904
6039
  case "SubagentStart":
6040
+ markSubagentSeen(input.payload.session_id, input.now);
5905
6041
  if (input.scope === "rules") return injectRules(resolveRulesRoot(input.id ?? "claude-code", input.cwd), input.event, input.id ?? "claude-code");
5906
6042
  if (input.scope === "aipilot") return "";
5907
6043
  if (input.scope === "lessons") return dispatchLessons("SubagentStart", input.payload, input.cwd, input.now, input.id ?? "claude-code");
@@ -5910,6 +6046,7 @@ function dispatchLifecycle(input) {
5910
6046
  if (input.scope === "lessons") return dispatchLessons("Stop", input.payload, input.cwd, input.now, input.id ?? "claude-code");
5911
6047
  return input.scope === "core" ? stopCore(input.payload, input.cwd, input.now) : null;
5912
6048
  case "SubagentStop":
6049
+ markSubagentSeen(input.payload.session_id, input.now);
5913
6050
  if (input.scope === "aipilot") return "";
5914
6051
  harvestSubagentTrack(input.payload, input.cwd, input.now);
5915
6052
  return trackAgentMemory(input.payload, void 0, input.now);
@@ -8288,6 +8425,23 @@ function designSystemWriteGate(filePath, state, corpusRequired = false) {
8288
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`);
8289
8426
  return null;
8290
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
+ }
8291
8445
  /** Gate Gemini create_frontend: requires phase >= 3 and a validated design system. */
8292
8446
  function geminiCreateGate(state) {
8293
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");
@@ -8322,12 +8476,28 @@ function browserNavigateGate(state, url) {
8322
8476
  function substituteLiteral(s, from, to, all) {
8323
8477
  return all ? s.split(from).join(to) : s.replace(from, () => to);
8324
8478
  }
8325
- /** Gate every file of a multi-file write primitive; the first violation blocks the envelope. */
8326
- 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) {
8327
8487
  for (const f of files) {
8328
8488
  const hit = pluginsWriteGuard(f.filePath, pluginsRoot, cwd) ?? stateFileGate(f.filePath);
8329
8489
  if (hit) return hit;
8330
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
+ };
8331
8501
  if (!f.filePath.endsWith("design-system.md")) continue;
8332
8502
  const gate = designSystemWriteGate(f.filePath, state, corpusRequired) ?? (f.op === "add" ? designSystemContentGate({
8333
8503
  filePath: f.filePath,
@@ -8423,6 +8593,125 @@ function designSystemContentGate(i) {
8423
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.`);
8424
8594
  }
8425
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
8426
8715
  //#region src/runtime/design-helpers.ts
8427
8716
  /**
8428
8717
  * @module design-helpers
@@ -8459,11 +8748,9 @@ function recordPost(event, cacheDir, state, corpusRoot = "", corpusRequired = fa
8459
8748
  ...state,
8460
8749
  geminiCalls: state.geminiCalls + 1
8461
8750
  });
8462
- else if (event.tool === "Read") {
8463
- const fp = event.filePath ?? "";
8464
- if (classifyCorpusRead(fp, corpusRoot) && existsSync(fp)) saveDesignState(cacheDir, recordCorpusRead(state, fp.slice(corpusRoot.length + 1), corpusRequired));
8465
- else saveDesignState(cacheDir, recordRead(state, fp, corpusRequired));
8466
- } 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")) {
8467
8754
  const fp = event.filePath ?? "";
8468
8755
  let content;
8469
8756
  try {
@@ -8511,6 +8798,14 @@ function designGate(payload, event, cacheDir, cwd, corpusRootOverride, pluginsRo
8511
8798
  const skillBlock = uiDesignSkillGate(event.tool, event.filePath ?? "", event.content ?? "", collectDesignEvidence(event.sessionId, cwd));
8512
8799
  if (skillBlock) return skillBlock;
8513
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
+ }
8514
8809
  const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
8515
8810
  if (!agentId) return null;
8516
8811
  const active = activeDesignAgent(cacheDir);
@@ -8541,7 +8836,7 @@ function designGate(payload, event, cacheDir, cwd, corpusRootOverride, pluginsRo
8541
8836
  }
8542
8837
  if (event.tool === "Write" || event.tool === "Edit") {
8543
8838
  const fp = event.filePath ?? "";
8544
- 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({
8545
8840
  filePath: fp,
8546
8841
  tool: event.tool,
8547
8842
  content: event.content ?? "",
@@ -8560,7 +8855,7 @@ function designGate(payload, event, cacheDir, cwd, corpusRootOverride, pluginsRo
8560
8855
  };
8561
8856
  return null;
8562
8857
  }
8563
- 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) !== "");
8564
8859
  if (event.tool === NAV) return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : "");
8565
8860
  if (event.tool === SHOT) return screenshotScrollGate(state);
8566
8861
  if (event.tool === GEMINI) {
@@ -8906,6 +9201,191 @@ function applyPatchGate(files, cwd) {
8906
9201
  return null;
8907
9202
  }
8908
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
8909
9389
  //#region src/runtime/handle-pre.ts
8910
9390
  /**
8911
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).
@@ -8914,6 +9394,7 @@ function applyPatchGate(files, cwd) {
8914
9394
  */
8915
9395
  async function handlePre(ctx) {
8916
9396
  const { id, payload, event, framework, mcpDir, file, opts } = ctx;
9397
+ const designCacheDir = ctx.designCacheDir ?? mcpDir;
8917
9398
  const intercept = mcpPreIntercept(id, event.tool, event.input, mcpDir, MCP_TTL_MS, opts.now);
8918
9399
  if (intercept !== null) {
8919
9400
  if (intercept.docSource) await recordActivity(file, {
@@ -8927,7 +9408,7 @@ async function handlePre(ctx) {
8927
9408
  exit: 0
8928
9409
  };
8929
9410
  }
8930
- const designBlock = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot);
9411
+ const designBlock = designGate(payload, event, designCacheDir, opts.cwd, opts.corpusRoot);
8931
9412
  if (designBlock) return {
8932
9413
  stdout: withDenyNotice(id, respond(id, designBlock), designBlock, event.sessionId, dirname(file), opts.now),
8933
9414
  exit: 0
@@ -8970,11 +9451,20 @@ async function handlePre(ctx) {
8970
9451
  transcriptPath: typeof payload.transcript_path === "string" ? payload.transcript_path : void 0,
8971
9452
  neverApproval: id === "codex" && isBypassPermissions(event.permissionMode)
8972
9453
  });
8973
- if (prompt) return {
8974
- stdout: withDenyNotice(id, respond(id, prompt), prompt, event.sessionId, dirname(file), opts.now),
8975
- exit: 0
8976
- };
8977
- 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, {
8978
9468
  trackFile: file,
8979
9469
  windowMs: opts.windowMs,
8980
9470
  now: opts.now
@@ -9060,104 +9550,6 @@ function docFramework(input, fallback) {
9060
9550
  return frameworkFromQuery(docQueryOf(input)) ?? fallback;
9061
9551
  }
9062
9552
  //#endregion
9063
- //#region src/policy/shell-read-refs.ts
9064
- /**
9065
- * @module shell-read-refs
9066
- * Codex teammates frequently read skill/SOLID reference `.md` files through a
9067
- * shell Bash call (`cat`, `head`, …) instead of a native `Read` — the
9068
- * refsRead/ref-journal freshness gates only ever credited a native `Read`
9069
- * (`src/runtime/activity.ts`'s `READ_TOOLS` branch), so a shell-read skill
9070
- * consultation was invisible to every SOLID/skill gate. This detects the
9071
- * `.md` paths a KNOWN read-only command targets in a Bash `command`, for a
9072
- * caller to fold into the SAME `{kind:"ref", path, ts}` activity
9073
- * `src/runtime/record.ts` already persists (no new store, no new gate).
9074
- *
9075
- * Fail-open by construction: only a whitelisted read-only command name
9076
- * credits its `.md` arguments — a non-read command (`echo`, `mv`, `tee`, …)
9077
- * sharing a chained/piped segment is never credited, and `sed -i`/
9078
- * `--in-place` (a MUTATION despite the `sed` name) is explicitly excluded.
9079
- * @packageDocumentation
9080
- */
9081
- /** Read-only shell commands that can target a `.md` reference by path. */
9082
- const READ_COMMANDS = /* @__PURE__ */ new Set([
9083
- "cat",
9084
- "head",
9085
- "tail",
9086
- "sed",
9087
- "rg",
9088
- "ripgrep",
9089
- "less",
9090
- "more",
9091
- "bat"
9092
- ]);
9093
- /** POSIX shells whose `-c`-style argv (or inline string) wraps a real script. */
9094
- const SHELL_BINS = /* @__PURE__ */ new Set([
9095
- "bash",
9096
- "sh",
9097
- "zsh",
9098
- "dash"
9099
- ]);
9100
- /** `sed -i` / `sed --in-place` mutates the file in place — never a read. */
9101
- const SED_INPLACE = /(^|\s)(-i\b|--in-place\b)/;
9102
- /** Split a command string on `&&`, `||`, `;`, `|`, and newlines — each side scanned independently. */
9103
- function segments(command) {
9104
- return command.split(/&&|\|\||[;|\n]/);
9105
- }
9106
- /** Strip a redirection (`>`, `>>`, `<`, `2>`, `&>`, …) and everything after — its target is WRITTEN, not read. */
9107
- function beforeRedirect(segment) {
9108
- const m = segment.match(/\s(?:\d*>{1,2}|<|&>)\s*\S/);
9109
- return m ? segment.slice(0, m.index) : segment;
9110
- }
9111
- /** Naive shell tokenizer: whitespace-split, stripping one matching layer of quotes per token. */
9112
- function tokenize(segment) {
9113
- return (segment.match(/(?:"[^"]*"|'[^']*'|\S+)/g) ?? []).map((t) => /^(['"]).*\1$/.test(t) ? t.slice(1, -1) : t);
9114
- }
9115
- /** Index of the first token that isn't an env-assignment prefix (`KEY=VAL cmd …`). */
9116
- function firstCommandIndex(tokens) {
9117
- let i = 0;
9118
- while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i] ?? "")) i++;
9119
- return i;
9120
- }
9121
- /** 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. */
9122
- function unwrapShellC(tokens) {
9123
- const i = firstCommandIndex(tokens);
9124
- const bin = tokens[i]?.slice(tokens[i].lastIndexOf("/") + 1);
9125
- if (!bin || !SHELL_BINS.has(bin)) return void 0;
9126
- const flag = tokens[i + 1];
9127
- if (!flag || !/^-[a-z]*c$/.test(flag)) return void 0;
9128
- return tokens[i + 2];
9129
- }
9130
- /** Credit the `.md` arguments of one segment if its command is a whitelisted read; recurses once into a `sh -c` wrapper. */
9131
- function scanSegment(raw, out) {
9132
- const segment = beforeRedirect(raw);
9133
- const tokens = tokenize(segment);
9134
- const inner = unwrapShellC(tokens);
9135
- if (inner !== void 0) {
9136
- for (const s of segments(inner)) scanSegment(s, out);
9137
- return;
9138
- }
9139
- const i = firstCommandIndex(tokens);
9140
- const name = tokens[i]?.slice(tokens[i].lastIndexOf("/") + 1);
9141
- if (!name || !READ_COMMANDS.has(name)) return;
9142
- if (name === "sed" && SED_INPLACE.test(segment)) return;
9143
- for (const t of tokens.slice(i + 1)) if (t.endsWith(".md") && !t.startsWith("-")) out.push(t);
9144
- }
9145
- /**
9146
- * The `.md` paths a read-only shell command reads in `command` (a Bash
9147
- * `tool_input.command` — plain string or Codex's argv-array form, both
9148
- * normalized via {@link commandToString}). Empty for a non-read command,
9149
- * `sed -i` in-place, or an unparseable/absent command — fail-open, never a
9150
- * false credit.
9151
- * @param command - Raw `tool_input.command` value (string | string[] | unknown).
9152
- */
9153
- function shellReadRefPaths(command) {
9154
- const str = commandToString(command);
9155
- if (!str) return [];
9156
- const out = [];
9157
- for (const seg of segments(str)) scanSegment(seg, out);
9158
- return out;
9159
- }
9160
- //#endregion
9161
9553
  //#region src/runtime/activity.ts
9162
9554
  /** Quality thresholds (chars): lead agent calls vs direct explore/research (parity Python `> 50`). */
9163
9555
  const AGENT_QUALITY_MIN = 500;
@@ -9476,17 +9868,29 @@ function firstFileMatch(files, check) {
9476
9868
  /**
9477
9869
  * Run the PostToolUse pipeline: store the MCP response, emit a design warning,
9478
9870
  * record the activity into the session track, apply per-scope side-effects (SEO
9479
- * deny, aipilot task cache), then inject the post-edit context. Codex
9480
- * `apply_patch` is fanned into per-file events ({@link fanOutFiles}) before the
9481
- * 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.
9482
9885
  * @param ctx - The resolved context (same shape as the pre pipeline).
9483
9886
  * @returns The native hook outcome.
9484
9887
  */
9485
9888
  async function handlePost(ctx) {
9486
9889
  const { id, payload, event, framework, mcpDir, file, opts } = ctx;
9890
+ const designCacheDir = ctx.designCacheDir ?? mcpDir;
9487
9891
  const response = payload.tool_response ?? payload.tool_output;
9488
9892
  mcpPostStore(event.tool, event.input, response, mcpDir);
9489
- const designWarn = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot);
9893
+ const designWarn = designGate(payload, event, designCacheDir, opts.cwd, opts.corpusRoot);
9490
9894
  const activities = activityFor({
9491
9895
  tool: event.tool,
9492
9896
  input: event.input,
@@ -9538,14 +9942,25 @@ async function handlePost(ctx) {
9538
9942
  extra = await postEditContext(opts.scope ?? "core", f, opts.now, id);
9539
9943
  if (extra) break;
9540
9944
  }
9541
- const notice = designPassNotice({
9542
- agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
9543
- tool: event.tool,
9544
- filePath: event.filePath ?? "",
9545
- content: event.content ?? "",
9546
- url: "",
9547
- phase: "post"
9548
- }, 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;
9549
9964
  const refNotice = refCreditNoticeFor(activities, event.sessionId, opts.now, defaultStateDir(opts.cwd));
9550
9965
  const userMessage = [notice?.userMessage, refNotice].filter(Boolean).join("\n") || void 0;
9551
9966
  if (designWarn) return {
@@ -9608,6 +10023,53 @@ async function asyncScopeStdout(scope, event, payload, cwd, now, id = "claude-co
9608
10023
  return null;
9609
10024
  }
9610
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
9611
10073
  //#region src/runtime/lifecycle/codex-resync/lock.ts
9612
10074
  /** Stale-after window (ms) — survives a session crashing before its `finally`. */
9613
10075
  const STALE_MS = 3e4;
@@ -9994,6 +10456,39 @@ function attachBudgetRecap(stdout, rawEvent, sessionId, cwd, now) {
9994
10456
  return attachSystemMessage(stdout, budgetReport(fragments));
9995
10457
  }
9996
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
9997
10492
  //#region src/runtime/handle.ts
9998
10493
  /** Raw Claude hook event name from a payload (empty when absent). */
9999
10494
  function rawEventName(payload) {
@@ -10011,8 +10506,9 @@ async function handleHook(id, payload, opts) {
10011
10506
  const layout = projectLayout(opts.cwd);
10012
10507
  const file = trackFile(event.sessionId, defaultStateDir(opts.cwd));
10013
10508
  const mcpDir = layout.cacheDir;
10509
+ const designCacheDir = resolveDesignCacheDir(event.sessionId, mcpDir, opts.home);
10014
10510
  const framework = detectFramework(event.filePath ?? "", event.content ?? "", opts.cwd);
10015
- 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 {
10016
10512
  stdout: "",
10017
10513
  exit: 0
10018
10514
  };
@@ -10027,8 +10523,10 @@ async function handleHook(id, payload, opts) {
10027
10523
  stdout: id === "claude-code" ? attachBudgetRecap(life, rawEventName(payload), event.sessionId, opts.cwd, opts.now) : life,
10028
10524
  exit: 0
10029
10525
  };
10030
- 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;
10031
10528
  if (userPrompt !== void 0) {
10529
+ handleConfirmSubmit(event.sessionId, userPrompt, opts.now, opts.home);
10032
10530
  await withTrack(file, (track) => recordBrainstormRequired(track, detectCreationIntent(userPrompt)));
10033
10531
  return {
10034
10532
  stdout: promptSubmitContext(userPrompt, opts.cwd, id),
@@ -10041,6 +10539,7 @@ async function handleHook(id, payload, opts) {
10041
10539
  event,
10042
10540
  framework,
10043
10541
  mcpDir,
10542
+ designCacheDir,
10044
10543
  file,
10045
10544
  opts
10046
10545
  });
@@ -10050,6 +10549,7 @@ async function handleHook(id, payload, opts) {
10050
10549
  event,
10051
10550
  framework,
10052
10551
  mcpDir,
10552
+ designCacheDir,
10053
10553
  file,
10054
10554
  opts
10055
10555
  });