@fusengine/harness 0.1.52 → 0.1.53

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,11 +4,10 @@ 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-QfgfNSh6.mjs";
8
- import { delimiter, dirname, join } from "node:path";
7
+ import { F as runDoctor, I as runningVersion, It as todayUtc, L as versionBanner, t as handleHook } from "../handle-D93-VIGS.mjs";
8
+ import { delimiter, join } from "node:path";
9
9
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
- import { fileURLToPath } from "node:url";
12
11
  //#region src/changelog/fetch.ts
13
12
  /**
14
13
  * Changelog scanner — ports the changelog-watcher plugin's `fetch-changelog`
@@ -354,90 +353,6 @@ function discoverRefs(home, cwd, marketplaces) {
354
353
  return [...bySkill.values()].join(delimiter);
355
354
  }
356
355
  //#endregion
357
- //#region src/cli/doctor.ts
358
- /**
359
- * `harness doctor` — diagnose which `@fusengine/harness` is actually running.
360
- *
361
- * A confirmed, still-open bun bug (oven-sh/bun #5791; scoped-pkg behaviour
362
- * reinforced by #32019/#32150) makes `bunx <pkg>` (unpinned) prefer a stale
363
- * GLOBAL install over npm-latest, so a consumer can silently run an old harness
364
- * after a publish. This command surfaces the truth: the resolved version +
365
- * package path of the code executing right now, the runtime binary, and the
366
- * latest version published on npm. It queries the registry over HTTP (not
367
- * `npm view`, whose exit code is 0 even on an empty result — npm/cli#6408) and
368
- * never throws: an offline environment yields `latest: null`, never a crash.
369
- */
370
- const PKG = "@fusengine/harness";
371
- /** Walk up from `startDir` for the `@fusengine/harness` `package.json`. */
372
- function findPackage(startDir) {
373
- let dir = startDir;
374
- for (let depth = 0; depth < 6; depth++) {
375
- try {
376
- const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
377
- if (pkg.name === PKG) return {
378
- version: pkg.version ?? "unknown",
379
- path: dir
380
- };
381
- } catch {}
382
- const parent = dirname(dir);
383
- if (parent === dir) break;
384
- dir = parent;
385
- }
386
- return null;
387
- }
388
- /** Resolve the running version + package path (no network), from a module URL. */
389
- function runningVersion(moduleUrl) {
390
- const found = findPackage(dirname(fileURLToPath(moduleUrl)));
391
- return {
392
- version: found?.version ?? "unknown",
393
- path: found?.path ?? "unknown"
394
- };
395
- }
396
- /** One-line `pkg vX.Y.Z` banner (stderr, only on explicit `--version`/`doctor` commands — never on `hook`, to avoid spamming automated invocations). */
397
- function versionBanner(moduleUrl) {
398
- return `${PKG} v${runningVersion(moduleUrl).version}`;
399
- }
400
- /** Latest published version via the npm registry HTTP API. `null` on any failure. */
401
- async function npmLatest() {
402
- try {
403
- const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(8e3) });
404
- if (!res.ok) return null;
405
- return (await res.json()).version ?? null;
406
- } catch {
407
- return null;
408
- }
409
- }
410
- /** Build the full diagnostic report for the module at `moduleUrl`. */
411
- async function buildDoctorReport(moduleUrl) {
412
- const { version, path } = runningVersion(moduleUrl);
413
- const latest = await npmLatest();
414
- return {
415
- running: version,
416
- packagePath: path,
417
- runtime: process.execPath,
418
- latest,
419
- stale: latest !== null && latest !== version
420
- };
421
- }
422
- /** Render a {@link DoctorReport} as human-readable stdout text. */
423
- function formatDoctor(r) {
424
- const lines = [
425
- `${PKG} doctor`,
426
- ` running: ${r.running}`,
427
- ` package: ${r.packagePath}`,
428
- ` runtime: ${r.runtime}`,
429
- ` npm latest: ${r.latest ?? "(unavailable — offline or unreachable)"}`
430
- ];
431
- if (r.stale) lines.push(` ! stale — npm serves ${r.latest}. Pin "@fusengine/harness@${r.latest}" in hooks.json (see README).`);
432
- else if (r.latest !== null) lines.push(` ok — running the latest published version.`);
433
- return lines.join("\n");
434
- }
435
- /** Run `harness doctor`: print the diagnostic to stdout. Always resolves 0 (pure info). */
436
- async function runDoctor(moduleUrl) {
437
- process.stdout.write(formatDoctor(await buildDoctorReport(moduleUrl)) + "\n");
438
- return 0;
439
- }
440
- //#endregion
441
356
  //#region src/cli/bin.ts
442
357
  /**
443
358
  * harness — CLI for @fusengine/harness.
@@ -20,7 +20,8 @@ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, read
20
20
  import { homedir } from "node:os";
21
21
  import { mkdir, rmdir } from "node:fs/promises";
22
22
  import { createHash } from "node:crypto";
23
- import { execFileSync } from "node:child_process";
23
+ import { execFileSync, execSync } from "node:child_process";
24
+ import { fileURLToPath } from "node:url";
24
25
  //#region src/runtime/lifecycle/security/skill-state.ts
25
26
  /**
26
27
  * Shared security-tracker state: per-UTC-day JSON under
@@ -329,7 +330,7 @@ function designLifecycle(payload, cacheDir, cwd, stamp, now) {
329
330
  return false;
330
331
  }
331
332
  /** Sidecar basename under the per-project state dir. */
332
- const SIDECAR$1 = "inject-dedup.json";
333
+ const SIDECAR$2 = "inject-dedup.json";
333
334
  /** Load the `{ key -> epochMs }` map, or `{}` when missing/corrupt. */
