@fusengine/harness 0.1.50 → 0.1.52

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 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-DzPjaPS-.mjs";
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 recordDoc, d as recordTrivialEdit, f as trivialCount, i as agentsFresh, l as recordRefRead, n as saveTrack, o as recordAgent, p as apexAuthorizationGate, r as verifyTrack, s as recordBrainstormRequired, t as loadTrack, u as recordTarget } from "./store-QSSTO3lY.mjs";
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/runtime/lifecycle/agent-files.ts
934
+ //#region src/tracking/receipts.ts
934
935
  /**
935
- * Per-agent file attribution for SubagentStop. Parses a sub-agent's OWN
936
- * transcript (`agent_transcript_path` the clean JSONL the Claude Code platform
937
- * writes for that specific sub-agent, added in CLI v2.0.42) to recover the exact
938
- * set of files it wrote via Write/Edit/MultiEdit/NotebookEdit. This lets the
939
- * sniper reminder attribute only the files THIS agent touched, instead of every
940
- * file changed in the whole session (which cross-attributes other teammates'
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
- /** Tools whose `input.file_path`/`notebook_path` names a file the agent authored. */
945
- const WRITE_TOOLS = /* @__PURE__ */ new Set([
946
- "Write",
947
- "Edit",
948
- "MultiEdit",
949
- "NotebookEdit"
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
- * Return the distinct list of file paths written by the sub-agent whose own
953
- * transcript is at `transcriptPath`, by scanning its Write/Edit/MultiEdit/
954
- * NotebookEdit `tool_use` blocks. Distinguishes "no writes" from "cannot read":
955
- *
956
- * - `null` the path is absent, or the transcript is unreadable. The caller
957
- * MUST fall back to the session-wide list (fail-open: never drop the sniper
958
- * reminder just because a transcript could not be parsed — no regression vs.
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 filesWrittenByAgent(transcriptPath) {
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 || !WRITE_TOOLS.has(block.name)) continue;
990
- const fp = block.input?.file_path ?? block.input?.notebook_path;
991
- if (typeof fp === "string" && fp && !seen.has(fp)) {
992
- seen.add(fp);
993
- out.push(fp);
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
- * Filter `sessionFiles` (from unified state `.changes.modifiedFiles`) down to the
1001
- * ones this agent actually wrote. Matches on the exact recorded string first,
1002
- * then tolerantly on `basename` so a relative-vs-absolute path drift between the
1003
- * PostToolUse record and the transcript input never loses an owned file.
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
- return contextResponse("SubagentStop", `SNIPER VALIDATION REQUIRED: Agent '${agentType}' modified ${owned.length} code file(s): ${owned.join(", ")}. Run sniper agent now.`);
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: re-measure the session's modified code files and emit a
1309
- * `SOLID VIOLATION` additionalContext listing any file over the line ceiling.
1310
- * Ports `task-completed/validate-task-solid.py`.
1311
- * @param payload - The TaskCompleted hook payload (`task_id`, `task_subject`, `session_id`).
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
- * @returns The native hook stdout, or `""` when there are no violations.
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": return input.scope === "aipilot" ? "" : trackAgentMemory(input.payload, void 0, input.now);
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 for {@link REQUIRED_AGENTS}, in ms. Matches the
4740
- * plugin's `FUSE_ENFORCE_TTL_SEC` default (120s). Only a fallback for direct
4741
- * programmatic callers that omit `windowMs` (e.g. tests) — the real CLI path
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 stateless guards (file-size, git, security...) first, then a
4749
- * trivial-edit fast path, then the stateful APEX gates fed from the session
4750
- * track. Returns the first blocking prompt, or null to allow.
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 {
@@ -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-DMpotbRz.mjs";
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 for {@link REQUIRED_AGENTS}, in ms. Matches the
112
- * plugin's `FUSE_ENFORCE_TTL_SEC` default (120s). Only a fallback for direct
113
- * programmatic callers that omit `windowMs` (e.g. tests) — the real CLI path
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 stateless guards (file-size, git, security...) first, then a
121
- * trivial-edit fast path, then the stateful APEX gates fed from the session
122
- * track. Returns the first blocking prompt, or null to allow.
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
@@ -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-DzPjaPS-.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-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 { emptyTrack as a, recordDoc as c, recordTrivialEdit as d, trivialCount as f, agentsFresh as i, recordRefRead as l, saveTrack as n, recordAgent as o, apexAuthorizationGate as p, verifyTrack as r, recordBrainstormRequired as s, loadTrack as t, recordTarget as u };
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-DMpotbRz.mjs";
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
  /**
@@ -1,2 +1,2 @@
1
- import { a as emptyTrack, c as recordDoc, d as recordTrivialEdit, f as trivialCount, i as agentsFresh, l as recordRefRead, n as saveTrack, o as recordAgent, s as recordBrainstormRequired, t as loadTrack, u as recordTarget } from "../store-QSSTO3lY.mjs";
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.50",
3
+ "version": "0.1.52",
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",
@@ -130,6 +130,7 @@
130
130
  "sideEffects": false,
131
131
  "scripts": {
132
132
  "test": "bun test",
133
+ "sim": "bun test test/sim/",
133
134
  "typecheck": "tsc --noEmit",
134
135
  "docs:api": "typedoc",
135
136
  "build": "tsdown src/index.ts src/config/index.ts src/util/index.ts src/detect/index.ts src/policy/index.ts src/prompt/index.ts src/memory/index.ts src/cache/index.ts src/freshness/index.ts src/refs/index.ts src/statusline/index.ts src/cli/index.ts src/cli/bin.ts src/init/index.ts src/tracking/index.ts src/runtime/index.ts src/adapters/claude/index.ts src/adapters/codex/index.ts src/adapters/cursor/index.ts src/adapters/cline/index.ts src/adapters/gemini/index.ts src/adapters/hermes/index.ts --dts --format esm --clean --out-dir dist",