@fusengine/harness 0.1.50 → 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-DzPjaPS-.mjs → handle-QfgfNSh6.mjs} +663 -252
- package/dist/runtime/index.d.mts +7 -8
- package/dist/runtime/index.mjs +1 -1
- 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
package/dist/cli/bin.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { t as detectHarness } from "../harness-Cb9xR8dC.mjs";
|
|
|
4
4
|
import { t as claudeHome } from "../home-state-D0RLWP8J.mjs";
|
|
5
5
|
import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-DZvP_9xB.mjs";
|
|
6
6
|
import { n as writeInitFile, t as initFor } from "../run-Do2JltgU.mjs";
|
|
7
|
-
import { Nt as todayUtc, t as handleHook } from "../handle-
|
|
7
|
+
import { Nt as todayUtc, t as handleHook } from "../handle-QfgfNSh6.mjs";
|
|
8
8
|
import { delimiter, dirname, join } from "node:path";
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { homedir } from "node:os";
|
|
@@ -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";
|
|
@@ -328,9 +329,9 @@ function designLifecycle(payload, cacheDir, cwd, stamp, now) {
|
|
|
328
329
|
return false;
|
|
329
330
|
}
|
|
330
331
|
/** Sidecar basename under the per-project state dir. */
|
|
331
|
-
const SIDECAR = "inject-dedup.json";
|
|
332
|
+
const SIDECAR$1 = "inject-dedup.json";
|
|
332
333
|
/** Load the `{ key -> epochMs }` map, or `{}` when missing/corrupt. */
|
|
333
|
-
function loadMap(path) {
|
|
334
|
+
function loadMap$1(path) {
|
|
334
335
|
try {
|
|
335
336
|
if (!existsSync(path)) return {};
|
|
336
337
|
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -340,7 +341,7 @@ function loadMap(path) {
|
|
|
340
341
|
}
|
|
341
342
|
}
|
|
342
343
|
/** Keep only entries newer than `windowMs` before `now` (bounds sidecar size). */
|
|
343
|
-
function prune(map, now, windowMs) {
|
|
344
|
+
function prune$1(map, now, windowMs) {
|
|
344
345
|
const out = {};
|
|
345
346
|
for (const [k, t] of Object.entries(map)) if (typeof t === "number" && now - t < windowMs) out[k] = t;
|
|
346
347
|
return out;
|
|
@@ -360,8 +361,8 @@ function prune(map, now, windowMs) {
|
|
|
360
361
|
*/
|
|
361
362
|
function oncePerWindow(key, windowMs, opts = {}) {
|
|
362
363
|
const now = opts.now ?? Date.now();
|
|
363
|
-
const path = join(opts.dir ?? defaultStateDir(), SIDECAR);
|
|
364
|
-
const map = prune(loadMap(path), now, windowMs);
|
|
364
|
+
const path = join(opts.dir ?? defaultStateDir(), SIDECAR$1);
|
|
365
|
+
const map = prune$1(loadMap$1(path), now, windowMs);
|
|
365
366
|
const last = map[key];
|
|
366
367
|
if (typeof last === "number" && now - last < windowMs) return false;
|
|
367
368
|
map[key] = now;
|
|
@@ -930,42 +931,122 @@ function subagentCacheContext(sessionIdRaw, home = homedir(), env = process.env,
|
|
|
930
931
|
return fresh.length ? contextResponse("SubagentStart", render(fresh)) : "";
|
|
931
932
|
}
|
|
932
933
|
//#endregion
|
|
933
|
-
//#region src/
|
|
934
|
+
//#region src/tracking/receipts.ts
|
|
934
935
|
/**
|
|
935
|
-
*
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
* work — the bug this fixes). Per LESSON.md, the SubagentStop transcript is the
|
|
942
|
-
* reliable anchor; sidechain PostToolUse hooks are not (issues #43612/#34692).
|
|
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
|
|
943
942
|
*/
|
|
944
|
-
/**
|
|
945
|
-
const
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
+
}
|
|
951
963
|
/**
|
|
952
|
-
*
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
* the pre-fix behavior).
|
|
960
|
-
* - `string[]` (possibly empty) — the transcript was read; every write was
|
|
961
|
-
* collected. An empty array means this agent authored no files, so it should
|
|
962
|
-
* NOT be told to validate files other agents changed.
|
|
963
|
-
*
|
|
964
|
-
* @param transcriptPath - Absolute path to the sub-agent's own `.jsonl`
|
|
965
|
-
* transcript (SubagentStop payload field `agent_transcript_path`).
|
|
966
|
-
* @returns Distinct written paths (order-preserving), or `null` when unreadable.
|
|
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).
|
|
967
971
|
*/
|
|
968
|
-
function
|
|
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) {
|
|
969
1050
|
if (!transcriptPath) return null;
|
|
970
1051
|
let text;
|
|
971
1052
|
try {
|
|
@@ -974,7 +1055,6 @@ function filesWrittenByAgent(transcriptPath) {
|
|
|
974
1055
|
return null;
|
|
975
1056
|
}
|
|
976
1057
|
const out = [];
|
|
977
|
-
const seen = /* @__PURE__ */ new Set();
|
|
978
1058
|
for (const line of text.split("\n")) {
|
|
979
1059
|
if (!line.trim()) continue;
|
|
980
1060
|
let entry;
|
|
@@ -985,22 +1065,63 @@ function filesWrittenByAgent(transcriptPath) {
|
|
|
985
1065
|
}
|
|
986
1066
|
const content = entry.message?.content;
|
|
987
1067
|
if (!Array.isArray(content)) continue;
|
|
1068
|
+
const ts = parseTs$2(entry.timestamp);
|
|
988
1069
|
for (const block of content) {
|
|
989
|
-
if (block?.type !== "tool_use" || !block.name
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
}
|
|
1070
|
+
if (block?.type !== "tool_use" || !block.name) continue;
|
|
1071
|
+
out.push({
|
|
1072
|
+
name: block.name,
|
|
1073
|
+
input: block.input,
|
|
1074
|
+
ts
|
|
1075
|
+
});
|
|
995
1076
|
}
|
|
996
1077
|
}
|
|
997
1078
|
return out;
|
|
998
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
|
+
]);
|
|
999
1096
|
/**
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
*
|
|
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.
|
|
1004
1125
|
* @param sessionFiles - Session-wide modified files.
|
|
1005
1126
|
* @param written - Paths the agent wrote (from {@link filesWrittenByAgent}).
|
|
1006
1127
|
* @returns The subset of `sessionFiles` attributable to this agent.
|
|
@@ -1058,7 +1179,9 @@ function trackAgentMemory(data, home = homedir(), now = Date.now()) {
|
|
|
1058
1179
|
cumulativeCodeFiles: 0
|
|
1059
1180
|
}
|
|
1060
1181
|
}, home);
|
|
1061
|
-
|
|
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}`);
|
|
1062
1185
|
}
|
|
1063
1186
|
}
|
|
1064
1187
|
return JSON.stringify({ message: `Agent ${agentType} completed (no code changes)` });
|
|
@@ -1260,8 +1383,292 @@ function postEditTypescript(filePath) {
|
|
|
1260
1383
|
if (hasBin("prettier")) {
|
|
1261
1384
|
if (run("prettier", ["--check", filePath]).code !== 0) issues.push(`Prettier: ${basename(filePath)} needs formatting`);
|
|
1262
1385
|
}
|
|
1263
|
-
if (issues.length === 0) return "";
|
|
1264
|
-
return contextResponse("PostToolUse", `Lint issues in ${basename(filePath)}: ${issues.join(" | ")}`);
|
|
1386
|
+
if (issues.length === 0) return "";
|
|
1387
|
+
return contextResponse("PostToolUse", `Lint issues in ${basename(filePath)}: ${issues.join(" | ")}`);
|
|
1388
|
+
}
|
|
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 {}
|
|
1265
1672
|
}
|
|
1266
1673
|
//#endregion
|
|
1267
1674
|
//#region src/runtime/lifecycle/task-completed.ts
|
|
@@ -1286,6 +1693,30 @@ const CODE_EXTENSIONS$2 = /* @__PURE__ */ new Set([
|
|
|
1286
1693
|
".svelte",
|
|
1287
1694
|
".astro"
|
|
1288
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
|
+
}
|
|
1289
1720
|
/**
|
|
1290
1721
|
* Re-count physical lines of every modified code file and collect SOLID
|
|
1291
1722
|
* violations (`<basename>: <n> lines (max <max>)`) for those exceeding `max`.
|
|
@@ -1305,21 +1736,24 @@ function collectViolations(files, max) {
|
|
|
1305
1736
|
return violations;
|
|
1306
1737
|
}
|
|
1307
1738
|
/**
|
|
1308
|
-
* Handle TaskCompleted
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
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`).
|
|
1312
1744
|
* @param home - Home dir (defaults to `~`).
|
|
1313
|
-
* @
|
|
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.
|
|
1314
1748
|
*/
|
|
1315
|
-
function validateTaskSolid(payload, home = homedir()) {
|
|
1749
|
+
function validateTaskSolid(payload, home = homedir(), now = Date.now(), stateDir = defaultStateDir(process.cwd())) {
|
|
1316
1750
|
const sid = sanitizeSessionId(payload.session_id ?? "unknown");
|
|
1317
1751
|
if (!sid) return "";
|
|
1318
1752
|
const files = loadSessionState(sid, home).changes?.modifiedFiles ?? [];
|
|
1319
1753
|
if (files.length === 0) return "";
|
|
1320
1754
|
const max = resolveMaxLines();
|
|
1321
1755
|
const violations = collectViolations(files, max);
|
|
1322
|
-
if (violations.length === 0) return "";
|
|
1756
|
+
if (violations.length === 0) return receiptGate(sid, files, now, stateDir) ?? "";
|
|
1323
1757
|
const taskId = String(payload.task_id ?? "");
|
|
1324
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("; "));
|
|
1325
1759
|
}
|
|
@@ -3180,7 +3614,10 @@ function dispatchLifecycle(input) {
|
|
|
3180
3614
|
if (input.scope === "lessons") return dispatchLessons("SubagentStart", input.payload, input.cwd, input.now);
|
|
3181
3615
|
return subagentCacheContext(input.payload.session_id);
|
|
3182
3616
|
case "Stop": return input.scope === "lessons" ? dispatchLessons("Stop", input.payload, input.cwd, input.now) : null;
|
|
3183
|
-
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);
|
|
3184
3621
|
case "TeammateIdle": return validateTeammateOutput(input.payload);
|
|
3185
3622
|
case "PostToolUseFailure":
|
|
3186
3623
|
logToolFailure(input.payload, void 0, input.now);
|
|
@@ -4412,101 +4849,6 @@ function geminiMcpGate(tool, filePath, content, ev) {
|
|
|
4412
4849
|
};
|
|
4413
4850
|
}
|
|
4414
4851
|
//#endregion
|
|
4415
|
-
//#region src/freshness/explore-tools.ts
|
|
4416
|
-
/** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
|
|
4417
|
-
const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
|
|
4418
|
-
/**
|
|
4419
|
-
* Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`).
|
|
4420
|
-
* The fuse-browser fast-path entries are a deliberate TS addition: CLAUDE.md
|
|
4421
|
-
* mandates fuse-browser FIRST for web research, and `docSourceOf` (activity.ts)
|
|
4422
|
-
* already credits it for doc consultation — without them here, an agent
|
|
4423
|
-
* following the rules would never satisfy the freshness gate (same
|
|
4424
|
-
* cross-consumer inconsistency class as the solidReadGate lesson).
|
|
4425
|
-
*/
|
|
4426
|
-
const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
|
|
4427
|
-
"mcp__context7__query-docs",
|
|
4428
|
-
"mcp__context7__resolve-library-id",
|
|
4429
|
-
"mcp__exa__web_search_exa",
|
|
4430
|
-
"mcp__exa__get_code_context_exa",
|
|
4431
|
-
"mcp__exa__deep_researcher_start",
|
|
4432
|
-
"WebSearch",
|
|
4433
|
-
"WebFetch",
|
|
4434
|
-
"mcp__fuse-browser__browser_fetch",
|
|
4435
|
-
"mcp__fuse-browser__browser_fetch_batch",
|
|
4436
|
-
"mcp__fuse-browser__browser_crawl",
|
|
4437
|
-
"mcp__fuse-browser__browser_serp_batch"
|
|
4438
|
-
]);
|
|
4439
|
-
/** Bash executables that count as exploration (parity `EXPLORE_BASH_CMDS`). */
|
|
4440
|
-
const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
|
|
4441
|
-
"grep",
|
|
4442
|
-
"rg",
|
|
4443
|
-
"find",
|
|
4444
|
-
"ls",
|
|
4445
|
-
"fd",
|
|
4446
|
-
"ast-grep",
|
|
4447
|
-
"tree",
|
|
4448
|
-
"cat",
|
|
4449
|
-
"head",
|
|
4450
|
-
"tail"
|
|
4451
|
-
]);
|
|
4452
|
-
/** Legacy Python cache names still credited (parity `CACHE_READ_RE` + doc-helpers). */
|
|
4453
|
-
const CACHE_READ_RE = /\/context\/mcp\/(exa-search|exa-code-context|context7)-/;
|
|
4454
|
-
/**
|
|
4455
|
-
* Real TS cache stores: `<root>/.harness/cache/<fnv16>.md` (core MCP store,
|
|
4456
|
-
* `projectLayout().cacheDir`) and `~/.fuse-harness/cache/**` (ai-pilot doc
|
|
4457
|
-
* caches, `cache-base.cacheBaseDir`). Segment-matched — not resolved against
|
|
4458
|
-
* `homedir()` — so tilde-prefixed and absolute paths both hit; the `.md`
|
|
4459
|
-
* suffix keeps session-state JSON reads from counting as research.
|
|
4460
|
-
*/
|
|
4461
|
-
const TS_CACHE_READ_RE = /\.(?:fuse-)?harness[\\/]cache[\\/].*\.md$/;
|
|
4462
|
-
/**
|
|
4463
|
-
* First non-assignment shell token's basename, or "" — mirrors Python
|
|
4464
|
-
* `_bash_executable` (skips leading `VAR=value` env prefixes, e.g. `FOO=1 grep`).
|
|
4465
|
-
* @param cmd - Raw Bash `command` string.
|
|
4466
|
-
* @returns The executable basename, or "" when none.
|
|
4467
|
-
*/
|
|
4468
|
-
function bashExecutable(cmd) {
|
|
4469
|
-
for (const token of cmd.trim().split(/\s+/)) {
|
|
4470
|
-
if (!token) continue;
|
|
4471
|
-
const last = token.split("/").pop() ?? token;
|
|
4472
|
-
if (!last.includes("=")) return last;
|
|
4473
|
-
}
|
|
4474
|
-
return "";
|
|
4475
|
-
}
|
|
4476
|
-
/**
|
|
4477
|
-
* Classify a direct tool use into an APEX phase, or `null` when it is neither
|
|
4478
|
-
* exploration nor research. Mirrors Python `track-subagent-research._classify`.
|
|
4479
|
-
* @param tool - Harness tool name (e.g. "Glob", "Bash", "WebSearch").
|
|
4480
|
-
* @param input - Tool input payload.
|
|
4481
|
-
* @returns The credited phase + cache flag, or `null`.
|
|
4482
|
-
*/
|
|
4483
|
-
function classifyExplore(tool, input) {
|
|
4484
|
-
if (RESEARCH_TOOLS.has(tool)) return {
|
|
4485
|
-
phase: "research-expert",
|
|
4486
|
-
cacheHit: false
|
|
4487
|
-
};
|
|
4488
|
-
if (EXPLORE_TOOLS.has(tool)) return {
|
|
4489
|
-
phase: "explore-codebase",
|
|
4490
|
-
cacheHit: false
|
|
4491
|
-
};
|
|
4492
|
-
if (tool === "Read") {
|
|
4493
|
-
const path = String(input?.file_path ?? input?.path ?? "");
|
|
4494
|
-
if (path && (CACHE_READ_RE.test(path) || TS_CACHE_READ_RE.test(path))) return {
|
|
4495
|
-
phase: "research-expert",
|
|
4496
|
-
cacheHit: true
|
|
4497
|
-
};
|
|
4498
|
-
return null;
|
|
4499
|
-
}
|
|
4500
|
-
if (tool === "Bash") {
|
|
4501
|
-
const cmd = String(input?.command ?? "").trim();
|
|
4502
|
-
if (EXPLORE_BASH_CMDS.has(bashExecutable(cmd))) return {
|
|
4503
|
-
phase: "explore-codebase",
|
|
4504
|
-
cacheHit: false
|
|
4505
|
-
};
|
|
4506
|
-
}
|
|
4507
|
-
return null;
|
|
4508
|
-
}
|
|
4509
|
-
//#endregion
|
|
4510
4852
|
//#region src/freshness/agent-evidence.ts
|
|
4511
4853
|
/**
|
|
4512
4854
|
* Platform-authored transcript evidence for APEX agent freshness.
|
|
@@ -4582,96 +4924,6 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
|
|
|
4582
4924
|
return names.every((n) => found.has(n));
|
|
4583
4925
|
}
|
|
4584
4926
|
//#endregion
|
|
4585
|
-
//#region src/freshness/agent-evidence-record.ts
|
|
4586
|
-
/**
|
|
4587
|
-
* Session-track agent evidence — writer (parity track-subagent-research.py) +
|
|
4588
|
-
* reader scan (parity apex_agent_helpers._scan_agents). Sub-agent hooks fire
|
|
4589
|
-
* with the LEAD's `session_id`, so evidence recorded here lands in the ONE
|
|
4590
|
-
* session track the freshness gate scans FIRST — sidechain research and
|
|
4591
|
-
* Workflow-spawned agents count, unlike the lead-transcript scan.
|
|
4592
|
-
*/
|
|
4593
|
-
/** Tools the existing Task tracking already credits as agent LAUNCHES. */
|
|
4594
|
-
const AGENT_LAUNCH_TOOLS = /* @__PURE__ */ new Set(["Task", "Agent"]);
|
|
4595
|
-
/**
|
|
4596
|
-
* `JSON.stringify` length of the raw `tool_response` OBJECT (parity Python
|
|
4597
|
-
* `len(str(tool_response))`); 0 when absent or unserializable (circular).
|
|
4598
|
-
*/
|
|
4599
|
-
function responseJsonLength(toolResponse) {
|
|
4600
|
-
if (toolResponse === void 0 || toolResponse === null) return 0;
|
|
4601
|
-
try {
|
|
4602
|
-
return (JSON.stringify(toolResponse) ?? "").length;
|
|
4603
|
-
} catch {
|
|
4604
|
-
return 0;
|
|
4605
|
-
}
|
|
4606
|
-
}
|
|
4607
|
-
/**
|
|
4608
|
-
* Classify one PostToolUse call into session evidence, or null when it is
|
|
4609
|
-
* neither exploration nor research (parity track-subagent-research._classify,
|
|
4610
|
-
* reusing the shared {@link classifyExplore} tables — research/explore/Bash/
|
|
4611
|
-
* cache-read). ANTI-DOUBLE-COUNT: calls classified as agent launches
|
|
4612
|
-
* (`Task`/`Agent`) are skipped — the existing Task tracking credits those; the
|
|
4613
|
-
* criterion is the CALL's classification, never its provenance (lead vs sub).
|
|
4614
|
-
* @param tool - Harness tool name (e.g. "Glob", "Bash", "Read").
|
|
4615
|
-
* @param input - Raw tool input payload.
|
|
4616
|
-
* @param toolResponse - Raw `tool_response` OBJECT from the hook payload.
|
|
4617
|
-
* @returns The evidence to record, or null to skip.
|
|
4618
|
-
*/
|
|
4619
|
-
function classifyAgentEvidence(tool, input, toolResponse) {
|
|
4620
|
-
if (AGENT_LAUNCH_TOOLS.has(tool)) return null;
|
|
4621
|
-
const hit = classifyExplore(tool, input);
|
|
4622
|
-
if (!hit) return null;
|
|
4623
|
-
return {
|
|
4624
|
-
name: hit.phase === "explore-codebase" ? "subagent-explore-codebase" : "subagent-research-expert",
|
|
4625
|
-
quality: hit.cacheHit || responseJsonLength(toolResponse) > 50 ? "sufficient" : "insufficient"
|
|
4626
|
-
};
|
|
4627
|
-
}
|
|
4628
|
-
/**
|
|
4629
|
-
* Persist evidence into the SESSION track via {@link recordAgent} — keyed by
|
|
4630
|
-
* `session_id` alone (sub-agent hooks carry the lead's `session_id`); the TTL
|
|
4631
|
-
* anchors on `ts`, the tool call's own timestamp. `agentId` is tagged as
|
|
4632
|
-
* metadata when present, NEVER used as a condition (unreliable field —
|
|
4633
|
-
* anthropics/claude-code#22348).
|
|
4634
|
-
* @param file - Session track file path.
|
|
4635
|
-
* @param evidence - Classified evidence from {@link classifyAgentEvidence}.
|
|
4636
|
-
* @param ts - Epoch-ms timestamp of the tool call (the hook event's `now`).
|
|
4637
|
-
* @param agentId - Optional Claude `agent_id` — metadata tag only.
|
|
4638
|
-
*/
|
|
4639
|
-
async function recordAgentEvidence(file, evidence, ts, agentId) {
|
|
4640
|
-
const next = recordAgent(await loadTrack(file), evidence.name, ts, evidence.quality);
|
|
4641
|
-
const last = next.agents[next.agents.length - 1];
|
|
4642
|
-
if (agentId && last) {
|
|
4643
|
-
const tagged = {
|
|
4644
|
-
...last,
|
|
4645
|
-
agentId
|
|
4646
|
-
};
|
|
4647
|
-
next.agents[next.agents.length - 1] = tagged;
|
|
4648
|
-
}
|
|
4649
|
-
await saveTrack(file, next);
|
|
4650
|
-
}
|
|
4651
|
-
/**
|
|
4652
|
-
* Parity `_scan_agents` (apex_agent_helpers.py): reverse-scan `track.agents`,
|
|
4653
|
-
* STOPPING at the first entry older than `windowMs` (entries append in time
|
|
4654
|
-
* order), matching each required name by SUBSTRING (`research-expert` matches
|
|
4655
|
-
* `subagent-research-expert`) and counting ONLY `quality === "sufficient"`.
|
|
4656
|
-
* @param track - The loaded session track.
|
|
4657
|
-
* @param names - Required agent names — ALL must match to return true.
|
|
4658
|
-
* @param windowMs - Freshness window (ms), anchored on each entry's `ts`.
|
|
4659
|
-
* @param now - Current epoch ms.
|
|
4660
|
-
* @returns True when every required name has fresh, sufficient evidence.
|
|
4661
|
-
*/
|
|
4662
|
-
function agentsFreshInTrack(track, names, windowMs, now) {
|
|
4663
|
-
const found = /* @__PURE__ */ new Set();
|
|
4664
|
-
for (let i = track.agents.length - 1; i >= 0; i--) {
|
|
4665
|
-
const entry = track.agents[i];
|
|
4666
|
-
if (!entry) continue;
|
|
4667
|
-
if (now - entry.ts > windowMs) break;
|
|
4668
|
-
if (entry.quality !== "sufficient") continue;
|
|
4669
|
-
for (const req of names) if (entry.name.includes(req)) found.add(req);
|
|
4670
|
-
if (found.size === names.length) return true;
|
|
4671
|
-
}
|
|
4672
|
-
return names.every((n) => found.has(n));
|
|
4673
|
-
}
|
|
4674
|
-
//#endregion
|
|
4675
4927
|
//#region src/runtime/gate-apex.ts
|
|
4676
4928
|
/**
|
|
4677
4929
|
* The APEX-scoped portion of {@link gate}, in the Python enforce-apex-phases.ts
|
|
@@ -4732,24 +4984,177 @@ async function apexScopedGate(input, track, window) {
|
|
|
4732
4984
|
}
|
|
4733
4985
|
}
|
|
4734
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
|
|
4735
5129
|
//#region src/runtime/gate.ts
|
|
4736
5130
|
/** Prior agents the freshness gate requires before a code edit. */
|
|
4737
5131
|
const REQUIRED_AGENTS = ["explore-codebase", "research-expert"];
|
|
4738
5132
|
/**
|
|
4739
|
-
* Default freshness window
|
|
4740
|
-
*
|
|
4741
|
-
*
|
|
4742
|
-
* 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`).
|
|
4743
5136
|
*/
|
|
4744
5137
|
const DEFAULT_WINDOW_MS = 12e4;
|
|
4745
5138
|
/** Trivial edits allowed within the window before the full APEX gates apply. */
|
|
4746
5139
|
const TRIVIAL_BUDGET = 4;
|
|
4747
5140
|
/**
|
|
4748
|
-
* Full gate: the
|
|
4749
|
-
*
|
|
4750
|
-
*
|
|
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`).
|
|
4751
5144
|
*/
|
|
4752
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) {
|
|
4753
5158
|
const precommit = preCommitGate(input.tool, input.command, input.cwd);
|
|
4754
5159
|
if (precommit) return precommit;
|
|
4755
5160
|
const protectedDeny = protectedPathGate(input.tool, input.filePath);
|
|
@@ -5868,6 +6273,12 @@ async function handlePost(ctx) {
|
|
|
5868
6273
|
for (const activity of activities) await recordActivity(file, activity);
|
|
5869
6274
|
const evidence = classifyAgentEvidence(event.tool, event.input, response);
|
|
5870
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
|
+
}
|
|
5871
6282
|
postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now, payload, opts.cwd);
|
|
5872
6283
|
const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
|
|
5873
6284
|
if (seoDeny) return {
|
package/dist/runtime/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as Prompt } from "../types-DVbIl9md.mjs";
|
|
2
2
|
import { t as RefMeta } from "../types-CY5qT2X1.mjs";
|
|
3
|
-
import { t as AgentQuality } from "../session-state-
|
|
3
|
+
import { t as AgentQuality } from "../session-state-CY4iohn_.mjs";
|
|
4
4
|
|
|
5
5
|
//#region src/runtime/paths.d.ts
|
|
6
6
|
/**
|
|
@@ -108,18 +108,17 @@ interface GateInput {
|
|
|
108
108
|
/** Prior agents the freshness gate requires before a code edit. */
|
|
109
109
|
declare const REQUIRED_AGENTS: ReadonlyArray<string>;
|
|
110
110
|
/**
|
|
111
|
-
* Default freshness window
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* always supplies `windowMs` from `resolveTtlSec()` (`src/config/ttl.ts`).
|
|
111
|
+
* Default freshness window (ms). Matches the plugin's `FUSE_ENFORCE_TTL_SEC`
|
|
112
|
+
* default (120s); only a fallback for callers that omit `windowMs` (e.g. tests) —
|
|
113
|
+
* the real CLI path always supplies it from `resolveTtlSec()` (`src/config/ttl.ts`).
|
|
115
114
|
*/
|
|
116
115
|
declare const DEFAULT_WINDOW_MS = 12e4;
|
|
117
116
|
/** Trivial edits allowed within the window before the full APEX gates apply. */
|
|
118
117
|
declare const TRIVIAL_BUDGET = 4;
|
|
119
118
|
/**
|
|
120
|
-
* Full gate: the
|
|
121
|
-
*
|
|
122
|
-
*
|
|
119
|
+
* Full gate: {@link runGates} yields the first blocking prompt (or null); the
|
|
120
|
+
* anti-loop tail ({@link withDenyLoop}) rewrites an identical retried deny's
|
|
121
|
+
* message (decision unchanged). Sidecar dir = the track's dir (tests off `$HOME`).
|
|
123
122
|
*/
|
|
124
123
|
declare function gate(input: GateInput): Promise<Prompt | null>;
|
|
125
124
|
//#endregion
|
package/dist/runtime/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { r as projectLayout } from "../layout-C0jaaCQC.mjs";
|
|
2
2
|
import { a as sanitizeSessionId, c as sessionsDir, i as loadSessionState, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, s as sessionStatePath, t as claudeHome } from "../home-state-D0RLWP8J.mjs";
|
|
3
|
-
import { $ as saveApexState, A as trackSkillRead, At as saveSecurityState, B as writePluginMap, C as seoPostToolUse, Ct as taskContext, D as postTrackingSideEffects, Dt as normalizeEvent, E as securityAdvisory, Et as trackFile, F as dispatchLessons, G as mergeLines, H as isProject, I as lessonsFileFor, J as listChildren, K as countFiles, L as lessonsStateFileFor, M as dispatchLifecycle, Mt as securityStatePath, N as aipilotPostToolUse, Nt as todayUtc, O as trackWatchResearch, Ot as isoUtc, P as dispatchAipilot, Q as cleanupSession, R as cartoSessionStart, S as postEditContext, St as promptSubmitContext, T as dispatchMemory, Tt as projectHash, U as writeTree, V as generateProjectMap, W as loadEnriched, X as trackSessionChanges, Y as postEditTypescript, Z as validateRulesLoaded, _ as preCommitGate, _t as trimLogFile, a as recordActivity, at as validateSolidGate, b as extractSymbols, bt as projectContext, c as MCP_TTL_MS, ct as detectSolidProfile, d as isMcpTool, dt as readRules, et as logToolFailure, f as queryOf, ft as runSessionStartCleanups, g as gate, gt as removeOldFiles, h as TRIVIAL_BUDGET, ht as purgeTtlTree, i as respond, it as validateTailwind, j as trackEnrichment, jt as securityStateDir, k as trackMcpResearch, kt as loadSecurityState, l as WEBFETCH_TTL_MS, lt as solidDetectStart, m as REQUIRED_AGENTS, mt as pruneEmptyDirs, n as activityFor, nt as trackAgentMemory, o as mcpPostStore, ot as checkFileSize, p as DEFAULT_WINDOW_MS, pt as sessionStartCore, q as getFileDesc, r as handlePre, rt as subagentCacheContext, s as mcpPreIntercept, st as countLoc, t as handleHook, tt as validateTeammateOutput, u as cacheQueryOf, ut as injectRules, v as detectDuplication, vt as devContext, w as seoPostToolUseResponse, wt as defaultStateDir, x as lifecycleStdout, xt as claudeMdKey, y as dryGate, yt as gitContext, z as generateEcosystemMap } from "../handle-
|
|
3
|
+
import { $ as saveApexState, A as trackSkillRead, At as saveSecurityState, B as writePluginMap, C as seoPostToolUse, Ct as taskContext, D as postTrackingSideEffects, Dt as normalizeEvent, E as securityAdvisory, Et as trackFile, F as dispatchLessons, G as mergeLines, H as isProject, I as lessonsFileFor, J as listChildren, K as countFiles, L as lessonsStateFileFor, M as dispatchLifecycle, Mt as securityStatePath, N as aipilotPostToolUse, Nt as todayUtc, O as trackWatchResearch, Ot as isoUtc, P as dispatchAipilot, Q as cleanupSession, R as cartoSessionStart, S as postEditContext, St as promptSubmitContext, T as dispatchMemory, Tt as projectHash, U as writeTree, V as generateProjectMap, W as loadEnriched, X as trackSessionChanges, Y as postEditTypescript, Z as validateRulesLoaded, _ as preCommitGate, _t as trimLogFile, a as recordActivity, at as validateSolidGate, b as extractSymbols, bt as projectContext, c as MCP_TTL_MS, ct as detectSolidProfile, d as isMcpTool, dt as readRules, et as logToolFailure, f as queryOf, ft as runSessionStartCleanups, g as gate, gt as removeOldFiles, h as TRIVIAL_BUDGET, ht as purgeTtlTree, i as respond, it as validateTailwind, j as trackEnrichment, jt as securityStateDir, k as trackMcpResearch, kt as loadSecurityState, l as WEBFETCH_TTL_MS, lt as solidDetectStart, m as REQUIRED_AGENTS, mt as pruneEmptyDirs, n as activityFor, nt as trackAgentMemory, o as mcpPostStore, ot as checkFileSize, p as DEFAULT_WINDOW_MS, pt as sessionStartCore, q as getFileDesc, r as handlePre, rt as subagentCacheContext, s as mcpPreIntercept, st as countLoc, t as handleHook, tt as validateTeammateOutput, u as cacheQueryOf, ut as injectRules, v as detectDuplication, vt as devContext, w as seoPostToolUseResponse, wt as defaultStateDir, x as lifecycleStdout, xt as claudeMdKey, y as dryGate, yt as gitContext, z as generateEcosystemMap } from "../handle-QfgfNSh6.mjs";
|
|
4
4
|
//#region src/runtime/storage.ts
|
|
5
5
|
/**
|
|
6
6
|
* The project's single state dir (`<root>/.harness`) — neutral + harness-agnostic,
|
|
@@ -9,6 +9,21 @@ interface SessionTarget {
|
|
|
9
9
|
set_at: string;
|
|
10
10
|
}
|
|
11
11
|
//#endregion
|
|
12
|
+
//#region src/tracking/receipts.d.ts
|
|
13
|
+
/**
|
|
14
|
+
* A verification receipt captured at PostToolUse from a Bash verification
|
|
15
|
+
* command. Feeds the TaskCompleted receipt gate: a "done" over modified code
|
|
16
|
+
* files is refused unless a fresh, passing receipt (`exitCode === 0`, `fail === 0`)
|
|
17
|
+
* exists. `pass`/`fail` are parsed only for test runs; `tsc` carries the code alone.
|
|
18
|
+
*/
|
|
19
|
+
interface Receipt {
|
|
20
|
+
kind: "tsc" | "test";
|
|
21
|
+
exitCode: number;
|
|
22
|
+
pass?: number;
|
|
23
|
+
fail?: number;
|
|
24
|
+
ts: number;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
12
27
|
//#region src/tracking/session-state.d.ts
|
|
13
28
|
/** Quality of a recorded agent call (the freshness gate ignores insufficient ones). */
|
|
14
29
|
type AgentQuality = "sufficient" | "insufficient";
|
|
@@ -34,6 +49,8 @@ interface SessionTrack {
|
|
|
34
49
|
}[];
|
|
35
50
|
trivialEdits: number[];
|
|
36
51
|
brainstormRequired?: boolean;
|
|
52
|
+
/** Verification receipts (tsc/test) at PostToolUse; absent/empty reads as unverified in the TaskCompleted gate (backward compat, fail-closed). See {@link Receipt}. */
|
|
53
|
+
receipts?: Receipt[];
|
|
37
54
|
}
|
|
38
55
|
/** A fresh, empty track. */
|
|
39
56
|
declare function emptyTrack(): SessionTrack;
|
|
@@ -346,4 +346,4 @@ async function saveTrack(file, track) {
|
|
|
346
346
|
writeLastNonce(envelope.nonce);
|
|
347
347
|
}
|
|
348
348
|
//#endregion
|
|
349
|
-
export {
|
|
349
|
+
export { writeLastNonce as a, recordAgent as c, recordRefRead as d, recordTarget as f, apexAuthorizationGate as h, verifyTrack as i, recordBrainstormRequired as l, trivialCount as m, saveTrack as n, agentsFresh as o, recordTrivialEdit as p, signTrack as r, emptyTrack as s, loadTrack as t, recordDoc as u };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as recordAgent, c as recordRefRead, d as trivialCount, i as emptyTrack, l as recordTarget, n as SessionTrack, o as recordBrainstormRequired, r as agentsFresh, s as recordDoc, t as AgentQuality, u as recordTrivialEdit } from "../session-state-
|
|
1
|
+
import { a as recordAgent, c as recordRefRead, d as trivialCount, i as emptyTrack, l as recordTarget, n as SessionTrack, o as recordBrainstormRequired, r as agentsFresh, s as recordDoc, t as AgentQuality, u as recordTrivialEdit } from "../session-state-CY4iohn_.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/tracking/store.d.ts
|
|
4
4
|
/**
|
package/dist/tracking/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { c as recordAgent, d as recordRefRead, f as recordTarget, l as recordBrainstormRequired, m as trivialCount, n as saveTrack, o as agentsFresh, p as recordTrivialEdit, s as emptyTrack, t as loadTrack, u as recordDoc } from "../store-CQ4roWrU.mjs";
|
|
2
2
|
export { agentsFresh, emptyTrack, loadTrack, recordAgent, recordBrainstormRequired, recordDoc, recordRefRead, recordTarget, recordTrivialEdit, saveTrack, trivialCount };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusengine/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.51",
|
|
4
4
|
"description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "src/index.ts",
|