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