334
335
  function loadMap$1(path) {
335
336
  try {
@@ -361,7 +362,7 @@ function prune$1(map, now, windowMs) {
361
362
  */
362
363
  function oncePerWindow(key, windowMs, opts = {}) {
363
364
  const now = opts.now ?? Date.now();
364
- const path = join(opts.dir ?? defaultStateDir(), SIDECAR$1);
365
+ const path = join(opts.dir ?? defaultStateDir(), SIDECAR$2);
365
366
  const map = prune$1(loadMap$1(path), now, windowMs);
366
367
  const last = map[key];
367
368
  if (typeof last === "number" && now - last < windowMs) return false;
@@ -417,7 +418,7 @@ function taskContext(cwd) {
417
418
  //#endregion
418
419
  //#region src/runtime/dev-context.ts
419
420
  /** Run a git subcommand in `cwd`, returning trimmed stdout or "" on error. */
420
- function git(cwd, args) {
421
+ function git$1(cwd, args) {
421
422
  try {
422
423
  return execFileSync("git", args, {
423
424
  cwd,
@@ -432,8 +433,8 @@ function git(cwd, args) {
432
433
  /** Build the git portion of the dev context (branch + up to 5 changed files). */
433
434
  function gitContext(cwd) {
434
435
  if (!existsSync(join(cwd, ".git"))) return [];
435
- const parts = [`Git branch: ${git(cwd, ["branch", "--show-current"]) || "unknown"}`];
436
- const status = git(cwd, ["status", "--porcelain"]);
436
+ const parts = [`Git branch: ${git$1(cwd, ["branch", "--show-current"]) || "unknown"}`];
437
+ const status = git$1(cwd, ["status", "--porcelain"]);
437
438
  if (status) parts.push("Modified files:\n" + status.split("\n").slice(0, 5).join("\n"));
438
439
  return parts;
439
440
  }
@@ -2255,35 +2256,107 @@ function lessonsStateFileFor(root) {
2255
2256
  return join(root, "MEMORY", "state.json");
2256
2257
  }
2257
2258
  //#endregion
2258
- //#region src/runtime/lifecycle/lessons/dispatch.ts
2259
- /**
2260
- * fuse-lessons scope dispatch (TS port of the 4 handler scripts). Routes by
2261
- * event: SessionStart/SubagentStart inject `MEMORY/LESSON.md`; Stop reminds
2262
- * across every project with unsaved code edits; PostToolUse marks the write to
2263
- * arm/silence the per-project throttle. Non-fatal by design.
2264
- */
2265
- /** Inject `MEMORY/LESSON.md` for `event`, after mechanical curation (a strict dedup rewrites the file in place; any report surfaces to the user via systemMessage). */
2266
- function injectMemory(cwd, event, now) {
2267
- const root = projectRoot(cwd);
2268
- const file = lessonsFileFor(root);
2269
- if (!existsSync(file)) return "";
2270
- let content = "";
2259
+ //#region src/memory/session-roots.ts
2260
+ /**
2261
+ * Session-scoped lessons roots registry. The flat {@link module:memory/registry}
2262
+ * keeps ONE global list of pending roots — correct mono-session, but wrong with
2263
+ * several concurrent Claude Code sessions: at Stop, one session lists (and, by
2264
+ * bumping the throttle, STEALS) another session's pending lesson on a project it
2265
+ * never touched. This registry keys "which project got code edits, and was its
2266
+ * Stop reminder already fired" by `session_id`, so each Stop sees and consumes
2267
+ * ONLY its own roots. Stored at `$HOME/.fuse-harness/cache/lessons/session-roots.json`;
2268
+ * non-fatal on any I/O failure (a missed reminder never blocks a session).
2269
+ */
2270
+ /** Registry path (rel. home) + stale-bucket purge horizon (bounds growth). */
2271
+ const SUBPATH = ".fuse-harness/cache/lessons/session-roots.json";
2272
+ const PURGE_MS = 10080 * 60 * 1e3;
2273
+ /** Absolute registry path, or null when home is unusable. */
2274
+ function file(home) {
2275
+ const h = home?.trim();
2276
+ return h && h.startsWith("/") ? `${h}/${SUBPATH}` : null;
2277
+ }
2278
+ /** Read the registry; missing/corrupt/legacy (array) shapes collapse to `{}`. */
2279
+ function read(home) {
2280
+ const f = file(home);
2281
+ if (!f) return {};
2271
2282
  try {
2272
- content = readFileSync(file, "utf-8").trim();
2283
+ const parsed = JSON.parse(readFileSync(f, "utf8"));
2284
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2273
2285
  } catch {
2274
- return "";
2286
+ return {};
2275
2287
  }
2276
- if (!content) return "";
2277
- const { content: curated, report } = curateLessons(content, now, root);
2278
- if (curated !== content) try {
2279
- atomicWrite(file, curated);
2280
- content = curated;
2288
+ }
2289
+ /** Purge stale buckets, then atomically persist (unique tmp + rename). Non-throwing. */
2290
+ function write(home, reg, now) {
2291
+ const f = file(home);
2292
+ if (!f) return;
2293
+ for (const [sid, entry] of Object.entries(reg)) if (!entry || now - (entry.updatedAt ?? 0) > PURGE_MS) delete reg[sid];
2294
+ try {
2295
+ mkdirSync(dirname(f), { recursive: true });
2296
+ atomicWrite(f, JSON.stringify(reg));
2281
2297
  } catch {}
2282
- const ctx = `Project lessons — never reproduce these:\n${content}\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.`;
2283
- return report ? attachSystemMessage(contextResponse(event, ctx), `LESSON.md curation:\n${report}`) : contextResponse(event, ctx);
2284
2298
  }
2285
- /** Select roots with unsaved code edits past the throttle, bumping their state. */
2286
- function collectPending(now, window) {
2299
+ /** Record `field` for `(sid, root)`, refreshing the purge cursor. `home` defaults to `$HOME`. */
2300
+ function markSessionRoot(sid, root, field, value, home = process.env.HOME) {
2301
+ const reg = read(home);
2302
+ const prev = reg[sid];
2303
+ const entry = prev && typeof prev.roots === "object" && prev.roots !== null ? prev : {
2304
+ updatedAt: value,
2305
+ roots: {}
2306
+ };
2307
+ const mark = entry.roots[root] ?? {
2308
+ editedAt: 0,
2309
+ remindedAt: 0
2310
+ };
2311
+ entry.roots[root] = {
2312
+ ...mark,
2313
+ [field]: value
2314
+ };
2315
+ entry.updatedAt = value;
2316
+ reg[sid] = entry;
2317
+ write(home, reg, value);
2318
+ }
2319
+ /**
2320
+ * Roots of `sid` with an unsaved code edit past the `window`; each returned
2321
+ * root's `remindedAt` is bumped to `now` so the reminder fires at most once per
2322
+ * window and is consumed ONLY by this session. `home` defaults to `$HOME`.
2323
+ */
2324
+ function collectSessionPending(sid, now, window, home = process.env.HOME) {
2325
+ const reg = read(home);
2326
+ const entry = reg[sid];
2327
+ if (!entry || typeof entry.roots !== "object" || entry.roots === null) return [];
2328
+ const pending = [];
2329
+ for (const [root, mark] of Object.entries(entry.roots)) {
2330
+ if (mark.editedAt <= mark.remindedAt) continue;
2331
+ if (now - mark.remindedAt < window) continue;
2332
+ pending.push(root);
2333
+ entry.roots[root] = {
2334
+ ...mark,
2335
+ remindedAt: now
2336
+ };
2337
+ }
2338
+ if (pending.length > 0) write(home, reg, now);
2339
+ return pending;
2340
+ }
2341
+ //#endregion
2342
+ //#region src/runtime/lifecycle/lessons/reminder.ts
2343
+ /**
2344
+ * fuse-lessons write-mark + Stop-reminder, scoped by `session_id` when present.
2345
+ *
2346
+ * WITH a session id (normal Claude Code): each `(session, root)` pair carries
2347
+ * its own edit/reminder throttle in {@link module:memory/session-roots}, so a
2348
+ * Stop lists and silences ONLY the roots THAT session edited — concurrent
2349
+ * sessions on different projects never cross-remind nor steal each other's
2350
+ * throttle. WITHOUT a usable session id (a harness that omits it, or the legacy
2351
+ * on-disk state) it falls back to the original mono-session behavior: the global
2352
+ * flat root registry + the per-project `MEMORY/state.json` throttle.
2353
+ */
2354
+ /** Sanitized session id from a raw hook payload, or null (→ legacy fallback). */
2355
+ function sessionOf(payload) {
2356
+ return sanitizeSessionId(payload.session_id);
2357
+ }
2358
+ /** Legacy (no session id): pending roots across the global flat registry. */
2359
+ function collectLegacyPending(now, window) {
2287
2360
  const pending = [];
2288
2361
  for (const root of readRoots()) {
2289
2362
  const stateFile = lessonsStateFileFor(root);
@@ -2295,26 +2368,77 @@ function collectPending(now, window) {
2295
2368
  }
2296
2369
  return pending;
2297
2370
  }
2298
- /** Stop: emit one reminder covering every project with pending lessons. */
2299
- function remindWrite(now) {
2300
- const pending = collectPending(now, throttleMs());
2371
+ /** Stop reminder body listing each pending project's lessons file. */
2372
+ function reminderText(pending) {
2373
+ return `Before ending: if this session hit a mistake/blocker worth never reproducing, append 1-3 COMPACT bullets OR sharpen/merge existing ones (format \`- [${nowStamp()}] what went wrong → do instead\`, use exactly this timestamp) in each project's lessons file below. Skip if nothing notable.\n${pending.map((r) => `- ${r}/MEMORY/LESSON.md`).join("\n")}`;
2374
+ }
2375
+ /**
2376
+ * Stop: emit one reminder covering the stopping session's pending projects.
2377
+ * @param payload - Raw hook payload (`session_id` selects the scoped path).
2378
+ * @param now - Clock.
2379
+ * @returns Native Stop stdout, or "" when nothing is pending.
2380
+ */
2381
+ function remindWrite(payload, now) {
2382
+ const window = throttleMs();
2383
+ const sid = sessionOf(payload);
2384
+ const pending = sid ? collectSessionPending(sid, now, window) : collectLegacyPending(now, window);
2301
2385
  if (pending.length === 0) return "";
2302
- return contextResponse("Stop", `Before ending: if this session hit a mistake/blocker worth never reproducing, append 1-3 COMPACT bullets OR sharpen/merge existing ones (format \`- [${nowStamp()}] what went wrong → do instead\`, use exactly this timestamp) in each project's lessons file below. Skip if nothing notable.\n${pending.map((r) => `- ${r}/MEMORY/LESSON.md`).join("\n")}`);
2386
+ return contextResponse("Stop", reminderText(pending));
2303
2387
  }
2304
- /** PostToolUse: record the relevant throttle timestamp for the edited file. */
2388
+ /**
2389
+ * PostToolUse: record the edit against the throttle. A code file arms the
2390
+ * reminder; writing `MEMORY/LESSON.md` silences it (the lesson was just saved).
2391
+ * Session-scoped when `session_id` is present, else the legacy global path.
2392
+ * @param payload - Raw hook payload (`tool_input.file_path`, `session_id`).
2393
+ * @param now - Clock.
2394
+ */
2305
2395
  function markWrite(payload, now) {
2306
2396
  const input = payload.tool_input;
2307
2397
  if (!input?.file_path) return;
2308
2398
  const abs = resolve(input.file_path);
2309
2399
  const root = projectRootOrNull(dirname(abs));
2310
2400
  if (!root) return;
2311
- const stateFile = lessonsStateFileFor(root);
2312
- if (abs === resolve(root, "MEMORY", "LESSON.md")) setStateField(stateFile, "lastRemindedAt", now);
2313
- else if (isCodeFile(abs)) {
2314
- setStateField(stateFile, "lastCodeEditAt", now);
2401
+ const isLesson = abs === resolve(root, "MEMORY", "LESSON.md");
2402
+ if (!isLesson && !isCodeFile(abs)) return;
2403
+ const sid = sessionOf(payload);
2404
+ if (sid) markSessionRoot(sid, root, isLesson ? "remindedAt" : "editedAt", now);
2405
+ else if (isLesson) setStateField(lessonsStateFileFor(root), "lastRemindedAt", now);
2406
+ else {
2407
+ setStateField(lessonsStateFileFor(root), "lastCodeEditAt", now);
2315
2408
  addRoot(root);
2316
2409
  }
2317
2410
  }
2411
+ //#endregion
2412
+ //#region src/runtime/lifecycle/lessons/dispatch.ts
2413
+ /**
2414
+ * fuse-lessons scope dispatch (TS port of the 4 handler scripts). Routes by
2415
+ * event: SessionStart/SubagentStart inject `MEMORY/LESSON.md`; Stop reminds the
2416
+ * stopping session about ITS OWN projects with unsaved code edits; PostToolUse
2417
+ * marks the write to arm/silence the throttle. The reminder + mark logic (incl.
2418
+ * the per-`session_id` scoping that fixes the multi-session misdirection) lives
2419
+ * in {@link module:runtime/lifecycle/lessons/reminder}; this module keeps the
2420
+ * event router + lesson-file injection. Non-fatal by design.
2421
+ */
2422
+ /** Inject `MEMORY/LESSON.md` for `event`, after mechanical curation (a strict dedup rewrites the file in place; any report surfaces to the user via systemMessage). */
2423
+ function injectMemory(cwd, event, now) {
2424
+ const root = projectRoot(cwd);
2425
+ const file = lessonsFileFor(root);
2426
+ if (!existsSync(file)) return "";
2427
+ let content = "";
2428
+ try {
2429
+ content = readFileSync(file, "utf-8").trim();
2430
+ } catch {
2431
+ return "";
2432
+ }
2433
+ if (!content) return "";
2434
+ const { content: curated, report } = curateLessons(content, now, root);
2435
+ if (curated !== content) try {
2436
+ atomicWrite(file, curated);
2437
+ content = curated;
2438
+ } catch {}
2439
+ const ctx = `Project lessons — never reproduce these:\n${content}\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.`;
2440
+ return report ? attachSystemMessage(contextResponse(event, ctx), `LESSON.md curation:\n${report}`) : contextResponse(event, ctx);
2441
+ }
2318
2442
  /**
2319
2443
  * Route a fuse-lessons event to its handler. Returns the native stdout for
2320
2444
  * context-injecting events (SessionStart/SubagentStart/Stop) or "" for the
@@ -2329,7 +2453,7 @@ function dispatchLessons(event, payload, cwd, now) {
2329
2453
  switch (event) {
2330
2454
  case "SessionStart":
2331
2455
  case "SubagentStart": return injectMemory(cwd, event, now);
2332
- case "Stop": return remindWrite(now);
2456
+ case "Stop": return remindWrite(payload, now);
2333
2457
  case "PostToolUse":
2334
2458
  markWrite(payload, now);
2335
2459
  return "";
@@ -2337,6 +2461,559 @@ function dispatchLessons(event, payload, cwd, now) {
2337
2461
  }
2338
2462
  }
2339
2463
  //#endregion
2464
+ //#region src/policy/deny-loop.ts
2465
+ /**
2466
+ * @module deny-loop
2467
+ * Pure anti-loop logic: hash a tool-call, decide if it repeats a prior deny, and
2468
+ * enrich the repeated block's message.
2469
+ *
2470
+ * The proprietary rule "NEVER propose the same fix twice" is prose a model under
2471
+ * pressure ignores. This makes it machine-enforced: when a call whose
2472
+ * `(tool + normalized input)` hash was ALREADY denied in-window is retried, the
2473
+ * harness keeps the deny but rewrites the message — `[REPEAT]` title, STOP
2474
+ * prefix, forced `research-expert` action. State + wiring live in the sidecar
2475
+ * store ({@link module:deny-loop-store}); this file is IO-free and pure.
2476
+ * @packageDocumentation
2477
+ */
2478
+ /** Stable JSON: keys sorted at every depth so `{a,b}` and `{b,a}` hash identically. */
2479
+ function stableStringify(v) {
2480
+ if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
2481
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
2482
+ const o = v;
2483
+ return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(",")}}`;
2484
+ }
2485
+ /**
2486
+ * Stable identity hash of a tool-call = tool name + normalized (key-sorted) input,
2487
+ * so re-ordered keys never mask a repeat.
2488
+ * @param tool - Tool name (e.g. "Write", "Bash").
2489
+ * @param input - Identifying tool input (filePath/content/command...).
2490
+ * @returns 8-char hex hash.
2491
+ */
2492
+ function denyHash(tool, input) {
2493
+ return hashText(`${tool}\n${stableStringify(input)}`);
2494
+ }
2495
+ /**
2496
+ * Pure loop check: given the already-pruned in-window map, compute the running
2497
+ * count for `hash` and whether it repeats (count > 1). No IO — the caller persists.
2498
+ * @param hash - {@link denyHash} of the current call.
2499
+ * @param priorDenies - The `{ hash -> DenyEntry }` map, pruned to `now`/`windowMs`.
2500
+ * @param opts - Clock + window.
2501
+ * @returns `{ isRepeat, count, hash }`.
2502
+ */
2503
+ function denyLoopCheck(hash, priorDenies, opts) {
2504
+ const prev = priorDenies[hash];
2505
+ const count = (prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs ? prev.count : 0) + 1;
2506
+ return {
2507
+ isRepeat: count > 1,
2508
+ count,
2509
+ hash
2510
+ };
2511
+ }
2512
+ /**
2513
+ * Enrich a REPEATED block prompt — a NEW object, never a mutation (the input may
2514
+ * be a shared const like FAIL_CLOSED). The decision stays `block`; only the
2515
+ * message changes, so every harness renders it through the same adapter.
2516
+ * @param prompt - The original block prompt.
2517
+ * @param count - The running identical-deny count (n).
2518
+ * @returns A block prompt with `[REPEAT]` title, STOP-prefixed reason, forced research action.
2519
+ */
2520
+ function enrichRepeatDeny(prompt, count) {
2521
+ const stop = `Tentative identique n°${count} déjà refusée pour la même raison. STOP: ne retente pas ce même appel. `;
2522
+ const action = "Launch fuse-ai-pilot:research-expert to find a DIFFERENT approach";
2523
+ return {
2524
+ ...prompt,
2525
+ title: prompt.title.startsWith("[REPEAT]") ? prompt.title : `[REPEAT] ${prompt.title}`,
2526
+ reason: stop + prompt.reason,
2527
+ actions: [action, ...prompt.actions ?? []]
2528
+ };
2529
+ }
2530
+ //#endregion
2531
+ //#region src/tracking/one-shot-store.ts
2532
+ /** A fresh, empty state — always spread (`{ ...EMPTY }`) so the const is never shared. */
2533
+ const EMPTY = {
2534
+ gates: {},
2535
+ firstTry: 0,
2536
+ corrected: 0,
2537
+ pending: {},
2538
+ updatedAt: 0
2539
+ };
2540
+ /**
2541
+ * Drop stale data: whole-state idle reset past the window, else per-entry prune of
2542
+ * gates/pending older than `windowMs`. Keeps the "7d" window honest, bounds size.
2543
+ */
2544
+ function pruneState(s, now, windowMs) {
2545
+ if (now - s.updatedAt >= windowMs) return { ...EMPTY };
2546
+ const gates = {};
2547
+ for (const [k, g] of Object.entries(s.gates)) if (now - g.lastTs < windowMs) gates[k] = g;
2548
+ const pending = {};
2549
+ for (const [k, p] of Object.entries(s.pending)) if (now - p.ts < windowMs) pending[k] = p;
2550
+ return {
2551
+ ...s,
2552
+ gates,
2553
+ pending
2554
+ };
2555
+ }
2556
+ /**
2557
+ * Record a deny for gate `title` on operation `op` (content-free tool identity):
2558
+ * bump the gate's deny count and mark `op` pending for a later fix.
2559
+ */
2560
+ function applyDeny(s, title, op, now) {
2561
+ const g = s.gates[title] ?? {
2562
+ denies: 0,
2563
+ corrected: 0,
2564
+ lastTs: 0
2565
+ };
2566
+ return {
2567
+ ...s,
2568
+ gates: {
2569
+ ...s.gates,
2570
+ [title]: {
2571
+ denies: g.denies + 1,
2572
+ corrected: g.corrected,
2573
+ lastTs: now
2574
+ }
2575
+ },
2576
+ pending: {
2577
+ ...s.pending,
2578
+ [op]: {
2579
+ title,
2580
+ ts: now
2581
+ }
2582
+ },
2583
+ updatedAt: now
2584
+ };
2585
+ }
2586
+ /**
2587
+ * Record an allow for a gateable `op`. A non-gateable allow (Read/Task/MCP) leaves
2588
+ * state untouched — it never counts and never clears a pending deny. Otherwise: a
2589
+ * pending deny → `corrected` (a fix, credited to the blocking gate); no pending →
2590
+ * `firstTry` (one-shot).
2591
+ */
2592
+ function applyAllow(s, op, now, gateable) {
2593
+ if (!gateable) return s;
2594
+ const pend = s.pending[op];
2595
+ if (pend) {
2596
+ const g = s.gates[pend.title] ?? {
2597
+ denies: 0,
2598
+ corrected: 0,
2599
+ lastTs: 0
2600
+ };
2601
+ const { [op]: _drop, ...pending } = s.pending;
2602
+ return {
2603
+ ...s,
2604
+ gates: {
2605
+ ...s.gates,
2606
+ [pend.title]: {
2607
+ ...g,
2608
+ corrected: g.corrected + 1,
2609
+ lastTs: now
2610
+ }
2611
+ },
2612
+ corrected: s.corrected + 1,
2613
+ pending,
2614
+ updatedAt: now
2615
+ };
2616
+ }
2617
+ return gateable ? {
2618
+ ...s,
2619
+ firstTry: s.firstTry + 1,
2620
+ updatedAt: now
2621
+ } : s;
2622
+ }
2623
+ /**
2624
+ * Compact injectable summary (one line); "" when there is nothing to report.
2625
+ * @returns e.g. `gates 7d: 88% one-shot (44/50 clean); SOLID file-size limit 4den/3fix`.
2626
+ */
2627
+ function formatSummary(s) {
2628
+ const keys = Object.keys(s.gates);
2629
+ const total = s.firstTry + s.corrected;
2630
+ if (keys.length === 0 && total === 0) return "";
2631
+ const head = total > 0 ? `${Math.round(s.firstTry / total * 100)}% one-shot (${s.firstTry}/${total} clean)` : "no clean pass yet";
2632
+ const parts = keys.map((k) => ({
2633
+ k,
2634
+ g: s.gates[k]
2635
+ })).sort((a, b) => b.g.denies - a.g.denies).map(({ k, g }) => `${k} ${g.denies}den/${g.corrected}fix`);
2636
+ return `gates 7d: ${head}${parts.length ? `; ${parts.join("; ")}` : ""}`;
2637
+ }
2638
+ //#endregion
2639
+ //#region src/tracking/one-shot.ts
2640
+ /**
2641
+ * @module one-shot
2642
+ * Sidecar store + gate wiring for the per-gate one-shot metric.
2643
+ *
2644
+ * STATE — a standalone sidecar (`one-shot.json`) in the same per-project state dir
2645
+ * as the session track, mirroring {@link module:deny-loop-store} (atomicWrite,
2646
+ * prune-by-window, fail-safe). A write error NEVER changes a gate decision nor its
2647
+ * prompt — metrics are pure observation.
2648
+ *
2649
+ * KEY — the operation identity is content-FREE (`tool + filePath/command`): a fix
2650
+ * changes the content, so a content hash would make every retry a new op and hide
2651
+ * the deny→allow transition this metric exists to see. The pure model lives in
2652
+ * {@link module:one-shot-store}; this file is the only IO surface.
2653
+ * @packageDocumentation
2654
+ */
2655
+ /** Sidecar basename under the per-project state dir. */
2656
+ const SIDECAR$1 = "one-shot.json";
2657
+ /** Retention window: 7 days. Aggregates and pending denies older than this are pruned. */
2658
+ const WINDOW_MS = 10080 * 60 * 1e3;
2659
+ /** Load the state, or a fresh copy when missing/corrupt. */
2660
+ function loadState(path) {
2661
+ try {
2662
+ if (!existsSync(path)) return { ...EMPTY };
2663
+ const d = JSON.parse(readFileSync(path, "utf8"));
2664
+ return d && typeof d === "object" && !Array.isArray(d) ? {
2665
+ ...EMPTY,
2666
+ ...d
2667
+ } : { ...EMPTY };
2668
+ } catch {
2669
+ return { ...EMPTY };
2670
+ }
2671
+ }
2672
+ /**
2673
+ * Record a gate outcome: a `block` is a deny for its gate title; a `null` allow is
2674
+ * a fix (if the op was pending) or a one-shot (if gateable). `ask`/`inform` are
2675
+ * neither and are skipped. Fails silently — a metric write NEVER affects a decision.
2676
+ *
2677
+ * The op key is tool-INDEPENDENT (`filePath`/`command` only, constant `"op"` tool):
2678
+ * a deny (a `Write`) and its fix (an `Edit`) on the same file must link.
2679
+ * @param prompt - The gate's outcome (block, allow=null, or ask/inform).
2680
+ * @param input - Identifying tool input (content decides gateability only).
2681
+ * @param opts - Clock + state dir.
2682
+ */
2683
+ function recordOneShot(prompt, input, opts) {
2684
+ try {
2685
+ if (prompt && prompt.kind !== "block") return;
2686
+ const path = join(opts.dir, SIDECAR$1);
2687
+ let s = pruneState(loadState(path), opts.now, WINDOW_MS);
2688
+ const op = denyHash("op", {
2689
+ filePath: input.filePath,
2690
+ command: input.command
2691
+ });
2692
+ s = prompt ? applyDeny(s, prompt.title, op, opts.now) : applyAllow(s, op, opts.now, input.content != null || input.command != null);
2693
+ atomicWrite(path, JSON.stringify(s));
2694
+ } catch {}
2695
+ }
2696
+ /**
2697
+ * Compact, injection-ready one-shot summary for the project rooted at `cwd`. The
2698
+ * state dir is derived EXACTLY like the runtime writer ({@link defaultStateDir},
2699
+ * mirroring `handle.ts` `trackFile(sid, defaultStateDir(cwd))`), so the file read
2700
+ * here is the same one {@link recordOneShot} wrote. "" when no data or read error.
2701
+ * @param cwd - The project working directory (Claude `cwd`), NOT the state dir.
2702
+ * @returns One line, e.g. `gates 7d: 88% one-shot (44/50 clean); ...`, or "".
2703
+ */
2704
+ function oneShotSummary(cwd) {
2705
+ try {
2706
+ return formatSummary(pruneState(loadState(join(defaultStateDir(cwd), SIDECAR$1)), Date.now(), WINDOW_MS));
2707
+ } catch {
2708
+ return "";
2709
+ }
2710
+ }
2711
+ //#endregion
2712
+ //#region src/runtime/lifecycle/snapshot/git.ts
2713
+ /**
2714
+ * Run a git subcommand at `root` with a short timeout, returning trimmed stdout.
2715
+ * Uses `node:child_process` (the Bun shell can hang on some git plumbing) and
2716
+ * swallows every failure — a non-repo, missing git, or timeout yields `""` so
2717
+ * the caller omits the section instead of throwing inside the hook.
2718
+ * @param root - Directory to run git in.
2719
+ * @param args - The git args (e.g. `"log --oneline -3"`).
2720
+ * @returns Trimmed stdout, or `""` on any error.
2721
+ */
2722
+ function git(root, args) {
2723
+ try {
2724
+ return execSync(`git ${args}`, {
2725
+ cwd: root,
2726
+ encoding: "utf8",
2727
+ timeout: 150,
2728
+ stdio: [
2729
+ "ignore",
2730
+ "pipe",
2731
+ "ignore"
2732
+ ]
2733
+ }).trim();
2734
+ } catch {
2735
+ return "";
2736
+ }
2737
+ }
2738
+ /** Count staged/unstaged/untracked files from porcelain v1 output (skips the `##` branch line). */
2739
+ function countWip(porcelain) {
2740
+ const w = {
2741
+ staged: 0,
2742
+ unstaged: 0,
2743
+ untracked: 0
2744
+ };
2745
+ for (const line of porcelain.split("\n")) {
2746
+ if (!line || line.startsWith("#")) continue;
2747
+ if (line.startsWith("??")) {
2748
+ w.untracked++;
2749
+ continue;
2750
+ }
2751
+ const x = line[0], y = line[1];
2752
+ if (x && x !== " " && x !== "?") w.staged++;
2753
+ if (y === "M" || y === "D") w.unstaged++;
2754
+ }
2755
+ return w;
2756
+ }
2757
+ /** Parse the current branch from the leading `## branch...upstream` porcelain line. */
2758
+ function parseBranch(porcelain) {
2759
+ const head = porcelain.split("\n")[0] ?? "";
2760
+ if (!head.startsWith("## ")) return "";
2761
+ const rest = head.slice(3);
2762
+ const dots = rest.indexOf("...");
2763
+ return (dots >= 0 ? rest.slice(0, dots) : rest).split(" ")[0] ?? "";
2764
+ }
2765
+ /**
2766
+ * Collect a compact git reconciliation section for `root`: current branch, the
2767
+ * last three commits (oneline), and staged/unstaged/untracked WIP counts. When
2768
+ * `root` is not a git repo (status fails) the whole section is omitted (`""`).
2769
+ * @param root - The project/repo root.
2770
+ * @returns The rendered git section body, or `""` when not a repo.
2771
+ */
2772
+ function collectGit(root) {
2773
+ const status = git(root, "status --porcelain=v1 --branch");
2774
+ if (!status) return "";
2775
+ const branch = parseBranch(status) || "(unknown)";
2776
+ const w = countWip(status);
2777
+ const log = git(root, "log --oneline -3");
2778
+ const lines = [`- branch: ${branch}`];
2779
+ if (log) lines.push("- recent:", ...log.split("\n").map((l) => ` ${l}`));
2780
+ lines.push(`- WIP: ${w.staged} staged, ${w.unstaged} unstaged, ${w.untracked} untracked`);
2781
+ return lines.join("\n");
2782
+ }
2783
+ //#endregion
2784
+ //#region src/cli/doctor.ts
2785
+ /**
2786
+ * `harness doctor` — diagnose which `@fusengine/harness` is actually running.
2787
+ *
2788
+ * A confirmed, still-open bun bug (oven-sh/bun #5791; scoped-pkg behaviour
2789
+ * reinforced by #32019/#32150) makes `bunx <pkg>` (unpinned) prefer a stale
2790
+ * GLOBAL install over npm-latest, so a consumer can silently run an old harness
2791
+ * after a publish. This command surfaces the truth: the resolved version +
2792
+ * package path of the code executing right now, the runtime binary, and the
2793
+ * latest version published on npm. It queries the registry over HTTP (not
2794
+ * `npm view`, whose exit code is 0 even on an empty result — npm/cli#6408) and
2795
+ * never throws: an offline environment yields `latest: null`, never a crash.
2796
+ */
2797
+ const PKG = "@fusengine/harness";
2798
+ /** Walk up from `startDir` for the `@fusengine/harness` `package.json`. */
2799
+ function findPackage(startDir) {
2800
+ let dir = startDir;
2801
+ for (let depth = 0; depth < 6; depth++) {
2802
+ try {
2803
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
2804
+ if (pkg.name === PKG) return {
2805
+ version: pkg.version ?? "unknown",
2806
+ path: dir
2807
+ };
2808
+ } catch {}
2809
+ const parent = dirname(dir);
2810
+ if (parent === dir) break;
2811
+ dir = parent;
2812
+ }
2813
+ return null;
2814
+ }
2815
+ /** Resolve the running version + package path (no network), from a module URL. */
2816
+ function runningVersion(moduleUrl) {
2817
+ const found = findPackage(dirname(fileURLToPath(moduleUrl)));
2818
+ return {
2819
+ version: found?.version ?? "unknown",
2820
+ path: found?.path ?? "unknown"
2821
+ };
2822
+ }
2823
+ /** One-line `pkg vX.Y.Z` banner (stderr, only on explicit `--version`/`doctor` commands — never on `hook`, to avoid spamming automated invocations). */
2824
+ function versionBanner(moduleUrl) {
2825
+ return `${PKG} v${runningVersion(moduleUrl).version}`;
2826
+ }
2827
+ /** Latest published version via the npm registry HTTP API. `null` on any failure. */
2828
+ async function npmLatest() {
2829
+ try {
2830
+ const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(8e3) });
2831
+ if (!res.ok) return null;
2832
+ return (await res.json()).version ?? null;
2833
+ } catch {
2834
+ return null;
2835
+ }
2836
+ }
2837
+ /** Build the full diagnostic report for the module at `moduleUrl`. */
2838
+ async function buildDoctorReport(moduleUrl) {
2839
+ const { version, path } = runningVersion(moduleUrl);
2840
+ const latest = await npmLatest();
2841
+ return {
2842
+ running: version,
2843
+ packagePath: path,
2844
+ runtime: process.execPath,
2845
+ latest,
2846
+ stale: latest !== null && latest !== version
2847
+ };
2848
+ }
2849
+ /** Render a {@link DoctorReport} as human-readable stdout text. */
2850
+ function formatDoctor(r) {
2851
+ const lines = [
2852
+ `${PKG} doctor`,
2853
+ ` running: ${r.running}`,
2854
+ ` package: ${r.packagePath}`,
2855
+ ` runtime: ${r.runtime}`,
2856
+ ` npm latest: ${r.latest ?? "(unavailable — offline or unreachable)"}`
2857
+ ];
2858
+ if (r.stale) lines.push(` ! stale — npm serves ${r.latest}. Pin "@fusengine/harness@${r.latest}" in hooks.json (see README).`);
2859
+ else if (r.latest !== null) lines.push(` ok — running the latest published version.`);
2860
+ return lines.join("\n");
2861
+ }
2862
+ /** Run `harness doctor`: print the diagnostic to stdout. Always resolves 0 (pure info). */
2863
+ async function runDoctor(moduleUrl) {
2864
+ process.stdout.write(formatDoctor(await buildDoctorReport(moduleUrl)) + "\n");
2865
+ return 0;
2866
+ }
2867
+ //#endregion
2868
+ //#region src/runtime/lifecycle/snapshot/version.ts
2869
+ /** Read the `version` field of `<root>/package.json`, or `""` if absent/unreadable. */
2870
+ function pkgVersion(root) {
2871
+ try {
2872
+ return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version ?? "";
2873
+ } catch {
2874
+ return "";
2875
+ }
2876
+ }
2877
+ /**
2878
+ * Collect the version reconciliation section: the harness version actually
2879
+ * running (resolved from {@link runningVersion}, no network) and, when `root`
2880
+ * carries its own `package.json`, whether that project's version has drifted
2881
+ * from the running harness.
2882
+ * @param root - The project root (cwd repo).
2883
+ * @param moduleUrl - `import.meta.url` of the calling module (locates the running package.json).
2884
+ * @returns The rendered version section body (never `""`).
2885
+ */
2886
+ function collectVersion(root, moduleUrl) {
2887
+ const running = runningVersion(moduleUrl).version;
2888
+ const lines = [`- harness running: v${running}`];
2889
+ const project = pkgVersion(root);
2890
+ if (project && project !== running) lines.push(`- project package.json: v${project} (DRIFT — running harness differs)`);
2891
+ else if (project) lines.push(`- project package.json: v${project} (in sync)`);
2892
+ return lines.join("\n");
2893
+ }
2894
+ //#endregion
2895
+ //#region src/runtime/lifecycle/snapshot/board.ts
2896
+ /** Max board characters injected — a persistent board should stay small; over-long boards are truncated. */
2897
+ const MAX_BOARD = 4e3;
2898
+ /**
2899
+ * Collect the persistent task board: the contents of `<root>/.claude/BOARD.md`
2900
+ * (truncated to {@link MAX_BOARD}) plus an instruction to keep it current. The
2901
+ * board lives on disk so it survives context purges — rehydrated every session.
2902
+ * Missing/empty/unreadable board → `""` (section omitted).
2903
+ * @param root - The project root.
2904
+ * @returns The rendered board section body, or `""` when there is no board.
2905
+ */
2906
+ function collectBoard(root) {
2907
+ const path = join(root, ".claude", "BOARD.md");
2908
+ try {
2909
+ if (!existsSync(path)) return "";
2910
+ let body = readFileSync(path, "utf8").trim();
2911
+ if (!body) return "";
2912
+ if (body.length > MAX_BOARD) body = `${body.slice(0, MAX_BOARD)}\n… (truncated)`;
2913
+ return `- .claude/BOARD.md (keep current — Write to it as tasks start/finish):\n\n${body}`;
2914
+ } catch {
2915
+ return "";
2916
+ }
2917
+ }
2918
+ //#endregion
2919
+ //#region src/runtime/lifecycle/snapshot/format.ts
2920
+ /**
2921
+ * Render the non-empty `sections` under one reconciliation heading. Empty
2922
+ * sections are dropped; when every section is empty the whole snapshot is `""`.
2923
+ * @param sections - The collected sections in display order.
2924
+ * @returns The assembled markdown block, or `""` when nothing to report.
2925
+ */
2926
+ function renderSections(sections) {
2927
+ const parts = sections.filter((s) => s.body.trim()).map((s) => `### ${s.title}\n${s.body.trim()}`);
2928
+ if (!parts.length) return "";
2929
+ return `# Reconciliation snapshot
2930
+ Real state of the world at session start — reconcile against this instead of re-discovering it.\n\n${parts.join("\n\n")}`;
2931
+ }
2932
+ /**
2933
+ * Concatenate `snapshot` onto an existing SessionStart stdout's
2934
+ * `additionalContext` — it never replaces prior injected context (CLAUDE.md,
2935
+ * dev-context). When `stdout` is empty a fresh {@link contextResponse} is made;
2936
+ * a non-empty but unparseable `stdout` is returned UNCHANGED (the snapshot is
2937
+ * dropped) — fabricating a fresh response there would discard the very CLAUDE.md
2938
+ * injection the invariant protects, so preserving prior context always wins.
2939
+ * @param stdout - The core SessionStart JSON stdout (may be `""`).
2940
+ * @param snapshot - The snapshot markdown to append (no-op when `""`).
2941
+ * @returns The merged hook stdout JSON.
2942
+ */
2943
+ function attachSnapshot(stdout, snapshot) {
2944
+ if (!snapshot) return stdout;
2945
+ if (!stdout) return contextResponse("SessionStart", snapshot);
2946
+ try {
2947
+ const parsed = JSON.parse(stdout);
2948
+ const prev = parsed.hookSpecificOutput?.additionalContext ?? "";
2949
+ const merged = prev ? `${prev}\n\n${snapshot}` : snapshot;
2950
+ return JSON.stringify({
2951
+ ...parsed,
2952
+ hookSpecificOutput: {
2953
+ ...parsed.hookSpecificOutput,
2954
+ hookEventName: "SessionStart",
2955
+ additionalContext: merged
2956
+ }
2957
+ });
2958
+ } catch {
2959
+ return stdout;
2960
+ }
2961
+ }
2962
+ //#endregion
2963
+ //#region src/runtime/lifecycle/snapshot/index.ts
2964
+ /** Run `fn`, swallowing any throw into `""` so no single collector can break the hook. */
2965
+ function safe(fn) {
2966
+ try {
2967
+ return fn();
2968
+ } catch {
2969
+ return "";
2970
+ }
2971
+ }
2972
+ /**
2973
+ * Build the reconciliation snapshot markdown for `cwd`: git state, running
2974
+ * harness version + drift, the persistent board, and one-shot gate status. Each
2975
+ * collector is isolated by {@link safe}; an all-empty result yields `""`.
2976
+ * @param cwd - The session working directory.
2977
+ * @param moduleUrl - `import.meta.url` of the caller (locates the running package).
2978
+ * @returns The snapshot markdown, or `""` when nothing to report.
2979
+ */
2980
+ function renderSnapshot(cwd, moduleUrl) {
2981
+ const root = projectRootOrNull(cwd) ?? cwd;
2982
+ return renderSections([
2983
+ {
2984
+ title: "Git",
2985
+ body: safe(() => collectGit(root))
2986
+ },
2987
+ {
2988
+ title: "Version",
2989
+ body: safe(() => collectVersion(root, moduleUrl))
2990
+ },
2991
+ {
2992
+ title: "Board",
2993
+ body: safe(() => collectBoard(root))
2994
+ },
2995
+ {
2996
+ title: "One-shot gates",
2997
+ body: safe(() => oneShotSummary(cwd))
2998
+ }
2999
+ ]);
3000
+ }
3001
+ /**
3002
+ * Concatenate the reconciliation snapshot onto a core SessionStart stdout. Fully
3003
+ * fail-safe: any error returns `stdout` unchanged so the hook never breaks.
3004
+ * @param stdout - The core SessionStart JSON stdout (may be `""`).
3005
+ * @param cwd - The session working directory.
3006
+ * @param moduleUrl - `import.meta.url` of the caller.
3007
+ * @returns The merged hook stdout.
3008
+ */
3009
+ function withSnapshot(stdout, cwd, moduleUrl) {
3010
+ try {
3011
+ return attachSnapshot(stdout, renderSnapshot(cwd, moduleUrl));
3012
+ } catch {
3013
+ return stdout;
3014
+ }
3015
+ }
3016
+ //#endregion
2340
3017
  //#region src/runtime/lifecycle/aipilot/inject-apex.ts
2341
3018
  /**
2342
3019
  * SubagentStart (matcher "") for the ai-pilot scope: inject APEX AGENTS.md +
@@ -3596,7 +4273,8 @@ function sessionStart(input) {
3596
4273
  if (input.scope === "rules") return injectRules(process.env.CLAUDE_PLUGIN_ROOT ?? input.cwd);
3597
4274
  if (input.scope === "carto") return cartoSessionStart(input.cwd, input.now);
3598
4275
  if (input.scope === "lessons") return dispatchLessons("SessionStart", input.payload, input.cwd, input.now);
3599
- return sessionStartCore(input.cwd, void 0, input.now);
4276
+ const core = sessionStartCore(input.cwd, void 0, input.now);
4277
+ return input.scope === "core" ? withSnapshot(core, input.cwd, import.meta.url) : core;
3600
4278
  }
3601
4279
  /**
3602
4280
  * Route a lifecycle/session/context hook event to its ported handler. Returns
@@ -4984,73 +5662,6 @@ async function apexScopedGate(input, track, window) {
4984
5662
  }
4985
5663
  }
4986
5664
  //#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
5665
  //#region src/runtime/deny-loop-store.ts
5055
5666
  /**
5056
5667
  * @module deny-loop-store
@@ -5138,18 +5749,25 @@ const DEFAULT_WINDOW_MS = 12e4;
5138
5749
  /** Trivial edits allowed within the window before the full APEX gates apply. */
5139
5750
  const TRIVIAL_BUDGET = 4;
5140
5751
  /**
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`).
5752
+ * Full gate: {@link runGates} yields the first blocking prompt (or null); the tail
5753
+ * records the one-shot metric ({@link recordOneShot}, observation-only) then lets
5754
+ * {@link withDenyLoop} rewrite an identical retried deny (decision unchanged).
5144
5755
  */
5145
5756
  async function gate(input) {
5146
- return withDenyLoop(await runGates(input), input.tool, {
5757
+ const prompt = await runGates(input);
5758
+ const op = {
5147
5759
  filePath: input.filePath,
5148
5760
  content: input.content,
5149
5761
  command: input.command
5150
- }, {
5762
+ };
5763
+ const dir = dirname(input.trackFile);
5764
+ recordOneShot(prompt, op, {
5765
+ now: input.now,
5766
+ dir
5767
+ });
5768
+ return withDenyLoop(prompt, input.tool, op, {
5151
5769
  now: input.now,
5152
- dir: dirname(input.trackFile),
5770
+ dir,
5153
5771
  windowMs: input.windowMs ?? 12e4
5154
5772
  });
5155
5773
  }
@@ -6420,4 +7038,4 @@ async function handleHook(id, payload, opts) {
6420
7038
  });
6421
7039
  }
6422
7040
  //#endregion
6423
- export { saveApexState as $, trackSkillRead as A, saveSecurityState as At, writePluginMap as B, seoPostToolUse as C, taskContext as Ct, postTrackingSideEffects as D, normalizeEvent as Dt, securityAdvisory as E, trackFile as Et, dispatchLessons as F, mergeLines as G, isProject as H, lessonsFileFor as I, listChildren as J, countFiles as K, lessonsStateFileFor as L, dispatchLifecycle as M, securityStatePath as Mt, aipilotPostToolUse as N, todayUtc as Nt, trackWatchResearch as O, isoUtc as Ot, dispatchAipilot as P, cleanupSession as Q, cartoSessionStart as R, postEditContext as S, promptSubmitContext as St, dispatchMemory as T, projectHash$1 as Tt, writeTree as U, generateProjectMap as V, loadEnriched as W, trackSessionChanges as X, postEditTypescript as Y, validateRulesLoaded as Z, preCommitGate as _, trimLogFile as _t, recordActivity as a, validateSolidGate as at, extractSymbols as b, projectContext as bt, MCP_TTL_MS as c, detectSolidProfile as ct, isMcpTool as d, readRules as dt, logToolFailure as et, queryOf as f, runSessionStartCleanups as ft, gate as g, removeOldFiles as gt, TRIVIAL_BUDGET as h, purgeTtlTree as ht, respond as i, validateTailwind as it, trackEnrichment as j, securityStateDir as jt, trackMcpResearch as k, loadSecurityState as kt, WEBFETCH_TTL_MS as l, solidDetectStart as lt, REQUIRED_AGENTS as m, pruneEmptyDirs as mt, activityFor as n, trackAgentMemory as nt, mcpPostStore as o, checkFileSize as ot, DEFAULT_WINDOW_MS as p, sessionStartCore as pt, getFileDesc as q, handlePre as r, subagentCacheContext as rt, mcpPreIntercept as s, countLoc as st, handleHook as t, validateTeammateOutput as tt, cacheQueryOf as u, injectRules as ut, detectDuplication as v, devContext as vt, seoPostToolUseResponse as w, defaultStateDir as wt, lifecycleStdout as x, claudeMdKey as xt, dryGate as y, gitContext as yt, generateEcosystemMap as z };
7041
+ export { trackSessionChanges as $, trackSkillRead as A, normalizeEvent as At, lessonsStateFileFor as B, seoPostToolUse as C, projectContext as Ct, postTrackingSideEffects as D, defaultStateDir as Dt, securityAdvisory as E, taskContext as Et, runDoctor as F, securityStatePath as Ft, isProject as G, generateEcosystemMap as H, runningVersion as I, todayUtc as It, mergeLines as J, writeTree as K, versionBanner as L, dispatchLifecycle as M, loadSecurityState as Mt, aipilotPostToolUse as N, saveSecurityState as Nt, trackWatchResearch as O, projectHash$1 as Ot, dispatchAipilot as P, securityStateDir as Pt, postEditTypescript as Q, dispatchLessons as R, postEditContext as S, gitContext as St, dispatchMemory as T, promptSubmitContext as Tt, writePluginMap as U, cartoSessionStart as V, generateProjectMap as W, getFileDesc as X, countFiles as Y, listChildren as Z, preCommitGate as _, pruneEmptyDirs as _t, recordActivity as a, trackAgentMemory as at, extractSymbols as b, trimLogFile as bt, MCP_TTL_MS as c, validateSolidGate as ct, isMcpTool as d, detectSolidProfile as dt, validateRulesLoaded as et, queryOf as f, solidDetectStart as ft, gate as g, sessionStartCore as gt, TRIVIAL_BUDGET as h, runSessionStartCleanups as ht, respond as i, validateTeammateOutput as it, trackEnrichment as j, isoUtc as jt, trackMcpResearch as k, trackFile as kt, WEBFETCH_TTL_MS as l, checkFileSize as lt, REQUIRED_AGENTS as m, readRules as mt, activityFor as n, saveApexState as nt, mcpPostStore as o, subagentCacheContext as ot, DEFAULT_WINDOW_MS as p, injectRules as pt, loadEnriched as q, handlePre as r, logToolFailure as rt, mcpPreIntercept as s, validateTailwind as st, handleHook as t, cleanupSession as tt, cacheQueryOf as u, countLoc as ut, detectDuplication as v, purgeTtlTree as vt, seoPostToolUseResponse as w, claudeMdKey as wt, lifecycleStdout as x, devContext as xt, dryGate as y, removeOldFiles as yt, lessonsFileFor as z };
@@ -116,9 +116,9 @@ declare const DEFAULT_WINDOW_MS = 12e4;
116
116
  /** Trivial edits allowed within the window before the full APEX gates apply. */
117
117
  declare const TRIVIAL_BUDGET = 4;
118
118
  /**
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`).
119
+ * Full gate: {@link runGates} yields the first blocking prompt (or null); the tail
120
+ * records the one-shot metric ({@link recordOneShot}, observation-only) then lets
121
+ * {@link withDenyLoop} rewrite an identical retried deny (decision unchanged).
122
122
  */
123
123
  declare function gate(input: GateInput): Promise<Prompt | null>;
124
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-QfgfNSh6.mjs";
3
+ import { $ as trackSessionChanges, A as trackSkillRead, At as normalizeEvent, B as lessonsStateFileFor, C as seoPostToolUse, Ct as projectContext, D as postTrackingSideEffects, Dt as defaultStateDir, E as securityAdvisory, Et as taskContext, Ft as securityStatePath, G as isProject, H as generateEcosystemMap, It as todayUtc, J as mergeLines, K as writeTree, M as dispatchLifecycle, Mt as loadSecurityState, N as aipilotPostToolUse, Nt as saveSecurityState, O as trackWatchResearch, Ot as projectHash, P as dispatchAipilot, Pt as securityStateDir, Q as postEditTypescript, R as dispatchLessons, S as postEditContext, St as gitContext, T as dispatchMemory, Tt as promptSubmitContext, U as writePluginMap, V as cartoSessionStart, W as generateProjectMap, X as getFileDesc, Y as countFiles, Z as listChildren, _ as preCommitGate, _t as pruneEmptyDirs, a as recordActivity, at as trackAgentMemory, b as extractSymbols, bt as trimLogFile, c as MCP_TTL_MS, ct as validateSolidGate, d as isMcpTool, dt as detectSolidProfile, et as validateRulesLoaded, f as queryOf, ft as solidDetectStart, g as gate, gt as sessionStartCore, h as TRIVIAL_BUDGET, ht as runSessionStartCleanups, i as respond, it as validateTeammateOutput, j as trackEnrichment, jt as isoUtc, k as trackMcpResearch, kt as trackFile, l as WEBFETCH_TTL_MS, lt as checkFileSize, m as REQUIRED_AGENTS, mt as readRules, n as activityFor, nt as saveApexState, o as mcpPostStore, ot as subagentCacheContext, p as DEFAULT_WINDOW_MS, pt as injectRules, q as loadEnriched, r as handlePre, rt as logToolFailure, s as mcpPreIntercept, st as validateTailwind, t as handleHook, tt as cleanupSession, u as cacheQueryOf, ut as countLoc, v as detectDuplication, vt as purgeTtlTree, w as seoPostToolUseResponse, wt as claudeMdKey, x as lifecycleStdout, xt as devContext, y as dryGate, yt as removeOldFiles, z as lessonsFileFor } from "../handle-D93-VIGS.mjs";
4
4
  //#region src/runtime/storage.ts
5
5
  /**
6
6
  * The project's single state dir (`<root>/.harness`) — neutral + harness-agnostic,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fusengine/harness",
3
- "version": "0.1.52",
3
+ "version": "0.1.53",
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",