@fusengine/harness 0.1.87 → 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.
- package/README.md +34 -3
- package/dist/adapters/claude/index.mjs +1 -1
- package/dist/adapters/cline/index.mjs +1 -1
- package/dist/adapters/codex/index.mjs +4 -4
- package/dist/adapters/cursor/index.mjs +1 -1
- package/dist/adapters/gemini/index.mjs +1 -1
- package/dist/adapters/hermes/index.mjs +1 -1
- package/dist/adapters/kimi/index.mjs +1 -1
- package/dist/{claude-CEWX-8m9.mjs → claude-5bC8TDAz.mjs} +1 -1
- package/dist/cli/bin.mjs +2 -2
- package/dist/cli/index.mjs +1 -1
- package/dist/{evaluate-9bUzRquQ.mjs → evaluate-C1VSI5pb.mjs} +48 -4
- package/dist/{handle-Boj3vnxD.mjs → handle-DHhukRdh.mjs} +635 -135
- package/dist/{hermes-H6qePmBC.mjs → hermes-kP6NUQlG.mjs} +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{kimi-CqLkpg1A.mjs → kimi-D6X7b8wF.mjs} +2 -2
- package/dist/{permission-mode-BN3MNgbm.mjs → permission-mode-B0MUl7FT.mjs} +38 -1
- package/dist/policy/index.mjs +2 -2
- package/dist/{run-B4kqWMA3.mjs → run-DdWG0Meg.mjs} +1 -1
- package/dist/runtime/index.d.mts +9 -0
- package/dist/runtime/index.mjs +1 -1
- package/dist/{validate-DYPT8NY-.mjs → validate-DZFtAbLP.mjs} +1 -1
- package/package.json +1 -1
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
19
|
-
import { i as toKimiResponse, n as kimiDenyResponse } from "./kimi-
|
|
20
|
-
import { r as toHermesResponse } from "./hermes-
|
|
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(
|
|
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
|
-
/**
|
|
8326
|
-
|
|
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
|
-
|
|
8464
|
-
|
|
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,
|
|
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)
|
|
8974
|
-
|
|
8975
|
-
|
|
8976
|
-
|
|
8977
|
-
|
|
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.
|
|
9480
|
-
*
|
|
9481
|
-
*
|
|
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,
|
|
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
|
|
9542
|
-
|
|
9543
|
-
|
|
9544
|
-
|
|
9545
|
-
|
|
9546
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
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,
|
|
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
|
|
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
|
});
|