@fusengine/harness 0.1.49 → 0.1.51
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/dist/cli/bin.mjs +1 -1
- package/dist/{handle-D1hHqHvh.mjs → handle-QfgfNSh6.mjs} +1108 -252
- package/dist/runtime/index.d.mts +24 -9
- package/dist/runtime/index.mjs +2 -2
- package/dist/{session-state-DMpotbRz.d.mts → session-state-CY4iohn_.d.mts} +17 -0
- package/dist/{store-QSSTO3lY.mjs → store-CQ4roWrU.mjs} +1 -1
- package/dist/tracking/index.d.mts +1 -1
- package/dist/tracking/index.mjs +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { a as parseEnvInt, i as splitTarget, r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
|
|
2
|
+
import { s as resolveTtlSec } from "./dotenv-Jj8aL1FL.mjs";
|
|
2
3
|
import { r as projectLayout } from "./layout-C0jaaCQC.mjs";
|
|
3
4
|
import { i as walkUpFor, n as projectRoot, r as projectRootOrNull, t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
|
|
4
5
|
import { A as detectCreationIntent, E as isExcludedSwiftPath, F as docConsultedGate, H as detectProjectType$1, I as evaluateApex, M as POST_AUTH_GATES, N as PRE_AUTH_GATES, S as usesTailwindUtilities, T as isExcludedJsPath, V as detectModularArchitecture, W as requiredArchSkill, _ as scanPlugin, c as EXCLUDE_DIRS$1, d as buildApexTaskInjection, h as buildClaudeMdContext, k as capVerbosity, l as PROJECT_INDICATORS, n as missingSeoElements, o as parseEnrichment, r as descFromText, s as parseEntry, t as isHtmlLike, w as frameworkSolidGate, x as skillTriggerGate, y as parseField, z as detectFramework } from "./validate-DCQ8dkdL.mjs";
|
|
@@ -11,7 +12,7 @@ import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
|
|
|
11
12
|
import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as setStateField, t as addRoot } from "./registry-CymilZiZ.mjs";
|
|
12
13
|
import { d as loadIndex, i as cacheLookupMeta, n as webfetchCacheWrite, o as cacheLookupSubstringMeta, t as mcpCacheWrite, u as extractText } from "./mcp-store-BkBDmuxN.mjs";
|
|
13
14
|
import { t as loadRefs } from "./loader-AGz4nK7d.mjs";
|
|
14
|
-
import { c as
|
|
15
|
+
import { a as writeLastNonce, c as recordAgent, d as recordRefRead, f as recordTarget, h as apexAuthorizationGate, i as verifyTrack, l as recordBrainstormRequired, m as trivialCount, n as saveTrack, o as agentsFresh, p as recordTrivialEdit, r as signTrack, s as emptyTrack, t as loadTrack, u as recordDoc } from "./store-CQ4roWrU.mjs";
|
|
15
16
|
import { 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, r as contextResponse, s as informResponse, t as attachSystemMessage } from "./claude-BxC9semG.mjs";
|
|
16
17
|
import { r as toHermesResponse } from "./hermes-DWXCRFZU.mjs";
|
|
17
18
|
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
@@ -327,18 +328,81 @@ function designLifecycle(payload, cacheDir, cwd, stamp, now) {
|
|
|
327
328
|
}
|
|
328
329
|
return false;
|
|
329
330
|
}
|
|
331
|
+
/** Sidecar basename under the per-project state dir. */
|
|
332
|
+
const SIDECAR$1 = "inject-dedup.json";
|
|
333
|
+
/** Load the `{ key -> epochMs }` map, or `{}` when missing/corrupt. */
|
|
334
|
+
function loadMap$1(path) {
|
|
335
|
+
try {
|
|
336
|
+
if (!existsSync(path)) return {};
|
|
337
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
338
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
339
|
+
} catch {
|
|
340
|
+
return {};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Keep only entries newer than `windowMs` before `now` (bounds sidecar size). */
|
|
344
|
+
function prune$1(map, now, windowMs) {
|
|
345
|
+
const out = {};
|
|
346
|
+
for (const [k, t] of Object.entries(map)) if (typeof t === "number" && now - t < windowMs) out[k] = t;
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Cooldown gate. Returns `true` when `key` has NOT been recorded within the last
|
|
351
|
+
* `windowMs` (the caller MAY emit — and the emission is recorded now), or `false`
|
|
352
|
+
* when it was (the caller SHOULD suppress). The first call in a window wins;
|
|
353
|
+
* subsequent identical keys are throttled until the window elapses.
|
|
354
|
+
*
|
|
355
|
+
* Fails open: if the sidecar is unwritable, the emission is allowed rather than
|
|
356
|
+
* silently dropping context.
|
|
357
|
+
* @param key - Stable identity of the block (e.g. a content hash, or `lesson:<id>`).
|
|
358
|
+
* @param windowMs - Suppression window in ms.
|
|
359
|
+
* @param opts - Optional clock + state-dir overrides (for tests).
|
|
360
|
+
* @returns `true` to proceed/emit, `false` to suppress.
|
|
361
|
+
*/
|
|
362
|
+
function oncePerWindow(key, windowMs, opts = {}) {
|
|
363
|
+
const now = opts.now ?? Date.now();
|
|
364
|
+
const path = join(opts.dir ?? defaultStateDir(), SIDECAR$1);
|
|
365
|
+
const map = prune$1(loadMap$1(path), now, windowMs);
|
|
366
|
+
const last = map[key];
|
|
367
|
+
if (typeof last === "number" && now - last < windowMs) return false;
|
|
368
|
+
map[key] = now;
|
|
369
|
+
try {
|
|
370
|
+
atomicWrite(path, JSON.stringify(map));
|
|
371
|
+
} catch {}
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
330
374
|
//#endregion
|
|
331
375
|
//#region src/runtime/inject-context.ts
|
|
332
376
|
/**
|
|
377
|
+
* Build the {@link oncePerWindow} key for the CLAUDE.md preamble gate. The
|
|
378
|
+
* prompt hash keeps two distinct legitimate turns from colliding — even non-dev
|
|
379
|
+
* prompts, whose block is prompt-independent (just CLAUDE.md) and would
|
|
380
|
+
* otherwise hash-collide within the window — while the content hash still lets a
|
|
381
|
+
* same-turn double-fire of an identical block be suppressed. Single source of
|
|
382
|
+
* truth so the owner invariant test guards the real production key.
|
|
383
|
+
* @param prompt - The raw user prompt.
|
|
384
|
+
* @param ctx - The rendered CLAUDE.md (+ optional APEX) block.
|
|
385
|
+
* @returns The namespaced dedup key.
|
|
386
|
+
*/
|
|
387
|
+
function claudeMdKey(prompt, ctx) {
|
|
388
|
+
return `claude-md:${hashText(prompt)}:${hashText(ctx)}`;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
333
391
|
* UserPromptSubmit context injection: render the CLAUDE.md (+ optional APEX)
|
|
334
392
|
* preamble as a Claude `additionalContext` response, or "" when nothing to emit.
|
|
393
|
+
* Guarded by {@link oncePerWindow} via {@link claudeMdKey}: only a
|
|
394
|
+
* near-simultaneous double-fire of the SAME turn (identical prompt AND identical
|
|
395
|
+
* block, within {@link DEDUP_WINDOW_MS}) is suppressed. The invariant "CLAUDE.md
|
|
396
|
+
* is emitted on EVERY message" is thus preserved.
|
|
335
397
|
* @param prompt - The raw user prompt.
|
|
336
398
|
* @param cwd - Project root (for project-type detection).
|
|
337
399
|
* @returns The native hook stdout (possibly empty).
|
|
338
400
|
*/
|
|
339
401
|
function promptSubmitContext(prompt, cwd) {
|
|
340
402
|
const ctx = buildClaudeMdContext(prompt, cwd);
|
|
341
|
-
|
|
403
|
+
if (!ctx) return "";
|
|
404
|
+
if (!oncePerWindow(claudeMdKey(prompt, ctx), 3e3)) return "";
|
|
405
|
+
return contextResponse("UserPromptSubmit", ctx);
|
|
342
406
|
}
|
|
343
407
|
/**
|
|
344
408
|
* PreToolUse Task context injection: render the APEX sub-agent context as a
|
|
@@ -867,6 +931,207 @@ function subagentCacheContext(sessionIdRaw, home = homedir(), env = process.env,
|
|
|
867
931
|
return fresh.length ? contextResponse("SubagentStart", render(fresh)) : "";
|
|
868
932
|
}
|
|
869
933
|
//#endregion
|
|
934
|
+
//#region src/tracking/receipts.ts
|
|
935
|
+
/**
|
|
936
|
+
* @module receipts
|
|
937
|
+
* Verification receipts: capture `tsc`/test runs at PostToolUse and query the
|
|
938
|
+
* freshest PASSING one for the TaskCompleted (hard) and SubagentStop (advisory)
|
|
939
|
+
* gates. A "done" over modified code files is refused unless such a receipt
|
|
940
|
+
* exists — mechanising "no proof, no done".
|
|
941
|
+
* @packageDocumentation
|
|
942
|
+
*/
|
|
943
|
+
/** `tsc` / `bunx tsc` / `npx tsc -p .` — a type-check invocation. */
|
|
944
|
+
const TSC_RE = /(?:^|\s|\/)(?:bunx\s+|npx\s+|pnpm\s+|yarn\s+)?tsc\b/;
|
|
945
|
+
/** `bun test`, `vitest`, `jest`, `npm test`, `npm run test`, … — a test run. */
|
|
946
|
+
const TEST_RE = /\b(?:bun\s+test|vitest|jest|(?:npm|pnpm|yarn)\s+(?:run\s+)?test)\b/;
|
|
947
|
+
/** Append a verification receipt to the track. Immutable. */
|
|
948
|
+
function recordReceipt(track, receipt) {
|
|
949
|
+
return {
|
|
950
|
+
...track,
|
|
951
|
+
receipts: [...track.receipts ?? [], receipt]
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
/** Parse `N pass` / `M fail` counts from bun/vitest/jest output (undefined when absent). */
|
|
955
|
+
function parseCounts(output) {
|
|
956
|
+
const pass = output.match(/(\d+)\s+pass/i);
|
|
957
|
+
const fail = output.match(/(\d+)\s+fail/i);
|
|
958
|
+
return {
|
|
959
|
+
pass: pass ? Number(pass[1]) : void 0,
|
|
960
|
+
fail: fail ? Number(fail[1]) : void 0
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Classify a Bash command as a verification receipt, or `null` when it is not a
|
|
965
|
+
* recognised `tsc`/test invocation. Test runs additionally carry parsed
|
|
966
|
+
* pass/fail counts.
|
|
967
|
+
* @param command - The Bash command line.
|
|
968
|
+
* @param output - Combined stdout+stderr (bun writes its summary to stderr).
|
|
969
|
+
* @param exitCode - The command's exit code.
|
|
970
|
+
* @param now - Capture timestamp (epoch ms).
|
|
971
|
+
*/
|
|
972
|
+
function classifyReceipt(command, output, exitCode, now) {
|
|
973
|
+
if (TEST_RE.test(command)) {
|
|
974
|
+
const { pass, fail } = parseCounts(output);
|
|
975
|
+
return {
|
|
976
|
+
kind: "test",
|
|
977
|
+
exitCode,
|
|
978
|
+
pass,
|
|
979
|
+
fail,
|
|
980
|
+
ts: now
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
if (TSC_RE.test(command)) return {
|
|
984
|
+
kind: "tsc",
|
|
985
|
+
exitCode,
|
|
986
|
+
ts: now
|
|
987
|
+
};
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
/** A receipt PROVES success: exit 0 and (for tests) zero reported failures. */
|
|
991
|
+
function isPassing(r) {
|
|
992
|
+
return r.exitCode === 0 && (r.fail ?? 0) === 0;
|
|
993
|
+
}
|
|
994
|
+
/** The newest passing receipt within `windowMs`, or `null`. */
|
|
995
|
+
function freshPassingReceipt(track, windowMs, now) {
|
|
996
|
+
const cutoff = now - windowMs;
|
|
997
|
+
const hits = (track.receipts ?? []).filter((r) => r.ts > cutoff && isPassing(r));
|
|
998
|
+
return hits.length ? hits.reduce((a, b) => b.ts > a.ts ? b : a) : null;
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Sync variant reading the signed track file directly — for the sync gates
|
|
1002
|
+
* (TaskCompleted / SubagentStop). Returns the newest passing receipt, or `null`
|
|
1003
|
+
* on any read/verify failure (fail-closed: no proof ⇒ treated as unverified).
|
|
1004
|
+
*/
|
|
1005
|
+
function freshReceiptFromFile(file, windowMs, now) {
|
|
1006
|
+
try {
|
|
1007
|
+
if (!existsSync(file)) return null;
|
|
1008
|
+
const track = verifyTrack(JSON.parse(readFileSync(file, "utf-8")));
|
|
1009
|
+
return track ? freshPassingReceipt(track, windowMs, now) : null;
|
|
1010
|
+
} catch {
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Capture a verification receipt from a PostToolUse Bash command into the signed
|
|
1016
|
+
* track (best effort — a non-verification command is a no-op).
|
|
1017
|
+
*/
|
|
1018
|
+
async function captureReceipt(file, command, output, exitCode, now) {
|
|
1019
|
+
const receipt = classifyReceipt(command, output, exitCode, now);
|
|
1020
|
+
if (!receipt) return;
|
|
1021
|
+
await saveTrack(file, recordReceipt(await loadTrack(file), receipt));
|
|
1022
|
+
}
|
|
1023
|
+
//#endregion
|
|
1024
|
+
//#region src/runtime/lifecycle/agent-transcript.ts
|
|
1025
|
+
/**
|
|
1026
|
+
* Shared low-level parser for a sub-agent's OWN transcript (`agent_transcript_path`
|
|
1027
|
+
* — the clean JSONL the Claude Code platform writes per sub-agent, CLI v2.0.42+).
|
|
1028
|
+
* Per LESSON.md this is the RELIABLE anchor for evidence: sidechain PostToolUse
|
|
1029
|
+
* hooks are not guaranteed (issues #43612/#27655/#34692). One parser, reused by
|
|
1030
|
+
* file-attribution (`agent-files.ts`) and retroactive evidence harvesting
|
|
1031
|
+
* (`src/freshness/evidence-harvest.ts`) — no duplication.
|
|
1032
|
+
*/
|
|
1033
|
+
/** Parse a raw `timestamp` field to epoch ms; `undefined` when absent or invalid. */
|
|
1034
|
+
function parseTs$2(raw) {
|
|
1035
|
+
if (raw === void 0) return void 0;
|
|
1036
|
+
if (typeof raw === "number") return raw;
|
|
1037
|
+
const ms = Date.parse(raw);
|
|
1038
|
+
return Number.isFinite(ms) ? ms : void 0;
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Scan a sub-agent transcript into its ordered `tool_use` blocks (each with the
|
|
1042
|
+
* enclosing message timestamp). Distinguishes "no tool use" from "cannot read":
|
|
1043
|
+
* returns `null` when the path is absent or the transcript is unreadable (callers
|
|
1044
|
+
* fail-open), or a possibly-empty array when parsed. Malformed lines/blocks are
|
|
1045
|
+
* tolerated and skipped.
|
|
1046
|
+
* @param transcriptPath - Absolute path to the sub-agent's own `.jsonl` transcript.
|
|
1047
|
+
* @returns Ordered tool uses, or `null` when unreadable.
|
|
1048
|
+
*/
|
|
1049
|
+
function readAgentToolUses(transcriptPath) {
|
|
1050
|
+
if (!transcriptPath) return null;
|
|
1051
|
+
let text;
|
|
1052
|
+
try {
|
|
1053
|
+
text = readText(transcriptPath);
|
|
1054
|
+
} catch {
|
|
1055
|
+
return null;
|
|
1056
|
+
}
|
|
1057
|
+
const out = [];
|
|
1058
|
+
for (const line of text.split("\n")) {
|
|
1059
|
+
if (!line.trim()) continue;
|
|
1060
|
+
let entry;
|
|
1061
|
+
try {
|
|
1062
|
+
entry = JSON.parse(line);
|
|
1063
|
+
} catch {
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
const content = entry.message?.content;
|
|
1067
|
+
if (!Array.isArray(content)) continue;
|
|
1068
|
+
const ts = parseTs$2(entry.timestamp);
|
|
1069
|
+
for (const block of content) {
|
|
1070
|
+
if (block?.type !== "tool_use" || !block.name) continue;
|
|
1071
|
+
out.push({
|
|
1072
|
+
name: block.name,
|
|
1073
|
+
input: block.input,
|
|
1074
|
+
ts
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return out;
|
|
1079
|
+
}
|
|
1080
|
+
//#endregion
|
|
1081
|
+
//#region src/runtime/lifecycle/agent-files.ts
|
|
1082
|
+
/**
|
|
1083
|
+
* Per-agent file attribution for SubagentStop. Uses the shared transcript parser
|
|
1084
|
+
* ({@link readAgentToolUses}) to recover the exact set of files a sub-agent wrote
|
|
1085
|
+
* via Write/Edit/MultiEdit/NotebookEdit, so the sniper reminder attributes only
|
|
1086
|
+
* the files THIS agent touched instead of every file changed in the whole session
|
|
1087
|
+
* (which cross-attributes other teammates' work — the bug this fixes).
|
|
1088
|
+
*/
|
|
1089
|
+
/** Tools whose `input.file_path`/`notebook_path` names a file the agent authored. */
|
|
1090
|
+
const WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
1091
|
+
"Write",
|
|
1092
|
+
"Edit",
|
|
1093
|
+
"MultiEdit",
|
|
1094
|
+
"NotebookEdit"
|
|
1095
|
+
]);
|
|
1096
|
+
/**
|
|
1097
|
+
* Return the distinct file paths written by the sub-agent whose own transcript is
|
|
1098
|
+
* at `transcriptPath` (Write/Edit/MultiEdit/NotebookEdit). `null` when unreadable
|
|
1099
|
+
* (caller MUST fall back to the session-wide list — no regression); a possibly-
|
|
1100
|
+
* empty array when parsed (empty = authored nothing, so it should NOT be told to
|
|
1101
|
+
* validate files other agents changed).
|
|
1102
|
+
* @param transcriptPath - Absolute path to the sub-agent's own `.jsonl` transcript.
|
|
1103
|
+
* @returns Distinct written paths (order-preserving), or `null` when unreadable.
|
|
1104
|
+
*/
|
|
1105
|
+
function filesWrittenByAgent(transcriptPath) {
|
|
1106
|
+
const uses = readAgentToolUses(transcriptPath);
|
|
1107
|
+
if (uses === null) return null;
|
|
1108
|
+
const out = [];
|
|
1109
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1110
|
+
for (const u of uses) {
|
|
1111
|
+
if (!WRITE_TOOLS.has(u.name)) continue;
|
|
1112
|
+
const fp = u.input?.file_path ?? u.input?.notebook_path;
|
|
1113
|
+
if (typeof fp === "string" && fp && !seen.has(fp)) {
|
|
1114
|
+
seen.add(fp);
|
|
1115
|
+
out.push(fp);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return out;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Filter `sessionFiles` down to the ones this agent actually wrote. Matches the
|
|
1122
|
+
* exact recorded string first, then tolerantly on `basename` so a relative-vs-
|
|
1123
|
+
* absolute drift between the PostToolUse record and the transcript input never
|
|
1124
|
+
* loses an owned file.
|
|
1125
|
+
* @param sessionFiles - Session-wide modified files.
|
|
1126
|
+
* @param written - Paths the agent wrote (from {@link filesWrittenByAgent}).
|
|
1127
|
+
* @returns The subset of `sessionFiles` attributable to this agent.
|
|
1128
|
+
*/
|
|
1129
|
+
function attributeFiles(sessionFiles, written) {
|
|
1130
|
+
const exact = new Set(written);
|
|
1131
|
+
const bases = new Set(written.map((f) => basename(f)));
|
|
1132
|
+
return sessionFiles.filter((f) => exact.has(f) || bases.has(basename(f)));
|
|
1133
|
+
}
|
|
1134
|
+
//#endregion
|
|
870
1135
|
//#region src/runtime/lifecycle/agent-memory.ts
|
|
871
1136
|
/** `~/.claude/memory/agents` — agent completion history dir. */
|
|
872
1137
|
function memoryDir(home) {
|
|
@@ -903,17 +1168,21 @@ function trackAgentMemory(data, home = homedir(), now = Date.now()) {
|
|
|
903
1168
|
if (SKIP_AGENTS.test(agentType)) return JSON.stringify({ message: `Agent ${agentType} completed` });
|
|
904
1169
|
const state = loadSessionState(sessionId, home);
|
|
905
1170
|
const changes = state.changes;
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1171
|
+
if ((changes?.cumulativeCodeFiles ?? 0) > 0) {
|
|
1172
|
+
const written = filesWrittenByAgent(typeof data.agent_transcript_path === "string" ? data.agent_transcript_path : void 0);
|
|
1173
|
+
const owned = written === null ? changes?.modifiedFiles ?? [] : attributeFiles(changes?.modifiedFiles ?? [], written);
|
|
1174
|
+
if (owned.length > 0) {
|
|
1175
|
+
saveSessionState(sessionId, {
|
|
1176
|
+
...state,
|
|
1177
|
+
changes: {
|
|
1178
|
+
...changes,
|
|
1179
|
+
cumulativeCodeFiles: 0
|
|
1180
|
+
}
|
|
1181
|
+
}, home);
|
|
1182
|
+
const windowMs = resolveTtlSec(process.env) * 1e3 * 5;
|
|
1183
|
+
const note = freshReceiptFromFile(trackFile(sessionId, defaultStateDir(process.cwd())), windowMs, now) === null ? " NO VERIFICATION RECEIPT — run tsc + tests before reporting done." : "";
|
|
1184
|
+
return contextResponse("SubagentStop", `SNIPER VALIDATION REQUIRED: Agent '${agentType}' modified ${owned.length} code file(s): ${owned.join(", ")}. Run sniper agent now.${note}`);
|
|
1185
|
+
}
|
|
917
1186
|
}
|
|
918
1187
|
return JSON.stringify({ message: `Agent ${agentType} completed (no code changes)` });
|
|
919
1188
|
}
|
|
@@ -1118,6 +1387,290 @@ function postEditTypescript(filePath) {
|
|
|
1118
1387
|
return contextResponse("PostToolUse", `Lint issues in ${basename(filePath)}: ${issues.join(" | ")}`);
|
|
1119
1388
|
}
|
|
1120
1389
|
//#endregion
|
|
1390
|
+
//#region src/freshness/explore-tools.ts
|
|
1391
|
+
/** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
|
|
1392
|
+
const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
|
|
1393
|
+
/**
|
|
1394
|
+
* Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`).
|
|
1395
|
+
* The fuse-browser fast-path entries are a deliberate TS addition: CLAUDE.md
|
|
1396
|
+
* mandates fuse-browser FIRST for web research, and `docSourceOf` (activity.ts)
|
|
1397
|
+
* already credits it for doc consultation — without them here, an agent
|
|
1398
|
+
* following the rules would never satisfy the freshness gate (same
|
|
1399
|
+
* cross-consumer inconsistency class as the solidReadGate lesson).
|
|
1400
|
+
*/
|
|
1401
|
+
const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
|
|
1402
|
+
"mcp__context7__query-docs",
|
|
1403
|
+
"mcp__context7__resolve-library-id",
|
|
1404
|
+
"mcp__exa__web_search_exa",
|
|
1405
|
+
"mcp__exa__get_code_context_exa",
|
|
1406
|
+
"mcp__exa__deep_researcher_start",
|
|
1407
|
+
"WebSearch",
|
|
1408
|
+
"WebFetch",
|
|
1409
|
+
"mcp__fuse-browser__browser_fetch",
|
|
1410
|
+
"mcp__fuse-browser__browser_fetch_batch",
|
|
1411
|
+
"mcp__fuse-browser__browser_crawl",
|
|
1412
|
+
"mcp__fuse-browser__browser_serp_batch"
|
|
1413
|
+
]);
|
|
1414
|
+
/** Bash executables that count as exploration (parity `EXPLORE_BASH_CMDS`). */
|
|
1415
|
+
const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
|
|
1416
|
+
"grep",
|
|
1417
|
+
"rg",
|
|
1418
|
+
"find",
|
|
1419
|
+
"ls",
|
|
1420
|
+
"fd",
|
|
1421
|
+
"ast-grep",
|
|
1422
|
+
"tree",
|
|
1423
|
+
"cat",
|
|
1424
|
+
"head",
|
|
1425
|
+
"tail"
|
|
1426
|
+
]);
|
|
1427
|
+
/** Legacy Python cache names still credited (parity `CACHE_READ_RE` + doc-helpers). */
|
|
1428
|
+
const CACHE_READ_RE = /\/context\/mcp\/(exa-search|exa-code-context|context7)-/;
|
|
1429
|
+
/**
|
|
1430
|
+
* Real TS cache stores: `<root>/.harness/cache/<fnv16>.md` (core MCP store,
|
|
1431
|
+
* `projectLayout().cacheDir`) and `~/.fuse-harness/cache/**` (ai-pilot doc
|
|
1432
|
+
* caches, `cache-base.cacheBaseDir`). Segment-matched — not resolved against
|
|
1433
|
+
* `homedir()` — so tilde-prefixed and absolute paths both hit; the `.md`
|
|
1434
|
+
* suffix keeps session-state JSON reads from counting as research.
|
|
1435
|
+
*/
|
|
1436
|
+
const TS_CACHE_READ_RE = /\.(?:fuse-)?harness[\\/]cache[\\/].*\.md$/;
|
|
1437
|
+
/**
|
|
1438
|
+
* First non-assignment shell token's basename, or "" — mirrors Python
|
|
1439
|
+
* `_bash_executable` (skips leading `VAR=value` env prefixes, e.g. `FOO=1 grep`).
|
|
1440
|
+
* @param cmd - Raw Bash `command` string.
|
|
1441
|
+
* @returns The executable basename, or "" when none.
|
|
1442
|
+
*/
|
|
1443
|
+
function bashExecutable(cmd) {
|
|
1444
|
+
for (const token of cmd.trim().split(/\s+/)) {
|
|
1445
|
+
if (!token) continue;
|
|
1446
|
+
const last = token.split("/").pop() ?? token;
|
|
1447
|
+
if (!last.includes("=")) return last;
|
|
1448
|
+
}
|
|
1449
|
+
return "";
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Classify a direct tool use into an APEX phase, or `null` when it is neither
|
|
1453
|
+
* exploration nor research. Mirrors Python `track-subagent-research._classify`.
|
|
1454
|
+
* @param tool - Harness tool name (e.g. "Glob", "Bash", "WebSearch").
|
|
1455
|
+
* @param input - Tool input payload.
|
|
1456
|
+
* @returns The credited phase + cache flag, or `null`.
|
|
1457
|
+
*/
|
|
1458
|
+
function classifyExplore(tool, input) {
|
|
1459
|
+
if (RESEARCH_TOOLS.has(tool)) return {
|
|
1460
|
+
phase: "research-expert",
|
|
1461
|
+
cacheHit: false
|
|
1462
|
+
};
|
|
1463
|
+
if (EXPLORE_TOOLS.has(tool)) return {
|
|
1464
|
+
phase: "explore-codebase",
|
|
1465
|
+
cacheHit: false
|
|
1466
|
+
};
|
|
1467
|
+
if (tool === "Read") {
|
|
1468
|
+
const path = String(input?.file_path ?? input?.path ?? "");
|
|
1469
|
+
if (path && (CACHE_READ_RE.test(path) || TS_CACHE_READ_RE.test(path))) return {
|
|
1470
|
+
phase: "research-expert",
|
|
1471
|
+
cacheHit: true
|
|
1472
|
+
};
|
|
1473
|
+
return null;
|
|
1474
|
+
}
|
|
1475
|
+
if (tool === "Bash") {
|
|
1476
|
+
const cmd = String(input?.command ?? "").trim();
|
|
1477
|
+
if (EXPLORE_BASH_CMDS.has(bashExecutable(cmd))) return {
|
|
1478
|
+
phase: "explore-codebase",
|
|
1479
|
+
cacheHit: false
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
return null;
|
|
1483
|
+
}
|
|
1484
|
+
//#endregion
|
|
1485
|
+
//#region src/freshness/agent-evidence-record.ts
|
|
1486
|
+
/**
|
|
1487
|
+
* Session-track agent evidence — writer (parity track-subagent-research.py) +
|
|
1488
|
+
* reader scan (parity apex_agent_helpers._scan_agents). Sub-agent hooks fire
|
|
1489
|
+
* with the LEAD's `session_id`, so evidence recorded here lands in the ONE
|
|
1490
|
+
* session track the freshness gate scans FIRST — sidechain research and
|
|
1491
|
+
* Workflow-spawned agents count, unlike the lead-transcript scan.
|
|
1492
|
+
*/
|
|
1493
|
+
/** Tools the existing Task tracking already credits as agent LAUNCHES. */
|
|
1494
|
+
const AGENT_LAUNCH_TOOLS = /* @__PURE__ */ new Set(["Task", "Agent"]);
|
|
1495
|
+
/**
|
|
1496
|
+
* `JSON.stringify` length of the raw `tool_response` OBJECT (parity Python
|
|
1497
|
+
* `len(str(tool_response))`); 0 when absent or unserializable (circular).
|
|
1498
|
+
*/
|
|
1499
|
+
function responseJsonLength(toolResponse) {
|
|
1500
|
+
if (toolResponse === void 0 || toolResponse === null) return 0;
|
|
1501
|
+
try {
|
|
1502
|
+
return (JSON.stringify(toolResponse) ?? "").length;
|
|
1503
|
+
} catch {
|
|
1504
|
+
return 0;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Classify one PostToolUse call into session evidence, or null when it is
|
|
1509
|
+
* neither exploration nor research (parity track-subagent-research._classify,
|
|
1510
|
+
* reusing the shared {@link classifyExplore} tables — research/explore/Bash/
|
|
1511
|
+
* cache-read). ANTI-DOUBLE-COUNT: calls classified as agent launches
|
|
1512
|
+
* (`Task`/`Agent`) are skipped — the existing Task tracking credits those; the
|
|
1513
|
+
* criterion is the CALL's classification, never its provenance (lead vs sub).
|
|
1514
|
+
* @param tool - Harness tool name (e.g. "Glob", "Bash", "Read").
|
|
1515
|
+
* @param input - Raw tool input payload.
|
|
1516
|
+
* @param toolResponse - Raw `tool_response` OBJECT from the hook payload.
|
|
1517
|
+
* @returns The evidence to record, or null to skip.
|
|
1518
|
+
*/
|
|
1519
|
+
function classifyAgentEvidence(tool, input, toolResponse) {
|
|
1520
|
+
if (AGENT_LAUNCH_TOOLS.has(tool)) return null;
|
|
1521
|
+
const hit = classifyExplore(tool, input);
|
|
1522
|
+
if (!hit) return null;
|
|
1523
|
+
return {
|
|
1524
|
+
name: hit.phase === "explore-codebase" ? "subagent-explore-codebase" : "subagent-research-expert",
|
|
1525
|
+
quality: hit.cacheHit || responseJsonLength(toolResponse) > 50 ? "sufficient" : "insufficient"
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Persist evidence into the SESSION track via {@link recordAgent} — keyed by
|
|
1530
|
+
* `session_id` alone (sub-agent hooks carry the lead's `session_id`); the TTL
|
|
1531
|
+
* anchors on `ts`, the tool call's own timestamp. `agentId` is tagged as
|
|
1532
|
+
* metadata when present, NEVER used as a condition (unreliable field —
|
|
1533
|
+
* anthropics/claude-code#22348).
|
|
1534
|
+
* @param file - Session track file path.
|
|
1535
|
+
* @param evidence - Classified evidence from {@link classifyAgentEvidence}.
|
|
1536
|
+
* @param ts - Epoch-ms timestamp of the tool call (the hook event's `now`).
|
|
1537
|
+
* @param agentId - Optional Claude `agent_id` — metadata tag only.
|
|
1538
|
+
*/
|
|
1539
|
+
async function recordAgentEvidence(file, evidence, ts, agentId) {
|
|
1540
|
+
const next = recordAgent(await loadTrack(file), evidence.name, ts, evidence.quality);
|
|
1541
|
+
const last = next.agents[next.agents.length - 1];
|
|
1542
|
+
if (agentId && last) {
|
|
1543
|
+
const tagged = {
|
|
1544
|
+
...last,
|
|
1545
|
+
agentId
|
|
1546
|
+
};
|
|
1547
|
+
next.agents[next.agents.length - 1] = tagged;
|
|
1548
|
+
}
|
|
1549
|
+
await saveTrack(file, next);
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Parity `_scan_agents` (apex_agent_helpers.py): reverse-scan `track.agents`,
|
|
1553
|
+
* STOPPING at the first entry older than `windowMs` (entries append in time
|
|
1554
|
+
* order), matching each required name by SUBSTRING (`research-expert` matches
|
|
1555
|
+
* `subagent-research-expert`) and counting ONLY `quality === "sufficient"`.
|
|
1556
|
+
* @param track - The loaded session track.
|
|
1557
|
+
* @param names - Required agent names — ALL must match to return true.
|
|
1558
|
+
* @param windowMs - Freshness window (ms), anchored on each entry's `ts`.
|
|
1559
|
+
* @param now - Current epoch ms.
|
|
1560
|
+
* @returns True when every required name has fresh, sufficient evidence.
|
|
1561
|
+
*/
|
|
1562
|
+
function agentsFreshInTrack(track, names, windowMs, now) {
|
|
1563
|
+
const found = /* @__PURE__ */ new Set();
|
|
1564
|
+
for (let i = track.agents.length - 1; i >= 0; i--) {
|
|
1565
|
+
const entry = track.agents[i];
|
|
1566
|
+
if (!entry) continue;
|
|
1567
|
+
if (now - entry.ts > windowMs) break;
|
|
1568
|
+
if (entry.quality !== "sufficient") continue;
|
|
1569
|
+
for (const req of names) if (entry.name.includes(req)) found.add(req);
|
|
1570
|
+
if (found.size === names.length) return true;
|
|
1571
|
+
}
|
|
1572
|
+
return names.every((n) => found.has(n));
|
|
1573
|
+
}
|
|
1574
|
+
//#endregion
|
|
1575
|
+
//#region src/freshness/evidence-harvest.ts
|
|
1576
|
+
/**
|
|
1577
|
+
* Retroactive evidence harvest at SubagentStop. Parses a completed sub-agent's
|
|
1578
|
+
* OWN transcript (`agent_transcript_path`) and backfills the session track with
|
|
1579
|
+
* the research/explore tool_use it performed (→ `agents`) and the `.md` reference
|
|
1580
|
+
* files it read (→ `refsReadAt`). Per LESSON.md + issues #43612/#27655/#34692,
|
|
1581
|
+
* sidechain PostToolUse hooks are NOT guaranteed to fire, so live in-session
|
|
1582
|
+
* crediting is only opportunistic; SubagentStop is dispatched by the main session
|
|
1583
|
+
* (reliable), and its transcript is the durable anchor this harvest reads.
|
|
1584
|
+
*
|
|
1585
|
+
* QUALITY: harvested agent evidence is credited `sufficient`. The freshness reader
|
|
1586
|
+
* (`agentsFreshInTrack`) ignores `insufficient` entries, so anything less would
|
|
1587
|
+
* make the harvest inert. This is sound: the platform-authored transcript proves
|
|
1588
|
+
* the tool genuinely ran to completion in a finished sub-agent — a STRONGER
|
|
1589
|
+
* guarantee than the live path's response-size heuristic (an anti-gaming measure
|
|
1590
|
+
* for the self-recorded track, which the transcript's provenance supersedes).
|
|
1591
|
+
*/
|
|
1592
|
+
/** Dedup tolerance (ms) against evidence a live sidechain hook may already hold. */
|
|
1593
|
+
const DEDUP_MS = 2e3;
|
|
1594
|
+
/** True when an `agents` entry with `name` already sits within ±{@link DEDUP_MS} of `ts`. */
|
|
1595
|
+
function agentAlreadyRecorded(track, name, ts) {
|
|
1596
|
+
return track.agents.some((a) => a.name === name && Math.abs(a.ts - ts) <= DEDUP_MS);
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Fold one transcript tool_use into the track (immutably): credit classified
|
|
1600
|
+
* research/explore evidence to `agents` (deduped, forced `sufficient`) and any
|
|
1601
|
+
* `.md` Read to `refsReadAt` (never restamping a MORE-recent existing read).
|
|
1602
|
+
* Returns the same reference when the call contributes nothing.
|
|
1603
|
+
*/
|
|
1604
|
+
function applyToolUse(track, u, fallbackNow) {
|
|
1605
|
+
const ts = u.ts ?? fallbackNow;
|
|
1606
|
+
let next = track;
|
|
1607
|
+
const ev = classifyAgentEvidence(u.name, u.input, void 0);
|
|
1608
|
+
if (ev && !agentAlreadyRecorded(next, ev.name, ts)) next = recordAgent(next, ev.name, ts, "sufficient");
|
|
1609
|
+
if (u.name === "Read") {
|
|
1610
|
+
const path = String(u.input?.file_path ?? u.input?.path ?? "");
|
|
1611
|
+
const prev = next.refsReadAt?.[path];
|
|
1612
|
+
if (path.endsWith(".md") && (prev === void 0 || prev < ts)) next = recordRefRead(next, path, ts);
|
|
1613
|
+
}
|
|
1614
|
+
return next;
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Backfill `track` from the sub-agent transcript at `transcriptPath`. PURE: the
|
|
1618
|
+
* caller owns load/save. Fail-open — an absent or unreadable transcript returns
|
|
1619
|
+
* `track` UNCHANGED (same reference), never throwing.
|
|
1620
|
+
* @param transcriptPath - Sub-agent's own `.jsonl` (SubagentStop `agent_transcript_path`).
|
|
1621
|
+
* @param track - The current session track.
|
|
1622
|
+
* @param now - Fallback epoch-ms for tool_uses the platform left unstamped.
|
|
1623
|
+
* @returns The backfilled track, or `track` itself when nothing was harvested.
|
|
1624
|
+
*/
|
|
1625
|
+
function harvestAgentEvidence(transcriptPath, track, now) {
|
|
1626
|
+
const uses = readAgentToolUses(transcriptPath);
|
|
1627
|
+
if (uses === null) return track;
|
|
1628
|
+
return uses.reduce((acc, u) => applyToolUse(acc, u, now), track);
|
|
1629
|
+
}
|
|
1630
|
+
//#endregion
|
|
1631
|
+
//#region src/freshness/evidence-harvest-io.ts
|
|
1632
|
+
/**
|
|
1633
|
+
* SubagentStop wiring for {@link harvestAgentEvidence}. The core SubagentStop
|
|
1634
|
+
* dispatch (`runtime/lifecycle/dispatch.ts`) is SYNCHRONOUS and runs in a
|
|
1635
|
+
* short-lived hook process, so this does SYNC track I/O (load → harvest → save)
|
|
1636
|
+
* rather than the async `loadTrack`/`saveTrack`: a floating async write could be
|
|
1637
|
+
* dropped before the process exits. Mirrors the sync read precedent in
|
|
1638
|
+
* `policy/design/skill-evidence.ts` and the exact save body of `tracking/store.ts`
|
|
1639
|
+
* (`signTrack` → atomic write → `writeLastNonce`) — kept sync on purpose here.
|
|
1640
|
+
*/
|
|
1641
|
+
/** Load the current track synchronously; `emptyTrack()` when absent/corrupt (fail-closed read). */
|
|
1642
|
+
function loadTrackSync(file) {
|
|
1643
|
+
try {
|
|
1644
|
+
return verifyTrack(JSON.parse(readFileSync(file, "utf8"))) ?? emptyTrack();
|
|
1645
|
+
} catch {
|
|
1646
|
+
return emptyTrack();
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Harvest the finishing sub-agent's transcript into its session track. Runs for
|
|
1651
|
+
* EVERY agent type (unlike the sniper reminder, which skips research/explore) —
|
|
1652
|
+
* those are exactly the agents whose research/explore evidence we want to credit.
|
|
1653
|
+
* Fully fail-open: a missing `agent_transcript_path`, an unreadable transcript, or
|
|
1654
|
+
* a write error leaves the track untouched and never throws out of the hook.
|
|
1655
|
+
* @param payload - The raw SubagentStop hook payload.
|
|
1656
|
+
* @param cwd - Project root (selects the per-project state dir).
|
|
1657
|
+
* @param now - Fallback epoch-ms for unstamped transcript tool_uses.
|
|
1658
|
+
* @param baseDir - Override the track base dir (tests); defaults to the project state dir.
|
|
1659
|
+
*/
|
|
1660
|
+
function harvestSubagentTrack(payload, cwd, now, baseDir = defaultStateDir(cwd)) {
|
|
1661
|
+
const transcriptPath = typeof payload.agent_transcript_path === "string" ? payload.agent_transcript_path : void 0;
|
|
1662
|
+
if (!transcriptPath) return;
|
|
1663
|
+
const file = trackFile(typeof payload.session_id === "string" ? payload.session_id : "unknown", baseDir);
|
|
1664
|
+
const track = loadTrackSync(file);
|
|
1665
|
+
const next = harvestAgentEvidence(transcriptPath, track, now);
|
|
1666
|
+
if (next === track) return;
|
|
1667
|
+
try {
|
|
1668
|
+
const envelope = signTrack(next);
|
|
1669
|
+
atomicWrite(file, JSON.stringify(envelope, null, 2));
|
|
1670
|
+
writeLastNonce(envelope.nonce);
|
|
1671
|
+
} catch {}
|
|
1672
|
+
}
|
|
1673
|
+
//#endregion
|
|
1121
1674
|
//#region src/runtime/lifecycle/task-completed.ts
|
|
1122
1675
|
/** Code-file extensions audited on task completion (mirrors validate-task-solid.py). */
|
|
1123
1676
|
const CODE_EXTENSIONS$2 = /* @__PURE__ */ new Set([
|
|
@@ -1140,6 +1693,30 @@ const CODE_EXTENSIONS$2 = /* @__PURE__ */ new Set([
|
|
|
1140
1693
|
".svelte",
|
|
1141
1694
|
".astro"
|
|
1142
1695
|
]);
|
|
1696
|
+
/** Freshness multiple on `FUSE_ENFORCE_TTL_SEC` for receipts (no new env var); a tsc+test run precedes the "done" by more than one edit window. */
|
|
1697
|
+
const RECEIPT_TTL_MULTIPLIER = 5;
|
|
1698
|
+
/** The modified files that are code (by extension) — the receipt gate's trigger set. */
|
|
1699
|
+
function codeFiles(files) {
|
|
1700
|
+
return files.filter((fp) => CODE_EXTENSIONS$2.has(extname(fp)));
|
|
1701
|
+
}
|
|
1702
|
+
/**
|
|
1703
|
+
* Refuse completion when code files changed but no fresh, passing verification
|
|
1704
|
+
* receipt (`tsc`/test, exit 0, zero failures, within TTL×{@link RECEIPT_TTL_MULTIPLIER})
|
|
1705
|
+
* exists in the signed track. TaskCompleted does NOT honor `decision:"block"`
|
|
1706
|
+
* (verified against the official hooks docs — `TeammateIdle/TaskCreated/
|
|
1707
|
+
* TaskCompleted` are excluded from that list); the documented stdout refusal is
|
|
1708
|
+
* `{"continue":false,"stopReason":…}`, which halts the teammate with the reason
|
|
1709
|
+
* shown to the user. Returns that JSON, or `null` when the session is clear.
|
|
1710
|
+
*/
|
|
1711
|
+
function receiptGate(sid, files, now, stateDir) {
|
|
1712
|
+
if (codeFiles(files).length === 0) return null;
|
|
1713
|
+
const windowMs = resolveTtlSec(process.env) * 1e3 * RECEIPT_TTL_MULTIPLIER;
|
|
1714
|
+
if (freshReceiptFromFile(trackFile(sid, stateDir), windowMs, now)) return null;
|
|
1715
|
+
return JSON.stringify({
|
|
1716
|
+
continue: false,
|
|
1717
|
+
stopReason: "VERIFICATION RECEIPT REQUIRED: code files changed but no fresh passing tsc/test receipt exists. Run `bun test` + `tsc --noEmit` (exit 0, 0 failures) and re-complete."
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1143
1720
|
/**
|
|
1144
1721
|
* Re-count physical lines of every modified code file and collect SOLID
|
|
1145
1722
|
* violations (`<basename>: <n> lines (max <max>)`) for those exceeding `max`.
|
|
@@ -1159,21 +1736,24 @@ function collectViolations(files, max) {
|
|
|
1159
1736
|
return violations;
|
|
1160
1737
|
}
|
|
1161
1738
|
/**
|
|
1162
|
-
* Handle TaskCompleted
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1739
|
+
* Handle TaskCompleted (ports `task-completed/validate-task-solid.py`, plus the
|
|
1740
|
+
* receipt gate). SOLID violations surface first as `SOLID VIOLATION`
|
|
1741
|
+
* additionalContext; once the files comply, {@link receiptGate} refuses a "done"
|
|
1742
|
+
* that has no fresh passing tsc/test receipt.
|
|
1743
|
+
* @param payload - The TaskCompleted payload (`task_id`, `task_subject`, `session_id`).
|
|
1166
1744
|
* @param home - Home dir (defaults to `~`).
|
|
1167
|
-
* @
|
|
1745
|
+
* @param now - Clock (defaults to `Date.now()`).
|
|
1746
|
+
* @param stateDir - Track base dir (defaults to the cwd-derived state dir; matches `handleHook`).
|
|
1747
|
+
* @returns The native hook stdout, or `""` when the session is clean.
|
|
1168
1748
|
*/
|
|
1169
|
-
function validateTaskSolid(payload, home = homedir()) {
|
|
1749
|
+
function validateTaskSolid(payload, home = homedir(), now = Date.now(), stateDir = defaultStateDir(process.cwd())) {
|
|
1170
1750
|
const sid = sanitizeSessionId(payload.session_id ?? "unknown");
|
|
1171
1751
|
if (!sid) return "";
|
|
1172
1752
|
const files = loadSessionState(sid, home).changes?.modifiedFiles ?? [];
|
|
1173
1753
|
if (files.length === 0) return "";
|
|
1174
1754
|
const max = resolveMaxLines();
|
|
1175
1755
|
const violations = collectViolations(files, max);
|
|
1176
|
-
if (violations.length === 0) return "";
|
|
1756
|
+
if (violations.length === 0) return receiptGate(sid, files, now, stateDir) ?? "";
|
|
1177
1757
|
const taskId = String(payload.task_id ?? "");
|
|
1178
1758
|
return contextResponse("TaskCompleted", `SOLID VIOLATION in task '${String(payload.task_subject ?? "")}' (${taskId}): ${violations.length} file(s) exceed ${max} lines: ` + violations.slice(0, 5).join("; "));
|
|
1179
1759
|
}
|
|
@@ -1555,6 +2135,109 @@ function cartoSessionStart(cwd, now = Date.now()) {
|
|
|
1555
2135
|
return ctx ? contextResponse("SessionStart", ctx) : "";
|
|
1556
2136
|
}
|
|
1557
2137
|
//#endregion
|
|
2138
|
+
//#region src/runtime/lifecycle/aipilot/curate-lessons.ts
|
|
2139
|
+
/**
|
|
2140
|
+
* Mechanical, LLM-free curation of `MEMORY/LESSON.md` bullets (anti-obesity):
|
|
2141
|
+
* strict-dedup near-identical bullets (keep newest, `[TRIGGERS …]` preserved),
|
|
2142
|
+
* flag over-cap + stale (>90d, cited path gone) in a report. Only dedup writes.
|
|
2143
|
+
*/
|
|
2144
|
+
const CAP = 50;
|
|
2145
|
+
const STALE_DAYS = 90;
|
|
2146
|
+
const SIM_THRESHOLD = .8;
|
|
2147
|
+
const MIN_TOKENS = 4;
|
|
2148
|
+
const TRIG = /^\[TRIGGERS\s+.+\]$/;
|
|
2149
|
+
/** Epoch ms for a `[YYYY-MM-DD HH:MM]` stamp; `NaN` if absent or out of range. */
|
|
2150
|
+
function parseTs$1(line) {
|
|
2151
|
+
const m = line.match(/\[(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/);
|
|
2152
|
+
if (!m) return NaN;
|
|
2153
|
+
const mo = +(m[2] ?? 0), d = +(m[3] ?? 0);
|
|
2154
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31) return NaN;
|
|
2155
|
+
return Date.UTC(+(m[1] ?? 0), mo - 1, d, +(m[4] ?? 0), +(m[5] ?? 0));
|
|
2156
|
+
}
|
|
2157
|
+
/** Content words (>=4 chars), timestamp & TRIGGERS marker stripped. */
|
|
2158
|
+
function tokenize(text) {
|
|
2159
|
+
return new Set(text.toLowerCase().replace(/\[triggers[^\]]*\]/g, " ").replace(/\[\d{4}-\d{2}-\d{2}[^\]]*\]/g, " ").replace(/[^a-z0-9àâäéèêëîïôöùûüç/._-]+/gi, " ").split(/\s+/).filter((t) => t.length >= 4));
|
|
2160
|
+
}
|
|
2161
|
+
/** Jaccard overlap of two token sets (0 when both empty). */
|
|
2162
|
+
function jaccard(a, b) {
|
|
2163
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
2164
|
+
const inter = [...a].filter((t) => b.has(t)).length;
|
|
2165
|
+
return inter / (a.size + b.size - inter);
|
|
2166
|
+
}
|
|
2167
|
+
/** Repo-relative cited paths (slash + extension) referenced in a block. */
|
|
2168
|
+
function citedPaths(text) {
|
|
2169
|
+
const out = /* @__PURE__ */ new Set();
|
|
2170
|
+
for (const m of text.matchAll(/`([^`]+)`/g)) if (m[1]) out.add(m[1]);
|
|
2171
|
+
for (const m of text.matchAll(/[\w./@-]+\.\w{1,5}/g)) if (m[0]) out.add(m[0]);
|
|
2172
|
+
return [...out].filter((p) => p.includes("/") && /\.\w{1,5}$/.test(p));
|
|
2173
|
+
}
|
|
2174
|
+
/** Split into a verbatim preamble and one Block per `- ` bullet. */
|
|
2175
|
+
function parse(content) {
|
|
2176
|
+
const lines = content.split("\n");
|
|
2177
|
+
const blocks = [];
|
|
2178
|
+
let i = 0;
|
|
2179
|
+
while (i < lines.length && !/^-\s/.test(lines[i] ?? "")) i++;
|
|
2180
|
+
const preamble = lines.slice(0, i).join("\n");
|
|
2181
|
+
for (; i < lines.length; i++) {
|
|
2182
|
+
const l = lines[i] ?? "", last = blocks[blocks.length - 1];
|
|
2183
|
+
if (/^-\s/.test(l)) blocks.push({
|
|
2184
|
+
raw: [l],
|
|
2185
|
+
ts: parseTs$1(l),
|
|
2186
|
+
tokens: tokenize(l)
|
|
2187
|
+
});
|
|
2188
|
+
else if (l.trim() && last) last.raw.push(l);
|
|
2189
|
+
}
|
|
2190
|
+
return {
|
|
2191
|
+
preamble,
|
|
2192
|
+
blocks
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
/** Report lines for bullets older than STALE_DAYS whose only cited path is gone. */
|
|
2196
|
+
function staleReport(blocks, now, root) {
|
|
2197
|
+
const cutoff = now - STALE_DAYS * 864e5;
|
|
2198
|
+
return blocks.flatMap((b) => {
|
|
2199
|
+
if (!(b.ts <= cutoff)) return [];
|
|
2200
|
+
const paths = citedPaths(b.raw.join(" "));
|
|
2201
|
+
if (paths.length === 0 || paths.some((p) => existsSync(join(root, p)))) return [];
|
|
2202
|
+
return [`[STALE?] ${(b.raw[0] ?? "").slice(0, 90)} — chemin(s) disparu(s): ${paths.join(", ")}`];
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Strict-dedup LESSON.md bullets (keep newest, its `[TRIGGERS …]` line preserved —
|
|
2207
|
+
* or carried over from the dropped twin if the kept one lacks it) and report
|
|
2208
|
+
* cap/stale. `content` unchanged unless a dedup occurred. Returns content + report.
|
|
2209
|
+
*/
|
|
2210
|
+
function curateLessons(content, now, root = process.cwd()) {
|
|
2211
|
+
const { preamble, blocks } = parse(content);
|
|
2212
|
+
const kept = [];
|
|
2213
|
+
const fused = [];
|
|
2214
|
+
for (const b of blocks) {
|
|
2215
|
+
const hit = b.tokens.size >= MIN_TOKENS ? kept.find((k) => k.tokens.size >= MIN_TOKENS && jaccard(k.tokens, b.tokens) >= SIM_THRESHOLD) : void 0;
|
|
2216
|
+
if (!hit) {
|
|
2217
|
+
kept.push(b);
|
|
2218
|
+
continue;
|
|
2219
|
+
}
|
|
2220
|
+
const [win, drop] = b.ts > hit.ts || Number.isNaN(hit.ts) ? [b, hit] : [hit, b];
|
|
2221
|
+
if (win !== hit) kept[kept.indexOf(hit)] = win;
|
|
2222
|
+
if (!win.raw.some((l) => TRIG.test(l.trim()))) {
|
|
2223
|
+
const t = drop.raw.find((l) => TRIG.test(l.trim()));
|
|
2224
|
+
if (t) win.raw.push(t);
|
|
2225
|
+
}
|
|
2226
|
+
fused.push(`fusion: gardé ${(win.raw[0] ?? "").slice(0, 60)} · retiré ${(drop.raw[0] ?? "").slice(0, 60)}`);
|
|
2227
|
+
}
|
|
2228
|
+
const old = [...kept].sort((a, b) => a.ts - b.ts).slice(0, Math.max(0, kept.length - CAP));
|
|
2229
|
+
const cap = old.length ? [`${kept.length} bullets (> ${CAP}) — plus anciens candidats à l'archivage:`, ...old.map((b) => ` ${(b.raw[0] ?? "").slice(0, 80)}`)] : [];
|
|
2230
|
+
const report = [
|
|
2231
|
+
...fused,
|
|
2232
|
+
...cap,
|
|
2233
|
+
...staleReport(blocks, now, root)
|
|
2234
|
+
].join("\n");
|
|
2235
|
+
return {
|
|
2236
|
+
content: fused.length ? `${preamble}\n${kept.map((b) => b.raw.join("\n")).join("\n\n")}\n` : content,
|
|
2237
|
+
report
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
//#endregion
|
|
1558
2241
|
//#region src/runtime/lifecycle/lessons/state.ts
|
|
1559
2242
|
/**
|
|
1560
2243
|
* Per-project lessons paths. The `fuse-lessons` plugin stores its lessons under
|
|
@@ -1579,9 +2262,10 @@ function lessonsStateFileFor(root) {
|
|
|
1579
2262
|
* across every project with unsaved code edits; PostToolUse marks the write to
|
|
1580
2263
|
* arm/silence the per-project throttle. Non-fatal by design.
|
|
1581
2264
|
*/
|
|
1582
|
-
/** Inject
|
|
1583
|
-
function injectMemory(cwd, event) {
|
|
1584
|
-
const
|
|
2265
|
+
/** Inject `MEMORY/LESSON.md` for `event`, after mechanical curation (a strict dedup rewrites the file in place; any report surfaces to the user via systemMessage). */
|
|
2266
|
+
function injectMemory(cwd, event, now) {
|
|
2267
|
+
const root = projectRoot(cwd);
|
|
2268
|
+
const file = lessonsFileFor(root);
|
|
1585
2269
|
if (!existsSync(file)) return "";
|
|
1586
2270
|
let content = "";
|
|
1587
2271
|
try {
|
|
@@ -1590,7 +2274,13 @@ function injectMemory(cwd, event) {
|
|
|
1590
2274
|
return "";
|
|
1591
2275
|
}
|
|
1592
2276
|
if (!content) return "";
|
|
1593
|
-
|
|
2277
|
+
const { content: curated, report } = curateLessons(content, now, root);
|
|
2278
|
+
if (curated !== content) try {
|
|
2279
|
+
atomicWrite(file, curated);
|
|
2280
|
+
content = curated;
|
|
2281
|
+
} catch {}
|
|
2282
|
+
const ctx = `Project lessons — never reproduce these:\n${content}\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.`;
|
|
2283
|
+
return report ? attachSystemMessage(contextResponse(event, ctx), `LESSON.md curation:\n${report}`) : contextResponse(event, ctx);
|
|
1594
2284
|
}
|
|
1595
2285
|
/** Select roots with unsaved code edits past the throttle, bumping their state. */
|
|
1596
2286
|
function collectPending(now, window) {
|
|
@@ -1638,7 +2328,7 @@ function markWrite(payload, now) {
|
|
|
1638
2328
|
function dispatchLessons(event, payload, cwd, now) {
|
|
1639
2329
|
switch (event) {
|
|
1640
2330
|
case "SessionStart":
|
|
1641
|
-
case "SubagentStart": return injectMemory(cwd, event);
|
|
2331
|
+
case "SubagentStart": return injectMemory(cwd, event, now);
|
|
1642
2332
|
case "Stop": return remindWrite(now);
|
|
1643
2333
|
case "PostToolUse":
|
|
1644
2334
|
markWrite(payload, now);
|
|
@@ -2924,7 +3614,10 @@ function dispatchLifecycle(input) {
|
|
|
2924
3614
|
if (input.scope === "lessons") return dispatchLessons("SubagentStart", input.payload, input.cwd, input.now);
|
|
2925
3615
|
return subagentCacheContext(input.payload.session_id);
|
|
2926
3616
|
case "Stop": return input.scope === "lessons" ? dispatchLessons("Stop", input.payload, input.cwd, input.now) : null;
|
|
2927
|
-
case "SubagentStop":
|
|
3617
|
+
case "SubagentStop":
|
|
3618
|
+
if (input.scope === "aipilot") return "";
|
|
3619
|
+
harvestSubagentTrack(input.payload, input.cwd, input.now);
|
|
3620
|
+
return trackAgentMemory(input.payload, void 0, input.now);
|
|
2928
3621
|
case "TeammateIdle": return validateTeammateOutput(input.payload);
|
|
2929
3622
|
case "PostToolUseFailure":
|
|
2930
3623
|
logToolFailure(input.payload, void 0, input.now);
|
|
@@ -4131,124 +4824,29 @@ function geminiMcpConsulted(authorizations, sessionId) {
|
|
|
4131
4824
|
return Object.values(authorizations).some((a) => a.doc_sessions?.includes(sessionId) && (a.sources ?? (a.source ? [a.source] : [])).some((s) => GEMINI_MCP_SOURCE_RE.test(s)));
|
|
4132
4825
|
}
|
|
4133
4826
|
/**
|
|
4134
|
-
* Gate a UI write when `FUSE_ENFORCE_GEMINI_MCP` is on: block hand-written
|
|
4135
|
-
* Tailwind (>= 3 classes) in a `.tsx/.jsx/.vue/.svelte` file unless a Gemini
|
|
4136
|
-
* Design MCP call was made this session. Returns `null` when disabled (default),
|
|
4137
|
-
* out of scope, or already satisfied.
|
|
4138
|
-
* @param tool - the tool name.
|
|
4139
|
-
* @param filePath - the file being written.
|
|
4140
|
-
* @param content - the written content (new_string on Edit).
|
|
4141
|
-
* @param ev - session evidence (authorizations + sessionId).
|
|
4142
|
-
*/
|
|
4143
|
-
function geminiMcpGate(tool, filePath, content, ev) {
|
|
4144
|
-
if (!geminiMcpEnforced()) return null;
|
|
4145
|
-
if (tool !== "Write" && tool !== "Edit") return null;
|
|
4146
|
-
if (!filePath || !UI_EXT_RE.test(filePath) || GEMINI_EXEMPT_RE.test(filePath)) return null;
|
|
4147
|
-
if (!content) return null;
|
|
4148
|
-
if (tool === "Edit" && (content.match(/\n/g)?.length ?? 0) < MIN_LINES_EDIT) return null;
|
|
4149
|
-
if (countTailwindClasses(content) < MIN_TW) return null;
|
|
4150
|
-
if (geminiMcpConsulted(ev.authorizations, ev.sessionId)) return null;
|
|
4151
|
-
return {
|
|
4152
|
-
kind: "block",
|
|
4153
|
-
title: "Gemini Design MCP",
|
|
4154
|
-
reason: "BLOCKED: UI code with Tailwind detected but Gemini Design MCP not used. Use mcp__gemini-design__create_frontend, modify_frontend, or snippet_frontend BEFORE writing UI code manually.",
|
|
4155
|
-
actions: ["Call mcp__gemini-design__create_frontend / modify_frontend / snippet_frontend, then retry"]
|
|
4156
|
-
};
|
|
4157
|
-
}
|
|
4158
|
-
//#endregion
|
|
4159
|
-
//#region src/freshness/explore-tools.ts
|
|
4160
|
-
/** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
|
|
4161
|
-
const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
|
|
4162
|
-
/**
|
|
4163
|
-
* Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`).
|
|
4164
|
-
* The fuse-browser fast-path entries are a deliberate TS addition: CLAUDE.md
|
|
4165
|
-
* mandates fuse-browser FIRST for web research, and `docSourceOf` (activity.ts)
|
|
4166
|
-
* already credits it for doc consultation — without them here, an agent
|
|
4167
|
-
* following the rules would never satisfy the freshness gate (same
|
|
4168
|
-
* cross-consumer inconsistency class as the solidReadGate lesson).
|
|
4169
|
-
*/
|
|
4170
|
-
const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
|
|
4171
|
-
"mcp__context7__query-docs",
|
|
4172
|
-
"mcp__context7__resolve-library-id",
|
|
4173
|
-
"mcp__exa__web_search_exa",
|
|
4174
|
-
"mcp__exa__get_code_context_exa",
|
|
4175
|
-
"mcp__exa__deep_researcher_start",
|
|
4176
|
-
"WebSearch",
|
|
4177
|
-
"WebFetch",
|
|
4178
|
-
"mcp__fuse-browser__browser_fetch",
|
|
4179
|
-
"mcp__fuse-browser__browser_fetch_batch",
|
|
4180
|
-
"mcp__fuse-browser__browser_crawl",
|
|
4181
|
-
"mcp__fuse-browser__browser_serp_batch"
|
|
4182
|
-
]);
|
|
4183
|
-
/** Bash executables that count as exploration (parity `EXPLORE_BASH_CMDS`). */
|
|
4184
|
-
const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
|
|
4185
|
-
"grep",
|
|
4186
|
-
"rg",
|
|
4187
|
-
"find",
|
|
4188
|
-
"ls",
|
|
4189
|
-
"fd",
|
|
4190
|
-
"ast-grep",
|
|
4191
|
-
"tree",
|
|
4192
|
-
"cat",
|
|
4193
|
-
"head",
|
|
4194
|
-
"tail"
|
|
4195
|
-
]);
|
|
4196
|
-
/** Legacy Python cache names still credited (parity `CACHE_READ_RE` + doc-helpers). */
|
|
4197
|
-
const CACHE_READ_RE = /\/context\/mcp\/(exa-search|exa-code-context|context7)-/;
|
|
4198
|
-
/**
|
|
4199
|
-
* Real TS cache stores: `<root>/.harness/cache/<fnv16>.md` (core MCP store,
|
|
4200
|
-
* `projectLayout().cacheDir`) and `~/.fuse-harness/cache/**` (ai-pilot doc
|
|
4201
|
-
* caches, `cache-base.cacheBaseDir`). Segment-matched — not resolved against
|
|
4202
|
-
* `homedir()` — so tilde-prefixed and absolute paths both hit; the `.md`
|
|
4203
|
-
* suffix keeps session-state JSON reads from counting as research.
|
|
4204
|
-
*/
|
|
4205
|
-
const TS_CACHE_READ_RE = /\.(?:fuse-)?harness[\\/]cache[\\/].*\.md$/;
|
|
4206
|
-
/**
|
|
4207
|
-
* First non-assignment shell token's basename, or "" — mirrors Python
|
|
4208
|
-
* `_bash_executable` (skips leading `VAR=value` env prefixes, e.g. `FOO=1 grep`).
|
|
4209
|
-
* @param cmd - Raw Bash `command` string.
|
|
4210
|
-
* @returns The executable basename, or "" when none.
|
|
4211
|
-
*/
|
|
4212
|
-
function bashExecutable(cmd) {
|
|
4213
|
-
for (const token of cmd.trim().split(/\s+/)) {
|
|
4214
|
-
if (!token) continue;
|
|
4215
|
-
const last = token.split("/").pop() ?? token;
|
|
4216
|
-
if (!last.includes("=")) return last;
|
|
4217
|
-
}
|
|
4218
|
-
return "";
|
|
4219
|
-
}
|
|
4220
|
-
/**
|
|
4221
|
-
* Classify a direct tool use into an APEX phase, or `null` when it is neither
|
|
4222
|
-
* exploration nor research. Mirrors Python `track-subagent-research._classify`.
|
|
4223
|
-
* @param tool - Harness tool name (e.g. "Glob", "Bash", "WebSearch").
|
|
4224
|
-
* @param input - Tool input payload.
|
|
4225
|
-
* @returns The credited phase + cache flag, or `null`.
|
|
4827
|
+
* Gate a UI write when `FUSE_ENFORCE_GEMINI_MCP` is on: block hand-written
|
|
4828
|
+
* Tailwind (>= 3 classes) in a `.tsx/.jsx/.vue/.svelte` file unless a Gemini
|
|
4829
|
+
* Design MCP call was made this session. Returns `null` when disabled (default),
|
|
4830
|
+
* out of scope, or already satisfied.
|
|
4831
|
+
* @param tool - the tool name.
|
|
4832
|
+
* @param filePath - the file being written.
|
|
4833
|
+
* @param content - the written content (new_string on Edit).
|
|
4834
|
+
* @param ev - session evidence (authorizations + sessionId).
|
|
4226
4835
|
*/
|
|
4227
|
-
function
|
|
4228
|
-
if (
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
if (
|
|
4233
|
-
|
|
4234
|
-
|
|
4836
|
+
function geminiMcpGate(tool, filePath, content, ev) {
|
|
4837
|
+
if (!geminiMcpEnforced()) return null;
|
|
4838
|
+
if (tool !== "Write" && tool !== "Edit") return null;
|
|
4839
|
+
if (!filePath || !UI_EXT_RE.test(filePath) || GEMINI_EXEMPT_RE.test(filePath)) return null;
|
|
4840
|
+
if (!content) return null;
|
|
4841
|
+
if (tool === "Edit" && (content.match(/\n/g)?.length ?? 0) < MIN_LINES_EDIT) return null;
|
|
4842
|
+
if (countTailwindClasses(content) < MIN_TW) return null;
|
|
4843
|
+
if (geminiMcpConsulted(ev.authorizations, ev.sessionId)) return null;
|
|
4844
|
+
return {
|
|
4845
|
+
kind: "block",
|
|
4846
|
+
title: "Gemini Design MCP",
|
|
4847
|
+
reason: "BLOCKED: UI code with Tailwind detected but Gemini Design MCP not used. Use mcp__gemini-design__create_frontend, modify_frontend, or snippet_frontend BEFORE writing UI code manually.",
|
|
4848
|
+
actions: ["Call mcp__gemini-design__create_frontend / modify_frontend / snippet_frontend, then retry"]
|
|
4235
4849
|
};
|
|
4236
|
-
if (tool === "Read") {
|
|
4237
|
-
const path = String(input?.file_path ?? input?.path ?? "");
|
|
4238
|
-
if (path && (CACHE_READ_RE.test(path) || TS_CACHE_READ_RE.test(path))) return {
|
|
4239
|
-
phase: "research-expert",
|
|
4240
|
-
cacheHit: true
|
|
4241
|
-
};
|
|
4242
|
-
return null;
|
|
4243
|
-
}
|
|
4244
|
-
if (tool === "Bash") {
|
|
4245
|
-
const cmd = String(input?.command ?? "").trim();
|
|
4246
|
-
if (EXPLORE_BASH_CMDS.has(bashExecutable(cmd))) return {
|
|
4247
|
-
phase: "explore-codebase",
|
|
4248
|
-
cacheHit: false
|
|
4249
|
-
};
|
|
4250
|
-
}
|
|
4251
|
-
return null;
|
|
4252
4850
|
}
|
|
4253
4851
|
//#endregion
|
|
4254
4852
|
//#region src/freshness/agent-evidence.ts
|
|
@@ -4326,96 +4924,6 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
|
|
|
4326
4924
|
return names.every((n) => found.has(n));
|
|
4327
4925
|
}
|
|
4328
4926
|
//#endregion
|
|
4329
|
-
//#region src/freshness/agent-evidence-record.ts
|
|
4330
|
-
/**
|
|
4331
|
-
* Session-track agent evidence — writer (parity track-subagent-research.py) +
|
|
4332
|
-
* reader scan (parity apex_agent_helpers._scan_agents). Sub-agent hooks fire
|
|
4333
|
-
* with the LEAD's `session_id`, so evidence recorded here lands in the ONE
|
|
4334
|
-
* session track the freshness gate scans FIRST — sidechain research and
|
|
4335
|
-
* Workflow-spawned agents count, unlike the lead-transcript scan.
|
|
4336
|
-
*/
|
|
4337
|
-
/** Tools the existing Task tracking already credits as agent LAUNCHES. */
|
|
4338
|
-
const AGENT_LAUNCH_TOOLS = /* @__PURE__ */ new Set(["Task", "Agent"]);
|
|
4339
|
-
/**
|
|
4340
|
-
* `JSON.stringify` length of the raw `tool_response` OBJECT (parity Python
|
|
4341
|
-
* `len(str(tool_response))`); 0 when absent or unserializable (circular).
|
|
4342
|
-
*/
|
|
4343
|
-
function responseJsonLength(toolResponse) {
|
|
4344
|
-
if (toolResponse === void 0 || toolResponse === null) return 0;
|
|
4345
|
-
try {
|
|
4346
|
-
return (JSON.stringify(toolResponse) ?? "").length;
|
|
4347
|
-
} catch {
|
|
4348
|
-
return 0;
|
|
4349
|
-
}
|
|
4350
|
-
}
|
|
4351
|
-
/**
|
|
4352
|
-
* Classify one PostToolUse call into session evidence, or null when it is
|
|
4353
|
-
* neither exploration nor research (parity track-subagent-research._classify,
|
|
4354
|
-
* reusing the shared {@link classifyExplore} tables — research/explore/Bash/
|
|
4355
|
-
* cache-read). ANTI-DOUBLE-COUNT: calls classified as agent launches
|
|
4356
|
-
* (`Task`/`Agent`) are skipped — the existing Task tracking credits those; the
|
|
4357
|
-
* criterion is the CALL's classification, never its provenance (lead vs sub).
|
|
4358
|
-
* @param tool - Harness tool name (e.g. "Glob", "Bash", "Read").
|
|
4359
|
-
* @param input - Raw tool input payload.
|
|
4360
|
-
* @param toolResponse - Raw `tool_response` OBJECT from the hook payload.
|
|
4361
|
-
* @returns The evidence to record, or null to skip.
|
|
4362
|
-
*/
|
|
4363
|
-
function classifyAgentEvidence(tool, input, toolResponse) {
|
|
4364
|
-
if (AGENT_LAUNCH_TOOLS.has(tool)) return null;
|
|
4365
|
-
const hit = classifyExplore(tool, input);
|
|
4366
|
-
if (!hit) return null;
|
|
4367
|
-
return {
|
|
4368
|
-
name: hit.phase === "explore-codebase" ? "subagent-explore-codebase" : "subagent-research-expert",
|
|
4369
|
-
quality: hit.cacheHit || responseJsonLength(toolResponse) > 50 ? "sufficient" : "insufficient"
|
|
4370
|
-
};
|
|
4371
|
-
}
|
|
4372
|
-
/**
|
|
4373
|
-
* Persist evidence into the SESSION track via {@link recordAgent} — keyed by
|
|
4374
|
-
* `session_id` alone (sub-agent hooks carry the lead's `session_id`); the TTL
|
|
4375
|
-
* anchors on `ts`, the tool call's own timestamp. `agentId` is tagged as
|
|
4376
|
-
* metadata when present, NEVER used as a condition (unreliable field —
|
|
4377
|
-
* anthropics/claude-code#22348).
|
|
4378
|
-
* @param file - Session track file path.
|
|
4379
|
-
* @param evidence - Classified evidence from {@link classifyAgentEvidence}.
|
|
4380
|
-
* @param ts - Epoch-ms timestamp of the tool call (the hook event's `now`).
|
|
4381
|
-
* @param agentId - Optional Claude `agent_id` — metadata tag only.
|
|
4382
|
-
*/
|
|
4383
|
-
async function recordAgentEvidence(file, evidence, ts, agentId) {
|
|
4384
|
-
const next = recordAgent(await loadTrack(file), evidence.name, ts, evidence.quality);
|
|
4385
|
-
const last = next.agents[next.agents.length - 1];
|
|
4386
|
-
if (agentId && last) {
|
|
4387
|
-
const tagged = {
|
|
4388
|
-
...last,
|
|
4389
|
-
agentId
|
|
4390
|
-
};
|
|
4391
|
-
next.agents[next.agents.length - 1] = tagged;
|
|
4392
|
-
}
|
|
4393
|
-
await saveTrack(file, next);
|
|
4394
|
-
}
|
|
4395
|
-
/**
|
|
4396
|
-
* Parity `_scan_agents` (apex_agent_helpers.py): reverse-scan `track.agents`,
|
|
4397
|
-
* STOPPING at the first entry older than `windowMs` (entries append in time
|
|
4398
|
-
* order), matching each required name by SUBSTRING (`research-expert` matches
|
|
4399
|
-
* `subagent-research-expert`) and counting ONLY `quality === "sufficient"`.
|
|
4400
|
-
* @param track - The loaded session track.
|
|
4401
|
-
* @param names - Required agent names — ALL must match to return true.
|
|
4402
|
-
* @param windowMs - Freshness window (ms), anchored on each entry's `ts`.
|
|
4403
|
-
* @param now - Current epoch ms.
|
|
4404
|
-
* @returns True when every required name has fresh, sufficient evidence.
|
|
4405
|
-
*/
|
|
4406
|
-
function agentsFreshInTrack(track, names, windowMs, now) {
|
|
4407
|
-
const found = /* @__PURE__ */ new Set();
|
|
4408
|
-
for (let i = track.agents.length - 1; i >= 0; i--) {
|
|
4409
|
-
const entry = track.agents[i];
|
|
4410
|
-
if (!entry) continue;
|
|
4411
|
-
if (now - entry.ts > windowMs) break;
|
|
4412
|
-
if (entry.quality !== "sufficient") continue;
|
|
4413
|
-
for (const req of names) if (entry.name.includes(req)) found.add(req);
|
|
4414
|
-
if (found.size === names.length) return true;
|
|
4415
|
-
}
|
|
4416
|
-
return names.every((n) => found.has(n));
|
|
4417
|
-
}
|
|
4418
|
-
//#endregion
|
|
4419
4927
|
//#region src/runtime/gate-apex.ts
|
|
4420
4928
|
/**
|
|
4421
4929
|
* The APEX-scoped portion of {@link gate}, in the Python enforce-apex-phases.ts
|
|
@@ -4476,24 +4984,177 @@ async function apexScopedGate(input, track, window) {
|
|
|
4476
4984
|
}
|
|
4477
4985
|
}
|
|
4478
4986
|
//#endregion
|
|
4987
|
+
//#region src/policy/deny-loop.ts
|
|
4988
|
+
/**
|
|
4989
|
+
* @module deny-loop
|
|
4990
|
+
* Pure anti-loop logic: hash a tool-call, decide if it repeats a prior deny, and
|
|
4991
|
+
* enrich the repeated block's message.
|
|
4992
|
+
*
|
|
4993
|
+
* The proprietary rule "NEVER propose the same fix twice" is prose a model under
|
|
4994
|
+
* pressure ignores. This makes it machine-enforced: when a call whose
|
|
4995
|
+
* `(tool + normalized input)` hash was ALREADY denied in-window is retried, the
|
|
4996
|
+
* harness keeps the deny but rewrites the message — `[REPEAT]` title, STOP
|
|
4997
|
+
* prefix, forced `research-expert` action. State + wiring live in the sidecar
|
|
4998
|
+
* store ({@link module:deny-loop-store}); this file is IO-free and pure.
|
|
4999
|
+
* @packageDocumentation
|
|
5000
|
+
*/
|
|
5001
|
+
/** Stable JSON: keys sorted at every depth so `{a,b}` and `{b,a}` hash identically. */
|
|
5002
|
+
function stableStringify(v) {
|
|
5003
|
+
if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
|
|
5004
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
|
|
5005
|
+
const o = v;
|
|
5006
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(",")}}`;
|
|
5007
|
+
}
|
|
5008
|
+
/**
|
|
5009
|
+
* Stable identity hash of a tool-call = tool name + normalized (key-sorted) input,
|
|
5010
|
+
* so re-ordered keys never mask a repeat.
|
|
5011
|
+
* @param tool - Tool name (e.g. "Write", "Bash").
|
|
5012
|
+
* @param input - Identifying tool input (filePath/content/command...).
|
|
5013
|
+
* @returns 8-char hex hash.
|
|
5014
|
+
*/
|
|
5015
|
+
function denyHash(tool, input) {
|
|
5016
|
+
return hashText(`${tool}\n${stableStringify(input)}`);
|
|
5017
|
+
}
|
|
5018
|
+
/**
|
|
5019
|
+
* Pure loop check: given the already-pruned in-window map, compute the running
|
|
5020
|
+
* count for `hash` and whether it repeats (count > 1). No IO — the caller persists.
|
|
5021
|
+
* @param hash - {@link denyHash} of the current call.
|
|
5022
|
+
* @param priorDenies - The `{ hash -> DenyEntry }` map, pruned to `now`/`windowMs`.
|
|
5023
|
+
* @param opts - Clock + window.
|
|
5024
|
+
* @returns `{ isRepeat, count, hash }`.
|
|
5025
|
+
*/
|
|
5026
|
+
function denyLoopCheck(hash, priorDenies, opts) {
|
|
5027
|
+
const prev = priorDenies[hash];
|
|
5028
|
+
const count = (prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs ? prev.count : 0) + 1;
|
|
5029
|
+
return {
|
|
5030
|
+
isRepeat: count > 1,
|
|
5031
|
+
count,
|
|
5032
|
+
hash
|
|
5033
|
+
};
|
|
5034
|
+
}
|
|
5035
|
+
/**
|
|
5036
|
+
* Enrich a REPEATED block prompt — a NEW object, never a mutation (the input may
|
|
5037
|
+
* be a shared const like FAIL_CLOSED). The decision stays `block`; only the
|
|
5038
|
+
* message changes, so every harness renders it through the same adapter.
|
|
5039
|
+
* @param prompt - The original block prompt.
|
|
5040
|
+
* @param count - The running identical-deny count (n).
|
|
5041
|
+
* @returns A block prompt with `[REPEAT]` title, STOP-prefixed reason, forced research action.
|
|
5042
|
+
*/
|
|
5043
|
+
function enrichRepeatDeny(prompt, count) {
|
|
5044
|
+
const stop = `Tentative identique n°${count} déjà refusée pour la même raison. STOP: ne retente pas ce même appel. `;
|
|
5045
|
+
const action = "Launch fuse-ai-pilot:research-expert to find a DIFFERENT approach";
|
|
5046
|
+
return {
|
|
5047
|
+
...prompt,
|
|
5048
|
+
title: prompt.title.startsWith("[REPEAT]") ? prompt.title : `[REPEAT] ${prompt.title}`,
|
|
5049
|
+
reason: stop + prompt.reason,
|
|
5050
|
+
actions: [action, ...prompt.actions ?? []]
|
|
5051
|
+
};
|
|
5052
|
+
}
|
|
5053
|
+
//#endregion
|
|
5054
|
+
//#region src/runtime/deny-loop-store.ts
|
|
5055
|
+
/**
|
|
5056
|
+
* @module deny-loop-store
|
|
5057
|
+
* Sidecar state + gate wiring for the mechanical anti-loop ({@link module:deny-loop}).
|
|
5058
|
+
*
|
|
5059
|
+
* STATE — a standalone sidecar (`deny-loop.json`) in the same per-project state
|
|
5060
|
+
* dir as the session track, NOT a field in `session-state.ts`: that file is owned
|
|
5061
|
+
* by another concern this batch, so a shared field would couple two owners and
|
|
5062
|
+
* contend on one file for two unrelated maps. This mirrors the proven
|
|
5063
|
+
* {@link module:inject-dedup} `oncePerWindow` sidecar (atomicWrite, prune-by-window,
|
|
5064
|
+
* fail-open) and keeps the anti-loop self-contained.
|
|
5065
|
+
*
|
|
5066
|
+
* WINDOW — the hash expires with the same freshness window the gate already uses
|
|
5067
|
+
* (`FUSE_ENFORCE_TTL_SEC` / `windowMs`): a loop is a burst, not a lifetime, so a
|
|
5068
|
+
* stale deny past the window resets and a later identical call is a fresh #1.
|
|
5069
|
+
* @packageDocumentation
|
|
5070
|
+
*/
|
|
5071
|
+
/** Sidecar basename under the per-project state dir. */
|
|
5072
|
+
const SIDECAR = "deny-loop.json";
|
|
5073
|
+
/** Load the `{ hash -> DenyEntry }` map, or `{}` when missing/corrupt. */
|
|
5074
|
+
function loadMap(path) {
|
|
5075
|
+
try {
|
|
5076
|
+
if (!existsSync(path)) return {};
|
|
5077
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
5078
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
5079
|
+
} catch {
|
|
5080
|
+
return {};
|
|
5081
|
+
}
|
|
5082
|
+
}
|
|
5083
|
+
/** Drop entries whose last deny is older than the window (bounds size + resets stale loops). */
|
|
5084
|
+
function prune(map, now, windowMs) {
|
|
5085
|
+
const out = {};
|
|
5086
|
+
for (const [k, e] of Object.entries(map)) if (e && typeof e.lastTs === "number" && now - e.lastTs < windowMs) out[k] = e;
|
|
5087
|
+
return out;
|
|
5088
|
+
}
|
|
5089
|
+
/**
|
|
5090
|
+
* Record a deny for `(tool, input)`: prune, {@link denyLoopCheck}, persist the
|
|
5091
|
+
* bumped entry. Fails open (`isRepeat:false`) when the sidecar is unwritable — a
|
|
5092
|
+
* broken state dir must never manufacture a false `[REPEAT]`.
|
|
5093
|
+
* @param tool - Tool name.
|
|
5094
|
+
* @param input - Identifying tool input.
|
|
5095
|
+
* @param opts - Clock + state dir + window.
|
|
5096
|
+
* @returns `{ isRepeat, count, hash }`.
|
|
5097
|
+
*/
|
|
5098
|
+
function recordDeny(tool, input, opts) {
|
|
5099
|
+
const hash = denyHash(tool, input);
|
|
5100
|
+
const path = join(opts.dir, SIDECAR);
|
|
5101
|
+
const map = prune(loadMap(path), opts.now, opts.windowMs);
|
|
5102
|
+
const res = denyLoopCheck(hash, map, opts);
|
|
5103
|
+
map[hash] = {
|
|
5104
|
+
count: res.count,
|
|
5105
|
+
lastTs: opts.now
|
|
5106
|
+
};
|
|
5107
|
+
try {
|
|
5108
|
+
atomicWrite(path, JSON.stringify(map));
|
|
5109
|
+
} catch {}
|
|
5110
|
+
return res;
|
|
5111
|
+
}
|
|
5112
|
+
/**
|
|
5113
|
+
* Gate tail: record every block deny; on a repeat, return the enriched prompt.
|
|
5114
|
+
* Allows (`null`) and non-block prompts (`ask`/`inform`) pass through untouched —
|
|
5115
|
+
* a loop is a retried REFUSAL, and only `block` is a refusal. The decision is
|
|
5116
|
+
* NEVER changed; only a repeated block's message is rewritten.
|
|
5117
|
+
* @param prompt - The gate's outcome.
|
|
5118
|
+
* @param tool - Tool name.
|
|
5119
|
+
* @param input - Identifying tool input.
|
|
5120
|
+
* @param opts - Clock + state dir + window.
|
|
5121
|
+
* @returns The prompt, enriched only when it is a repeated block.
|
|
5122
|
+
*/
|
|
5123
|
+
function withDenyLoop(prompt, tool, input, opts) {
|
|
5124
|
+
if (!prompt || prompt.kind !== "block") return prompt;
|
|
5125
|
+
const { isRepeat, count } = recordDeny(tool, input, opts);
|
|
5126
|
+
return isRepeat ? enrichRepeatDeny(prompt, count) : prompt;
|
|
5127
|
+
}
|
|
5128
|
+
//#endregion
|
|
4479
5129
|
//#region src/runtime/gate.ts
|
|
4480
5130
|
/** Prior agents the freshness gate requires before a code edit. */
|
|
4481
5131
|
const REQUIRED_AGENTS = ["explore-codebase", "research-expert"];
|
|
4482
5132
|
/**
|
|
4483
|
-
* Default freshness window
|
|
4484
|
-
*
|
|
4485
|
-
*
|
|
4486
|
-
* always supplies `windowMs` from `resolveTtlSec()` (`src/config/ttl.ts`).
|
|
5133
|
+
* Default freshness window (ms). Matches the plugin's `FUSE_ENFORCE_TTL_SEC`
|
|
5134
|
+
* default (120s); only a fallback for callers that omit `windowMs` (e.g. tests) —
|
|
5135
|
+
* the real CLI path always supplies it from `resolveTtlSec()` (`src/config/ttl.ts`).
|
|
4487
5136
|
*/
|
|
4488
5137
|
const DEFAULT_WINDOW_MS = 12e4;
|
|
4489
5138
|
/** Trivial edits allowed within the window before the full APEX gates apply. */
|
|
4490
5139
|
const TRIVIAL_BUDGET = 4;
|
|
4491
5140
|
/**
|
|
4492
|
-
* Full gate: the
|
|
4493
|
-
*
|
|
4494
|
-
*
|
|
5141
|
+
* Full gate: {@link runGates} yields the first blocking prompt (or null); the
|
|
5142
|
+
* anti-loop tail ({@link withDenyLoop}) rewrites an identical retried deny's
|
|
5143
|
+
* message (decision unchanged). Sidecar dir = the track's dir (tests off `$HOME`).
|
|
4495
5144
|
*/
|
|
4496
5145
|
async function gate(input) {
|
|
5146
|
+
return withDenyLoop(await runGates(input), input.tool, {
|
|
5147
|
+
filePath: input.filePath,
|
|
5148
|
+
content: input.content,
|
|
5149
|
+
command: input.command
|
|
5150
|
+
}, {
|
|
5151
|
+
now: input.now,
|
|
5152
|
+
dir: dirname(input.trackFile),
|
|
5153
|
+
windowMs: input.windowMs ?? 12e4
|
|
5154
|
+
});
|
|
5155
|
+
}
|
|
5156
|
+
/** Stateless guards, then the trivial fast path, then the stateful APEX gates. */
|
|
5157
|
+
async function runGates(input) {
|
|
4497
5158
|
const precommit = preCommitGate(input.tool, input.command, input.cwd);
|
|
4498
5159
|
if (precommit) return precommit;
|
|
4499
5160
|
const protectedDeny = protectedPathGate(input.tool, input.filePath);
|
|
@@ -5159,6 +5820,206 @@ function designGate(payload, event, cacheDir, cwd) {
|
|
|
5159
5820
|
return null;
|
|
5160
5821
|
}
|
|
5161
5822
|
//#endregion
|
|
5823
|
+
//#region src/policy/lessons/trigger-index.ts
|
|
5824
|
+
/**
|
|
5825
|
+
* Compile the triggered-lesson index from `MEMORY/LESSON.md`. A lesson is a
|
|
5826
|
+
* bullet (`- [YYYY-MM-DD HH:MM] ...`); it opts into decision-time injection by
|
|
5827
|
+
* ending with a `[TRIGGERS tool:.. path:.. error:.. keyword:..]` line. Lessons
|
|
5828
|
+
* WITHOUT that tag are skipped here (they keep the SessionStart block behavior —
|
|
5829
|
+
* zero regression). Parsed once per file version (mtime-memoized).
|
|
5830
|
+
*/
|
|
5831
|
+
/** Matches a trailing `[TRIGGERS ...]` line (its body captured). */
|
|
5832
|
+
const TRIGGER_RE = /^\[TRIGGERS\s+(.+?)\]$/;
|
|
5833
|
+
/** Comma list for `key:` in a trigger body (values are space-delimited). */
|
|
5834
|
+
function list(body, key) {
|
|
5835
|
+
const val = body.match(new RegExp(`\\b${key}:([^\\s\\]]+)`))?.[1];
|
|
5836
|
+
return val ? val.split(",").filter(Boolean) : [];
|
|
5837
|
+
}
|
|
5838
|
+
/** Parse a `[TRIGGERS ...]` body into predicates (error is a single regex). */
|
|
5839
|
+
function parseTriggers(body) {
|
|
5840
|
+
const err = body.match(/\berror:([^\s\]]+)/);
|
|
5841
|
+
return {
|
|
5842
|
+
tools: list(body, "tool"),
|
|
5843
|
+
paths: list(body, "path"),
|
|
5844
|
+
error: err?.[1],
|
|
5845
|
+
keywords: list(body, "keyword")
|
|
5846
|
+
};
|
|
5847
|
+
}
|
|
5848
|
+
/** Collapse to a single ≤3-line compact string (cap length). */
|
|
5849
|
+
function compact(text) {
|
|
5850
|
+
const one = text.replace(/\s+/g, " ").trim();
|
|
5851
|
+
return one.length > 280 ? `${one.slice(0, 277)}…` : one;
|
|
5852
|
+
}
|
|
5853
|
+
/**
|
|
5854
|
+
* Parse LESSON.md content into triggered entries. A bullet's text spans its
|
|
5855
|
+
* `- ` line plus any following non-blank continuation lines up to the next
|
|
5856
|
+
* bullet; a `[TRIGGERS ...]` continuation line arms it.
|
|
5857
|
+
* @param content - Raw LESSON.md text.
|
|
5858
|
+
* @returns Entries that declared triggers (others skipped).
|
|
5859
|
+
*/
|
|
5860
|
+
function parseLessons(content) {
|
|
5861
|
+
const lines = content.split("\n");
|
|
5862
|
+
const out = [];
|
|
5863
|
+
for (let i = 0; i < lines.length; i++) {
|
|
5864
|
+
const line = lines[i];
|
|
5865
|
+
if (line === void 0 || !line.startsWith("- ")) continue;
|
|
5866
|
+
let text = line.slice(2);
|
|
5867
|
+
let triggers = null;
|
|
5868
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
5869
|
+
const cont = lines[j];
|
|
5870
|
+
if (cont === void 0 || cont.trim() === "" || cont.startsWith("- ")) break;
|
|
5871
|
+
const body = cont.trim().match(TRIGGER_RE)?.[1];
|
|
5872
|
+
if (body !== void 0) triggers = parseTriggers(body);
|
|
5873
|
+
else text += ` ${cont.trim()}`;
|
|
5874
|
+
}
|
|
5875
|
+
if (triggers) out.push({
|
|
5876
|
+
text: compact(text),
|
|
5877
|
+
triggers
|
|
5878
|
+
});
|
|
5879
|
+
}
|
|
5880
|
+
return out;
|
|
5881
|
+
}
|
|
5882
|
+
let memo = null;
|
|
5883
|
+
/**
|
|
5884
|
+
* Compile (once per file version) the triggered-lesson index from `file`.
|
|
5885
|
+
* Memoized by path+mtime: re-parses only when LESSON.md changes.
|
|
5886
|
+
* @param file - Absolute path to MEMORY/LESSON.md.
|
|
5887
|
+
* @returns The compiled entries (missing/unreadable file → empty).
|
|
5888
|
+
*/
|
|
5889
|
+
function lessonIndex(file) {
|
|
5890
|
+
let key;
|
|
5891
|
+
try {
|
|
5892
|
+
key = `${file}:${statSync(file).mtimeMs}`;
|
|
5893
|
+
} catch {
|
|
5894
|
+
return [];
|
|
5895
|
+
}
|
|
5896
|
+
if (memo?.key === key) return memo.entries;
|
|
5897
|
+
let entries = [];
|
|
5898
|
+
try {
|
|
5899
|
+
entries = parseLessons(readFileSync(file, "utf-8"));
|
|
5900
|
+
} catch {
|
|
5901
|
+
entries = [];
|
|
5902
|
+
}
|
|
5903
|
+
memo = {
|
|
5904
|
+
key,
|
|
5905
|
+
entries
|
|
5906
|
+
};
|
|
5907
|
+
return entries;
|
|
5908
|
+
}
|
|
5909
|
+
/** Glob (`*`/`**`) → RegExp, matching a path segment/tail. */
|
|
5910
|
+
function globToRe(glob) {
|
|
5911
|
+
const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(//g, ".*");
|
|
5912
|
+
return new RegExp(`(^|/)${esc}$`);
|
|
5913
|
+
}
|
|
5914
|
+
/** Safe case-insensitive regex test (absent source or invalid → false). */
|
|
5915
|
+
function safeTest(src, s) {
|
|
5916
|
+
if (!src) return false;
|
|
5917
|
+
try {
|
|
5918
|
+
return new RegExp(src, "i").test(s);
|
|
5919
|
+
} catch {
|
|
5920
|
+
return false;
|
|
5921
|
+
}
|
|
5922
|
+
}
|
|
5923
|
+
/** Score one entry against the call; null = no predicate matched. */
|
|
5924
|
+
function scoreEntry(e, tool, filePath, inputJson, prevError) {
|
|
5925
|
+
const tr = e.triggers;
|
|
5926
|
+
if (tr.tools.includes(tool)) return {
|
|
5927
|
+
entry: e,
|
|
5928
|
+
rank: 3
|
|
5929
|
+
};
|
|
5930
|
+
if (filePath && tr.paths.some((g) => globToRe(g).test(filePath))) return {
|
|
5931
|
+
entry: e,
|
|
5932
|
+
rank: 2
|
|
5933
|
+
};
|
|
5934
|
+
if (prevError && safeTest(tr.error, prevError)) return {
|
|
5935
|
+
entry: e,
|
|
5936
|
+
rank: 1
|
|
5937
|
+
};
|
|
5938
|
+
if (tr.keywords.some((k) => inputJson.includes(k))) return {
|
|
5939
|
+
entry: e,
|
|
5940
|
+
rank: 0
|
|
5941
|
+
};
|
|
5942
|
+
return null;
|
|
5943
|
+
}
|
|
5944
|
+
/** Stable, filesystem-safe cooldown key from a lesson's compact text (djb2). */
|
|
5945
|
+
function cooldownKey(text) {
|
|
5946
|
+
let h = 5381;
|
|
5947
|
+
for (let i = 0; i < text.length; i++) h = (h << 5) + h + text.charCodeAt(i) | 0;
|
|
5948
|
+
return `lesson:${(h >>> 0).toString(36)}`;
|
|
5949
|
+
}
|
|
5950
|
+
/**
|
|
5951
|
+
* The single most-specific lesson for this PreToolUse call, or null. Matching
|
|
5952
|
+
* priority: exact tool > path glob > error regex > input-JSON keyword. Cooldown
|
|
5953
|
+
* suppresses a lesson already injected within the window.
|
|
5954
|
+
* @param tool - The tool being called (e.g. `Write`).
|
|
5955
|
+
* @param toolInput - The raw `tool_input`.
|
|
5956
|
+
* @param opts - Index file, cooldown gate, and optional prior error.
|
|
5957
|
+
* @returns An `inform` prompt, or null when nothing matches / in cooldown.
|
|
5958
|
+
*/
|
|
5959
|
+
function lessonFor(tool, toolInput, opts) {
|
|
5960
|
+
const entries = lessonIndex(opts.file);
|
|
5961
|
+
if (entries.length === 0) return null;
|
|
5962
|
+
const filePath = typeof toolInput?.file_path === "string" ? toolInput.file_path : "";
|
|
5963
|
+
const inputJson = JSON.stringify(toolInput ?? {});
|
|
5964
|
+
let best = null;
|
|
5965
|
+
for (const e of entries) {
|
|
5966
|
+
const m = scoreEntry(e, tool, filePath, inputJson, opts.prevError);
|
|
5967
|
+
if (m && (!best || m.rank > best.rank)) best = m;
|
|
5968
|
+
}
|
|
5969
|
+
if (!best) return null;
|
|
5970
|
+
if (!opts.once(cooldownKey(best.entry.text), opts.cooldownMs ?? 18e5)) return null;
|
|
5971
|
+
return {
|
|
5972
|
+
kind: "inform",
|
|
5973
|
+
title: `Project lesson${filePath ? ` (${basename(filePath)})` : ""}`,
|
|
5974
|
+
reason: best.entry.text
|
|
5975
|
+
};
|
|
5976
|
+
}
|
|
5977
|
+
//#endregion
|
|
5978
|
+
//#region src/runtime/pre-allow.ts
|
|
5979
|
+
/**
|
|
5980
|
+
* PreToolUse ALLOW-path response assembly. Reached only after every gate
|
|
5981
|
+
* allowed (a deny/ask already returned upstream), so nothing here can block nor
|
|
5982
|
+
* override a decision. Combines the Python-parity pass notice (systemMessage)
|
|
5983
|
+
* with the single most-specific decision-time lesson (additionalContext).
|
|
5984
|
+
*/
|
|
5985
|
+
/**
|
|
5986
|
+
* Build the native outcome for a PreToolUse call that passed every gate: emit a
|
|
5987
|
+
* user-visible pass notice (once per allowed call) and, when its TRIGGERS match
|
|
5988
|
+
* this call, the one cooldown-guarded decision-time lesson. Both channels ride a
|
|
5989
|
+
* single response (lesson → additionalContext, notice → systemMessage).
|
|
5990
|
+
* @param id - Harness id for {@link respond}.
|
|
5991
|
+
* @param event - The normalized PreToolUse event.
|
|
5992
|
+
* @param payload - The raw hook payload (for `agent_id`).
|
|
5993
|
+
* @param mcpDir - MCP state dir backing the pass-notice throttle.
|
|
5994
|
+
* @param cwd - Project root (lesson file + notice scope).
|
|
5995
|
+
* @returns The native hook outcome (empty stdout when nothing to emit).
|
|
5996
|
+
*/
|
|
5997
|
+
function allowOutcome(id, event, payload, mcpDir, cwd) {
|
|
5998
|
+
const notice = designPassNotice({
|
|
5999
|
+
agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
|
|
6000
|
+
tool: event.tool,
|
|
6001
|
+
filePath: event.filePath ?? "",
|
|
6002
|
+
content: event.content ?? "",
|
|
6003
|
+
url: typeof event.input.url === "string" ? event.input.url : "",
|
|
6004
|
+
phase: "pre"
|
|
6005
|
+
}, mcpDir);
|
|
6006
|
+
const lesson = lessonFor(event.tool, event.input, {
|
|
6007
|
+
file: lessonsFileFor(projectRoot(cwd)),
|
|
6008
|
+
once: oncePerWindow
|
|
6009
|
+
});
|
|
6010
|
+
if (lesson) return {
|
|
6011
|
+
stdout: respond(id, notice?.userMessage ? {
|
|
6012
|
+
...lesson,
|
|
6013
|
+
userMessage: notice.userMessage
|
|
6014
|
+
} : lesson),
|
|
6015
|
+
exit: 0
|
|
6016
|
+
};
|
|
6017
|
+
return {
|
|
6018
|
+
stdout: notice ? respond(id, notice) : "",
|
|
6019
|
+
exit: 0
|
|
6020
|
+
};
|
|
6021
|
+
}
|
|
6022
|
+
//#endregion
|
|
5162
6023
|
//#region src/runtime/handle-pre.ts
|
|
5163
6024
|
/**
|
|
5164
6025
|
* Run the PreToolUse pipeline: MCP/WebFetch cache intercept, design gate, APEX
|
|
@@ -5223,18 +6084,7 @@ async function handlePre(ctx) {
|
|
|
5223
6084
|
stdout: respond(id, prompt),
|
|
5224
6085
|
exit: 0
|
|
5225
6086
|
};
|
|
5226
|
-
|
|
5227
|
-
agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
|
|
5228
|
-
tool: event.tool,
|
|
5229
|
-
filePath: event.filePath ?? "",
|
|
5230
|
-
content: event.content ?? "",
|
|
5231
|
-
url: typeof event.input.url === "string" ? event.input.url : "",
|
|
5232
|
-
phase: "pre"
|
|
5233
|
-
}, mcpDir);
|
|
5234
|
-
return {
|
|
5235
|
-
stdout: notice ? respond(id, notice) : "",
|
|
5236
|
-
exit: 0
|
|
5237
|
-
};
|
|
6087
|
+
return allowOutcome(id, event, payload, mcpDir, opts.cwd);
|
|
5238
6088
|
}
|
|
5239
6089
|
//#endregion
|
|
5240
6090
|
//#region src/freshness/query-framework.ts
|
|
@@ -5423,6 +6273,12 @@ async function handlePost(ctx) {
|
|
|
5423
6273
|
for (const activity of activities) await recordActivity(file, activity);
|
|
5424
6274
|
const evidence = classifyAgentEvidence(event.tool, event.input, response);
|
|
5425
6275
|
if (evidence) await recordAgentEvidence(file, evidence, opts.now, typeof payload.agent_id === "string" ? payload.agent_id : void 0);
|
|
6276
|
+
if (event.tool === "Bash" && event.command) {
|
|
6277
|
+
const r = payload.tool_result ?? response;
|
|
6278
|
+
const out = `${typeof r?.stdout === "string" ? r.stdout : ""}\n${typeof r?.stderr === "string" ? r.stderr : ""}`;
|
|
6279
|
+
const exit = Number(r?.exit_code ?? 0);
|
|
6280
|
+
await captureReceipt(file, event.command, out, Number.isFinite(exit) ? exit : 0, opts.now);
|
|
6281
|
+
}
|
|
5426
6282
|
postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now, payload, opts.cwd);
|
|
5427
6283
|
const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
|
|
5428
6284
|
if (seoDeny) return {
|
|
@@ -5564,4 +6420,4 @@ async function handleHook(id, payload, opts) {
|
|
|
5564
6420
|
});
|
|
5565
6421
|
}
|
|
5566
6422
|
//#endregion
|
|
5567
|
-
export { saveApexState as $, trackSkillRead as A,
|
|
6423
|
+
export { saveApexState as $, trackSkillRead as A, saveSecurityState as At, writePluginMap as B, seoPostToolUse as C, taskContext as Ct, postTrackingSideEffects as D, normalizeEvent as Dt, securityAdvisory as E, trackFile as Et, dispatchLessons as F, mergeLines as G, isProject as H, lessonsFileFor as I, listChildren as J, countFiles as K, lessonsStateFileFor as L, dispatchLifecycle as M, securityStatePath as Mt, aipilotPostToolUse as N, todayUtc as Nt, trackWatchResearch as O, isoUtc as Ot, dispatchAipilot as P, cleanupSession as Q, cartoSessionStart as R, postEditContext as S, promptSubmitContext as St, dispatchMemory as T, projectHash$1 as Tt, writeTree as U, generateProjectMap as V, loadEnriched as W, trackSessionChanges as X, postEditTypescript as Y, validateRulesLoaded as Z, preCommitGate as _, trimLogFile as _t, recordActivity as a, validateSolidGate as at, extractSymbols as b, projectContext as bt, MCP_TTL_MS as c, detectSolidProfile as ct, isMcpTool as d, readRules as dt, logToolFailure as et, queryOf as f, runSessionStartCleanups as ft, gate as g, removeOldFiles as gt, TRIVIAL_BUDGET as h, purgeTtlTree as ht, respond as i, validateTailwind as it, trackEnrichment as j, securityStateDir as jt, trackMcpResearch as k, loadSecurityState as kt, WEBFETCH_TTL_MS as l, solidDetectStart as lt, REQUIRED_AGENTS as m, pruneEmptyDirs as mt, activityFor as n, trackAgentMemory as nt, mcpPostStore as o, checkFileSize as ot, DEFAULT_WINDOW_MS as p, sessionStartCore as pt, getFileDesc as q, handlePre as r, subagentCacheContext as rt, mcpPreIntercept as s, countLoc as st, handleHook as t, validateTeammateOutput as tt, cacheQueryOf as u, injectRules as ut, detectDuplication as v, devContext as vt, seoPostToolUseResponse as w, defaultStateDir as wt, lifecycleStdout as x, claudeMdKey as xt, dryGate as y, gitContext as yt, generateEcosystemMap as z };
|