@fusengine/harness 0.1.52 → 0.1.54

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.
@@ -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
  }
@@ -902,10 +903,10 @@ function trunc(text, limit) {
902
903
  /** Render fresh cache entries as the markdown injection block. */
903
904
  function render(entries) {
904
905
  const lines = [
905
- "# MCP Cache disponible cette session",
906
- "Avant de lancer mcp__context7/exa, verifie si deja cached.",
907
- "Lis le fichier .md via Read pour recuperer le resultat.",
908
- "APEX: Read sur cache MCP compte comme research-expert satisfait.",
906
+ "# MCP cache available this session",
907
+ "Before calling mcp__context7/exa, check if the result is already cached.",
908
+ "Read the .md file via Read to retrieve the result.",
909
+ "APEX: reading the MCP cache via Read satisfies the research-expert step.",
909
910
  "",
910
911
  "| Tool | Query | File |",
911
912
  "| --- | --- | --- |"
@@ -1232,7 +1233,7 @@ function pad(n) {
1232
1233
  return String(n).padStart(2, "0");
1233
1234
  }
1234
1235
  /** Compact local timestamp `YYYYMMDD-HHMMSS` (mirrors Python strftime). */
1235
- function stamp(now) {
1236
+ function stamp$1(now) {
1236
1237
  const d = new Date(now);
1237
1238
  return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
1238
1239
  }
@@ -1249,7 +1250,7 @@ function saveApexState(cwd, now = Date.now()) {
1249
1250
  if (!existsSync(stateFile)) return "";
1250
1251
  const backupDir = join(apexDir, "backups");
1251
1252
  mkdirSync(backupDir, { recursive: true });
1252
- copyFileSync(stateFile, join(backupDir, `task-${stamp(now)}.json`));
1253
+ copyFileSync(stateFile, join(backupDir, `task-${stamp$1(now)}.json`));
1253
1254
  const backups = readdirSync(backupDir).filter((n) => n.startsWith("task-") && n.endsWith(".json")).sort().reverse();
1254
1255
  for (const old of backups.slice(5)) try {
1255
1256
  rmSync(join(backupDir, old), { force: true });
@@ -2135,16 +2136,10 @@ function cartoSessionStart(cwd, now = Date.now()) {
2135
2136
  return ctx ? contextResponse("SessionStart", ctx) : "";
2136
2137
  }
2137
2138
  //#endregion
2138
- //#region src/runtime/lifecycle/aipilot/curate-lessons.ts
2139
- /**
2140
- * Mechanical, LLM-free curation of `MEMORY/LESSON.md` bullets (anti-obesity):
2141
- * strict-dedup near-identical bullets (keep newest, `[TRIGGERS …]` preserved),
2142
- * flag over-cap + stale (>90d, cited path gone) in a report. Only dedup writes.
2143
- */
2144
- const CAP = 50;
2145
- const STALE_DAYS = 90;
2146
- const SIM_THRESHOLD = .8;
2147
- const MIN_TOKENS = 4;
2139
+ //#region src/runtime/lifecycle/aipilot/lesson-parse.ts
2140
+ /** Milliseconds in a day. */
2141
+ const DAY_MS = 864e5;
2142
+ /** Case-sensitive decision-time tag line (`[TRIGGERS …]`) — opus-lessons format. */
2148
2143
  const TRIG = /^\[TRIGGERS\s+.+\]$/;
2149
2144
  /** Epoch ms for a `[YYYY-MM-DD HH:MM]` stamp; `NaN` if absent or out of range. */
2150
2145
  function parseTs$1(line) {
@@ -2171,7 +2166,11 @@ function citedPaths(text) {
2171
2166
  for (const m of text.matchAll(/[\w./@-]+\.\w{1,5}/g)) if (m[0]) out.add(m[0]);
2172
2167
  return [...out].filter((p) => p.includes("/") && /\.\w{1,5}$/.test(p));
2173
2168
  }
2174
- /** Split into a verbatim preamble and one Block per `- ` bullet. */
2169
+ /** True when a block carries a `[TRIGGERS …]` continuation line. */
2170
+ function hasTrigger(b) {
2171
+ return b.raw.some((l) => TRIG.test(l.trim()));
2172
+ }
2173
+ /** Split content into a verbatim preamble and one Block per `- ` bullet. */
2175
2174
  function parse(content) {
2176
2175
  const lines = content.split("\n");
2177
2176
  const blocks = [];
@@ -2192,23 +2191,89 @@ function parse(content) {
2192
2191
  blocks
2193
2192
  };
2194
2193
  }
2194
+ //#endregion
2195
+ //#region src/runtime/lifecycle/aipilot/lesson-archive.ts
2196
+ /**
2197
+ * Stage 1 — cap→archive split for LESSON.md. When deduped bullets exceed CAP the
2198
+ * OLDEST excess is MOVED (never deleted) to LESSON-archive.md, EXCEPT a
2199
+ * `[TRIGGERS …]` bullet younger than STALE_DAYS: archiving it would blind the
2200
+ * PreToolUse trigger index (src/policy/lessons/trigger-index reads LESSON.md), so
2201
+ * it stays even past the cap. Pure: this module decides the partition and renders
2202
+ * the archive block; the fail-safe, archive-first file write is the caller's job.
2203
+ */
2204
+ /** Sort key: undated bullets sort oldest, so malformed entries archive first. */
2205
+ function age(b) {
2206
+ return Number.isNaN(b.ts) ? -Infinity : b.ts;
2207
+ }
2208
+ /** A TRIGGERS bullet is protected from archival until older than STALE_DAYS. */
2209
+ function isProtected(b, staleBefore) {
2210
+ return hasTrigger(b) && !(b.ts <= staleBefore);
2211
+ }
2212
+ /**
2213
+ * Partition deduped `blocks` (newest-first file order) into the bullets that
2214
+ * stay in LESSON.md and the oldest excess to archive. Archives only enough to
2215
+ * reach CAP, skipping protected TRIGGERS bullets (so the file MAY stay slightly
2216
+ * over cap by design). Order is preserved in both halves; `keep ∪ archive` is
2217
+ * exactly `blocks` with no loss and no mutation.
2218
+ * @param blocks - Deduped bullets, newest first.
2219
+ * @param now - Clock (ms) for the STALE_DAYS protection window.
2220
+ * @returns `{ keep, archive }` — a lossless partition of `blocks`.
2221
+ */
2222
+ function splitAtCap(blocks, now) {
2223
+ if (blocks.length <= 50) return {
2224
+ keep: blocks,
2225
+ archive: []
2226
+ };
2227
+ const staleBefore = now - 90 * DAY_MS;
2228
+ const oldestFirst = [...blocks].sort((a, b) => age(a) - age(b));
2229
+ const toArchive = /* @__PURE__ */ new Set();
2230
+ let excess = blocks.length - 50;
2231
+ for (const b of oldestFirst) {
2232
+ if (excess <= 0) break;
2233
+ if (isProtected(b, staleBefore)) continue;
2234
+ toArchive.add(b);
2235
+ excess--;
2236
+ }
2237
+ return {
2238
+ keep: blocks.filter((b) => !toArchive.has(b)),
2239
+ archive: blocks.filter((b) => toArchive.has(b))
2240
+ };
2241
+ }
2242
+ /**
2243
+ * Render `archive` bullets as a dated block to PREPEND to LESSON-archive.md
2244
+ * (newest archive session on top). Bullets are emitted BYTE-IDENTICAL (raw lines
2245
+ * rejoined) — zero mutation, so the move stays reversible/auditable.
2246
+ * @param archive - Bullets chosen by {@link splitAtCap}.
2247
+ * @param now - Clock (ms) for the archival header date.
2248
+ * @returns The block text (trailing newline), or "" when nothing is archived.
2249
+ */
2250
+ function formatArchive(archive, now) {
2251
+ if (archive.length === 0) return "";
2252
+ return `${`<!-- archived ${new Date(now).toISOString().slice(0, 10)}: ${archive.length} bullet(s) moved from LESSON.md at cap 50 -->`}\n${archive.map((b) => b.raw.join("\n")).join("\n\n")}\n`;
2253
+ }
2254
+ //#endregion
2255
+ //#region src/runtime/lifecycle/aipilot/curate-lessons.ts
2256
+ /**
2257
+ * Stage-0 mechanical, LLM-free dedup of MEMORY/LESSON.md bullets + cap→archive
2258
+ * orchestration. Strict-dedup near-identical bullets (keep newest, `[TRIGGERS …]`
2259
+ * preserved), then hand the deduped set to lesson-archive's cap split. Returns the
2260
+ * rewritten LESSON.md content, the archive block to move out, and a human report.
2261
+ * Pure: all file I/O (archive-first, fail-safe) lives in the dispatch caller.
2262
+ */
2263
+ const SIM_THRESHOLD = .8;
2264
+ const MIN_TOKENS = 4;
2195
2265
  /** Report lines for bullets older than STALE_DAYS whose only cited path is gone. */
2196
2266
  function staleReport(blocks, now, root) {
2197
- const cutoff = now - STALE_DAYS * 864e5;
2267
+ const cutoff = now - 90 * DAY_MS;
2198
2268
  return blocks.flatMap((b) => {
2199
2269
  if (!(b.ts <= cutoff)) return [];
2200
2270
  const paths = citedPaths(b.raw.join(" "));
2201
2271
  if (paths.length === 0 || paths.some((p) => existsSync(join(root, p)))) return [];
2202
- return [`[STALE?] ${(b.raw[0] ?? "").slice(0, 90)} — chemin(s) disparu(s): ${paths.join(", ")}`];
2272
+ return [`[STALE?] ${(b.raw[0] ?? "").slice(0, 90)} — missing path(s): ${paths.join(", ")}`];
2203
2273
  });
2204
2274
  }
2205
- /**
2206
- * Strict-dedup LESSON.md bullets (keep newest, its `[TRIGGERS …]` line preserved —
2207
- * or carried over from the dropped twin if the kept one lacks it) and report
2208
- * cap/stale. `content` unchanged unless a dedup occurred. Returns content + report.
2209
- */
2210
- function curateLessons(content, now, root = process.cwd()) {
2211
- const { preamble, blocks } = parse(content);
2275
+ /** Strict-dedup: keep the newest of each near-identical pair (TRIGGERS carried over). Returns kept blocks + merge report lines. */
2276
+ function dedup(blocks) {
2212
2277
  const kept = [];
2213
2278
  const fused = [];
2214
2279
  for (const b of blocks) {
@@ -2223,20 +2288,76 @@ function curateLessons(content, now, root = process.cwd()) {
2223
2288
  const t = drop.raw.find((l) => TRIG.test(l.trim()));
2224
2289
  if (t) win.raw.push(t);
2225
2290
  }
2226
- fused.push(`fusion: gardé ${(win.raw[0] ?? "").slice(0, 60)} · retiré ${(drop.raw[0] ?? "").slice(0, 60)}`);
2291
+ fused.push(`merged: kept ${(win.raw[0] ?? "").slice(0, 60)} · dropped ${(drop.raw[0] ?? "").slice(0, 60)}`);
2227
2292
  }
2228
- const old = [...kept].sort((a, b) => a.ts - b.ts).slice(0, Math.max(0, kept.length - CAP));
2229
- const cap = old.length ? [`${kept.length} bullets (> ${CAP}) — plus anciens candidats à l'archivage:`, ...old.map((b) => ` ${(b.raw[0] ?? "").slice(0, 80)}`)] : [];
2293
+ return {
2294
+ kept,
2295
+ fused
2296
+ };
2297
+ }
2298
+ /**
2299
+ * Dedup LESSON.md bullets, then archive the oldest excess over CAP (via
2300
+ * lesson-archive). `content` is byte-identical to the input when nothing is
2301
+ * deduped or archived. The `archive` block (possibly "") is what the caller must
2302
+ * PREPEND to LESSON-archive.md, archive-first, before writing `content`.
2303
+ * @param content - Raw LESSON.md text.
2304
+ * @param now - Clock (ms) for stale/archival windows.
2305
+ * @param root - Project root, for resolving cited paths in the stale report.
2306
+ * @returns The rewritten content, the archive block, and the report.
2307
+ */
2308
+ function curateLessons(content, now, root = process.cwd()) {
2309
+ const { preamble, blocks } = parse(content);
2310
+ const { kept, fused } = dedup(blocks);
2311
+ const { keep, archive } = splitAtCap(kept, now);
2312
+ const rebuilt = fused.length > 0 || archive.length > 0 ? `${preamble}\n${keep.map((b) => b.raw.join("\n")).join("\n\n")}\n` : content;
2313
+ const capReport = archive.length ? [`${kept.length} bullets (> 50) — ${archive.length} oldest archived → LESSON-archive.md`] : [];
2230
2314
  const report = [
2231
2315
  ...fused,
2232
- ...cap,
2316
+ ...capReport,
2233
2317
  ...staleReport(blocks, now, root)
2234
2318
  ].join("\n");
2235
2319
  return {
2236
- content: fused.length ? `${preamble}\n${kept.map((b) => b.raw.join("\n")).join("\n\n")}\n` : content,
2320
+ content: rebuilt,
2321
+ archive: formatArchive(archive, now),
2237
2322
  report
2238
2323
  };
2239
2324
  }
2325
+ /** The `[YYYY-MM-DD HH:MM]` (or date-only) stamp of a bullet, "" if absent. */
2326
+ function stamp(block) {
2327
+ return (block.raw[0] ?? "").match(/\[(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)\]/)?.[1] ?? "";
2328
+ }
2329
+ /** Bullet text: raw lines joined, leading "- ", date stamp & TRIGGERS lines stripped. */
2330
+ function bodyText(block) {
2331
+ return block.raw.filter((l) => !/^\s*\[TRIGGERS\s/.test(l)).join(" ").replace(/^-\s*/, "").replace(/\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*/, "").trim();
2332
+ }
2333
+ /** First sentence of `s` (split on a period + whitespace), whole string if none. */
2334
+ function firstSentence(s) {
2335
+ return (s.split(/(?<=\.)\s/)[0] ?? s).trim();
2336
+ }
2337
+ /** Collapse one older bullet to `- [date] <rule>`: first sentence after last "→", capped. */
2338
+ function compressBullet(block) {
2339
+ const text = bodyText(block);
2340
+ const arrow = text.lastIndexOf("→");
2341
+ let rule = firstSentence((arrow >= 0 ? text.slice(arrow + 1) : text).trim());
2342
+ if (rule.length > 200) rule = `${rule.slice(0, 199).trimEnd()}…`;
2343
+ const date = stamp(block);
2344
+ return `- ${date ? `[${date}] ` : ""}${rule}`;
2345
+ }
2346
+ /**
2347
+ * Build the compressed injection body for `content`. The preamble comments are
2348
+ * dropped (format docs, noise for the reader); the `recentFull` newest bullets
2349
+ * stay whole, every older bullet becomes one distilled rule-line.
2350
+ * @param content - Raw LESSON.md text.
2351
+ * @param recentFull - Count of newest bullets to keep verbatim.
2352
+ * @returns The compressed block (bullets only), or the trimmed content when there are no bullets.
2353
+ */
2354
+ function compressInjection(content, recentFull = 10) {
2355
+ const { blocks } = parse(content);
2356
+ if (blocks.length === 0) return content.trim();
2357
+ const full = blocks.slice(0, recentFull).map((b) => b.raw.join("\n"));
2358
+ const rest = blocks.slice(recentFull).map(compressBullet);
2359
+ return [...full, ...rest].join("\n");
2360
+ }
2240
2361
  //#endregion
2241
2362
  //#region src/runtime/lifecycle/lessons/state.ts
2242
2363
  /**
@@ -2250,40 +2371,116 @@ function curateLessons(content, now, root = process.cwd()) {
2250
2371
  function lessonsFileFor(root) {
2251
2372
  return join(root, "MEMORY", "LESSON.md");
2252
2373
  }
2374
+ /** Absolute `<root>/MEMORY/LESSON-archive.md` — cold storage for capped-out bullets. */
2375
+ function lessonsArchiveFileFor(root) {
2376
+ return join(root, "MEMORY", "LESSON-archive.md");
2377
+ }
2253
2378
  /** Absolute `<root>/MEMORY/state.json` — machine-local throttle counter. */
2254
2379
  function lessonsStateFileFor(root) {
2255
2380
  return join(root, "MEMORY", "state.json");
2256
2381
  }
2257
2382
  //#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 = "";
2383
+ //#region src/memory/session-roots.ts
2384
+ /**
2385
+ * Session-scoped lessons roots registry. The flat {@link module:memory/registry}
2386
+ * keeps ONE global list of pending roots — correct mono-session, but wrong with
2387
+ * several concurrent Claude Code sessions: at Stop, one session lists (and, by
2388
+ * bumping the throttle, STEALS) another session's pending lesson on a project it
2389
+ * never touched. This registry keys "which project got code edits, and was its
2390
+ * Stop reminder already fired" by `session_id`, so each Stop sees and consumes
2391
+ * ONLY its own roots. Stored at `$HOME/.fuse-harness/cache/lessons/session-roots.json`;
2392
+ * non-fatal on any I/O failure (a missed reminder never blocks a session).
2393
+ */
2394
+ /** Registry path (rel. home) + stale-bucket purge horizon (bounds growth). */
2395
+ const SUBPATH = ".fuse-harness/cache/lessons/session-roots.json";
2396
+ const PURGE_MS = 10080 * 60 * 1e3;
2397
+ /** Absolute registry path, or null when home is unusable. */
2398
+ function file(home) {
2399
+ const h = home?.trim();
2400
+ return h && h.startsWith("/") ? `${h}/${SUBPATH}` : null;
2401
+ }
2402
+ /** Read the registry; missing/corrupt/legacy (array) shapes collapse to `{}`. */
2403
+ function read(home) {
2404
+ const f = file(home);
2405
+ if (!f) return {};
2271
2406
  try {
2272
- content = readFileSync(file, "utf-8").trim();
2407
+ const parsed = JSON.parse(readFileSync(f, "utf8"));
2408
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2273
2409
  } catch {
2274
- return "";
2410
+ return {};
2275
2411
  }
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;
2412
+ }
2413
+ /** Purge stale buckets, then atomically persist (unique tmp + rename). Non-throwing. */
2414
+ function write(home, reg, now) {
2415
+ const f = file(home);
2416
+ if (!f) return;
2417
+ for (const [sid, entry] of Object.entries(reg)) if (!entry || now - (entry.updatedAt ?? 0) > PURGE_MS) delete reg[sid];
2418
+ try {
2419
+ mkdirSync(dirname(f), { recursive: true });
2420
+ atomicWrite(f, JSON.stringify(reg));
2281
2421
  } 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
2422
  }
2285
- /** Select roots with unsaved code edits past the throttle, bumping their state. */
2286
- function collectPending(now, window) {
2423
+ /** Record `field` for `(sid, root)`, refreshing the purge cursor. `home` defaults to `$HOME`. */
2424
+ function markSessionRoot(sid, root, field, value, home = process.env.HOME) {
2425
+ const reg = read(home);
2426
+ const prev = reg[sid];
2427
+ const entry = prev && typeof prev.roots === "object" && prev.roots !== null ? prev : {
2428
+ updatedAt: value,
2429
+ roots: {}
2430
+ };
2431
+ const mark = entry.roots[root] ?? {
2432
+ editedAt: 0,
2433
+ remindedAt: 0
2434
+ };
2435
+ entry.roots[root] = {
2436
+ ...mark,
2437
+ [field]: value
2438
+ };
2439
+ entry.updatedAt = value;
2440
+ reg[sid] = entry;
2441
+ write(home, reg, value);
2442
+ }
2443
+ /**
2444
+ * Roots of `sid` with an unsaved code edit past the `window`; each returned
2445
+ * root's `remindedAt` is bumped to `now` so the reminder fires at most once per
2446
+ * window and is consumed ONLY by this session. `home` defaults to `$HOME`.
2447
+ */
2448
+ function collectSessionPending(sid, now, window, home = process.env.HOME) {
2449
+ const reg = read(home);
2450
+ const entry = reg[sid];
2451
+ if (!entry || typeof entry.roots !== "object" || entry.roots === null) return [];
2452
+ const pending = [];
2453
+ for (const [root, mark] of Object.entries(entry.roots)) {
2454
+ if (mark.editedAt <= mark.remindedAt) continue;
2455
+ if (now - mark.remindedAt < window) continue;
2456
+ pending.push(root);
2457
+ entry.roots[root] = {
2458
+ ...mark,
2459
+ remindedAt: now
2460
+ };
2461
+ }
2462
+ if (pending.length > 0) write(home, reg, now);
2463
+ return pending;
2464
+ }
2465
+ //#endregion
2466
+ //#region src/runtime/lifecycle/lessons/reminder.ts
2467
+ /**
2468
+ * fuse-lessons write-mark + Stop-reminder, scoped by `session_id` when present.
2469
+ *
2470
+ * WITH a session id (normal Claude Code): each `(session, root)` pair carries
2471
+ * its own edit/reminder throttle in {@link module:memory/session-roots}, so a
2472
+ * Stop lists and silences ONLY the roots THAT session edited — concurrent
2473
+ * sessions on different projects never cross-remind nor steal each other's
2474
+ * throttle. WITHOUT a usable session id (a harness that omits it, or the legacy
2475
+ * on-disk state) it falls back to the original mono-session behavior: the global
2476
+ * flat root registry + the per-project `MEMORY/state.json` throttle.
2477
+ */
2478
+ /** Sanitized session id from a raw hook payload, or null (→ legacy fallback). */
2479
+ function sessionOf(payload) {
2480
+ return sanitizeSessionId(payload.session_id);
2481
+ }
2482
+ /** Legacy (no session id): pending roots across the global flat registry. */
2483
+ function collectLegacyPending(now, window) {
2287
2484
  const pending = [];
2288
2485
  for (const root of readRoots()) {
2289
2486
  const stateFile = lessonsStateFileFor(root);
@@ -2295,26 +2492,99 @@ function collectPending(now, window) {
2295
2492
  }
2296
2493
  return pending;
2297
2494
  }
2298
- /** Stop: emit one reminder covering every project with pending lessons. */
2299
- function remindWrite(now) {
2300
- const pending = collectPending(now, throttleMs());
2495
+ /** Stop reminder body listing each pending project's lessons file. */
2496
+ function reminderText(pending) {
2497
+ 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")}`;
2498
+ }
2499
+ /**
2500
+ * Stop: emit one reminder covering the stopping session's pending projects.
2501
+ * @param payload - Raw hook payload (`session_id` selects the scoped path).
2502
+ * @param now - Clock.
2503
+ * @returns Native Stop stdout, or "" when nothing is pending.
2504
+ */
2505
+ function remindWrite(payload, now) {
2506
+ const window = throttleMs();
2507
+ const sid = sessionOf(payload);
2508
+ const pending = sid ? collectSessionPending(sid, now, window) : collectLegacyPending(now, window);
2301
2509
  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")}`);
2510
+ return contextResponse("Stop", reminderText(pending));
2303
2511
  }
2304
- /** PostToolUse: record the relevant throttle timestamp for the edited file. */
2512
+ /**
2513
+ * PostToolUse: record the edit against the throttle. A code file arms the
2514
+ * reminder; writing `MEMORY/LESSON.md` silences it (the lesson was just saved).
2515
+ * Session-scoped when `session_id` is present, else the legacy global path.
2516
+ * @param payload - Raw hook payload (`tool_input.file_path`, `session_id`).
2517
+ * @param now - Clock.
2518
+ */
2305
2519
  function markWrite(payload, now) {
2306
2520
  const input = payload.tool_input;
2307
2521
  if (!input?.file_path) return;
2308
2522
  const abs = resolve(input.file_path);
2309
2523
  const root = projectRootOrNull(dirname(abs));
2310
2524
  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);
2525
+ const isLesson = abs === resolve(root, "MEMORY", "LESSON.md");
2526
+ if (!isLesson && !isCodeFile(abs)) return;
2527
+ const sid = sessionOf(payload);
2528
+ if (sid) markSessionRoot(sid, root, isLesson ? "remindedAt" : "editedAt", now);
2529
+ else if (isLesson) setStateField(lessonsStateFileFor(root), "lastRemindedAt", now);
2530
+ else {
2531
+ setStateField(lessonsStateFileFor(root), "lastCodeEditAt", now);
2315
2532
  addRoot(root);
2316
2533
  }
2317
2534
  }
2535
+ //#endregion
2536
+ //#region src/runtime/lifecycle/lessons/dispatch.ts
2537
+ /**
2538
+ * fuse-lessons scope dispatch (TS port of the 4 handler scripts). Routes by
2539
+ * event: SessionStart/SubagentStart inject `MEMORY/LESSON.md`; Stop reminds the
2540
+ * stopping session about ITS OWN projects with unsaved code edits; PostToolUse
2541
+ * marks the write to arm/silence the throttle. The reminder + mark logic (incl.
2542
+ * the per-`session_id` scoping that fixes the multi-session misdirection) lives
2543
+ * in {@link module:runtime/lifecycle/lessons/reminder}; this module keeps the
2544
+ * event router + lesson-file injection. Non-fatal by design.
2545
+ */
2546
+ /**
2547
+ * Persist a curation ATOMICALLY and ARCHIVE-FIRST for zero-loss: prepend the
2548
+ * moved bullets to LESSON-archive.md, THEN rewrite LESSON.md. On ANY write error
2549
+ * the original file is left untouched (returns `original`) so a bullet is never
2550
+ * lost — a rare archive-then-trim-fail leaves a duplicate (never a loss), which
2551
+ * the next dedup pass reconciles.
2552
+ */
2553
+ function persistCuration(file, root, curated, archive, original) {
2554
+ try {
2555
+ if (archive) {
2556
+ const af = lessonsArchiveFileFor(root);
2557
+ const prev = existsSync(af) ? readFileSync(af, "utf-8") : "";
2558
+ atomicWrite(af, prev ? `${archive}\n${prev}` : archive);
2559
+ }
2560
+ atomicWrite(file, curated);
2561
+ return curated;
2562
+ } catch {
2563
+ return original;
2564
+ }
2565
+ }
2566
+ /**
2567
+ * Inject `MEMORY/LESSON.md` for `event`. Mechanical curation (dedup + cap→archive)
2568
+ * rewrites the FILE; the injected BLOCK is then COMPRESSED (newest bullets whole,
2569
+ * older ones distilled to their rule) so a growing file never inflates the
2570
+ * SessionStart/SubagentStart context. Any curation report surfaces via systemMessage.
2571
+ */
2572
+ function injectMemory(cwd, event, now) {
2573
+ const root = projectRoot(cwd);
2574
+ const file = lessonsFileFor(root);
2575
+ if (!existsSync(file)) return "";
2576
+ let content = "";
2577
+ try {
2578
+ content = readFileSync(file, "utf-8").trim();
2579
+ } catch {
2580
+ return "";
2581
+ }
2582
+ if (!content) return "";
2583
+ const { content: curated, archive, report } = curateLessons(content, now, root);
2584
+ if (curated !== content) content = persistCuration(file, root, curated, archive, content);
2585
+ const ctx = `Project lessons — never reproduce these:\n${compressInjection(content)}\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.`;
2586
+ return report ? attachSystemMessage(contextResponse(event, ctx), `LESSON.md curation:\n${report}`) : contextResponse(event, ctx);
2587
+ }
2318
2588
  /**
2319
2589
  * Route a fuse-lessons event to its handler. Returns the native stdout for
2320
2590
  * context-injecting events (SessionStart/SubagentStart/Stop) or "" for the
@@ -2329,7 +2599,7 @@ function dispatchLessons(event, payload, cwd, now) {
2329
2599
  switch (event) {
2330
2600
  case "SessionStart":
2331
2601
  case "SubagentStart": return injectMemory(cwd, event, now);
2332
- case "Stop": return remindWrite(now);
2602
+ case "Stop": return remindWrite(payload, now);
2333
2603
  case "PostToolUse":
2334
2604
  markWrite(payload, now);
2335
2605
  return "";
@@ -2337,6 +2607,559 @@ function dispatchLessons(event, payload, cwd, now) {
2337
2607
  }
2338
2608
  }
2339
2609
  //#endregion
2610
+ //#region src/policy/deny-loop.ts
2611
+ /**
2612
+ * @module deny-loop
2613
+ * Pure anti-loop logic: hash a tool-call, decide if it repeats a prior deny, and
2614
+ * enrich the repeated block's message.
2615
+ *
2616
+ * The proprietary rule "NEVER propose the same fix twice" is prose a model under
2617
+ * pressure ignores. This makes it machine-enforced: when a call whose
2618
+ * `(tool + normalized input)` hash was ALREADY denied in-window is retried, the
2619
+ * harness keeps the deny but rewrites the message — `[REPEAT]` title, STOP
2620
+ * prefix, forced `research-expert` action. State + wiring live in the sidecar
2621
+ * store ({@link module:deny-loop-store}); this file is IO-free and pure.
2622
+ * @packageDocumentation
2623
+ */
2624
+ /** Stable JSON: keys sorted at every depth so `{a,b}` and `{b,a}` hash identically. */
2625
+ function stableStringify(v) {
2626
+ if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
2627
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
2628
+ const o = v;
2629
+ return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(",")}}`;
2630
+ }
2631
+ /**
2632
+ * Stable identity hash of a tool-call = tool name + normalized (key-sorted) input,
2633
+ * so re-ordered keys never mask a repeat.
2634
+ * @param tool - Tool name (e.g. "Write", "Bash").
2635
+ * @param input - Identifying tool input (filePath/content/command...).
2636
+ * @returns 8-char hex hash.
2637
+ */
2638
+ function denyHash(tool, input) {
2639
+ return hashText(`${tool}\n${stableStringify(input)}`);
2640
+ }
2641
+ /**
2642
+ * Pure loop check: given the already-pruned in-window map, compute the running
2643
+ * count for `hash` and whether it repeats (count > 1). No IO — the caller persists.
2644
+ * @param hash - {@link denyHash} of the current call.
2645
+ * @param priorDenies - The `{ hash -> DenyEntry }` map, pruned to `now`/`windowMs`.
2646
+ * @param opts - Clock + window.
2647
+ * @returns `{ isRepeat, count, hash }`.
2648
+ */
2649
+ function denyLoopCheck(hash, priorDenies, opts) {
2650
+ const prev = priorDenies[hash];
2651
+ const count = (prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs ? prev.count : 0) + 1;
2652
+ return {
2653
+ isRepeat: count > 1,
2654
+ count,
2655
+ hash
2656
+ };
2657
+ }
2658
+ /**
2659
+ * Enrich a REPEATED block prompt — a NEW object, never a mutation (the input may
2660
+ * be a shared const like FAIL_CLOSED). The decision stays `block`; only the
2661
+ * message changes, so every harness renders it through the same adapter.
2662
+ * @param prompt - The original block prompt.
2663
+ * @param count - The running identical-deny count (n).
2664
+ * @returns A block prompt with `[REPEAT]` title, STOP-prefixed reason, forced research action.
2665
+ */
2666
+ function enrichRepeatDeny(prompt, count) {
2667
+ const stop = `Identical attempt #${count} already denied for the same reason. STOP: do not retry this same call. `;
2668
+ const action = "Launch fuse-ai-pilot:research-expert to find a DIFFERENT approach";
2669
+ return {
2670
+ ...prompt,
2671
+ title: prompt.title.startsWith("[REPEAT]") ? prompt.title : `[REPEAT] ${prompt.title}`,
2672
+ reason: stop + prompt.reason,
2673
+ actions: [action, ...prompt.actions ?? []]
2674
+ };
2675
+ }
2676
+ //#endregion
2677
+ //#region src/tracking/one-shot-store.ts
2678
+ /** A fresh, empty state — always spread (`{ ...EMPTY }`) so the const is never shared. */
2679
+ const EMPTY = {
2680
+ gates: {},
2681
+ firstTry: 0,
2682
+ corrected: 0,
2683
+ pending: {},
2684
+ updatedAt: 0
2685
+ };
2686
+ /**
2687
+ * Drop stale data: whole-state idle reset past the window, else per-entry prune of
2688
+ * gates/pending older than `windowMs`. Keeps the "7d" window honest, bounds size.
2689
+ */
2690
+ function pruneState(s, now, windowMs) {
2691
+ if (now - s.updatedAt >= windowMs) return { ...EMPTY };
2692
+ const gates = {};
2693
+ for (const [k, g] of Object.entries(s.gates)) if (now - g.lastTs < windowMs) gates[k] = g;
2694
+ const pending = {};
2695
+ for (const [k, p] of Object.entries(s.pending)) if (now - p.ts < windowMs) pending[k] = p;
2696
+ return {
2697
+ ...s,
2698
+ gates,
2699
+ pending
2700
+ };
2701
+ }
2702
+ /**
2703
+ * Record a deny for gate `title` on operation `op` (content-free tool identity):
2704
+ * bump the gate's deny count and mark `op` pending for a later fix.
2705
+ */
2706
+ function applyDeny(s, title, op, now) {
2707
+ const g = s.gates[title] ?? {
2708
+ denies: 0,
2709
+ corrected: 0,
2710
+ lastTs: 0
2711
+ };
2712
+ return {
2713
+ ...s,
2714
+ gates: {
2715
+ ...s.gates,
2716
+ [title]: {
2717
+ denies: g.denies + 1,
2718
+ corrected: g.corrected,
2719
+ lastTs: now
2720
+ }
2721
+ },
2722
+ pending: {
2723
+ ...s.pending,
2724
+ [op]: {
2725
+ title,
2726
+ ts: now
2727
+ }
2728
+ },
2729
+ updatedAt: now
2730
+ };
2731
+ }
2732
+ /**
2733
+ * Record an allow for a gateable `op`. A non-gateable allow (Read/Task/MCP) leaves
2734
+ * state untouched — it never counts and never clears a pending deny. Otherwise: a
2735
+ * pending deny → `corrected` (a fix, credited to the blocking gate); no pending →
2736
+ * `firstTry` (one-shot).
2737
+ */
2738
+ function applyAllow(s, op, now, gateable) {
2739
+ if (!gateable) return s;
2740
+ const pend = s.pending[op];
2741
+ if (pend) {
2742
+ const g = s.gates[pend.title] ?? {
2743
+ denies: 0,
2744
+ corrected: 0,
2745
+ lastTs: 0
2746
+ };
2747
+ const { [op]: _drop, ...pending } = s.pending;
2748
+ return {
2749
+ ...s,
2750
+ gates: {
2751
+ ...s.gates,
2752
+ [pend.title]: {
2753
+ ...g,
2754
+ corrected: g.corrected + 1,
2755
+ lastTs: now
2756
+ }
2757
+ },
2758
+ corrected: s.corrected + 1,
2759
+ pending,
2760
+ updatedAt: now
2761
+ };
2762
+ }
2763
+ return gateable ? {
2764
+ ...s,
2765
+ firstTry: s.firstTry + 1,
2766
+ updatedAt: now
2767
+ } : s;
2768
+ }
2769
+ /**
2770
+ * Compact injectable summary (one line); "" when there is nothing to report.
2771
+ * @returns e.g. `gates 7d: 88% one-shot (44/50 clean); SOLID file-size limit 4den/3fix`.
2772
+ */
2773
+ function formatSummary(s) {
2774
+ const keys = Object.keys(s.gates);
2775
+ const total = s.firstTry + s.corrected;
2776
+ if (keys.length === 0 && total === 0) return "";
2777
+ const head = total > 0 ? `${Math.round(s.firstTry / total * 100)}% one-shot (${s.firstTry}/${total} clean)` : "no clean pass yet";
2778
+ const parts = keys.map((k) => ({
2779
+ k,
2780
+ g: s.gates[k]
2781
+ })).sort((a, b) => b.g.denies - a.g.denies).map(({ k, g }) => `${k} ${g.denies}den/${g.corrected}fix`);
2782
+ return `gates 7d: ${head}${parts.length ? `; ${parts.join("; ")}` : ""}`;
2783
+ }
2784
+ //#endregion
2785
+ //#region src/tracking/one-shot.ts
2786
+ /**
2787
+ * @module one-shot
2788
+ * Sidecar store + gate wiring for the per-gate one-shot metric.
2789
+ *
2790
+ * STATE — a standalone sidecar (`one-shot.json`) in the same per-project state dir
2791
+ * as the session track, mirroring {@link module:deny-loop-store} (atomicWrite,
2792
+ * prune-by-window, fail-safe). A write error NEVER changes a gate decision nor its
2793
+ * prompt — metrics are pure observation.
2794
+ *
2795
+ * KEY — the operation identity is content-FREE (`tool + filePath/command`): a fix
2796
+ * changes the content, so a content hash would make every retry a new op and hide
2797
+ * the deny→allow transition this metric exists to see. The pure model lives in
2798
+ * {@link module:one-shot-store}; this file is the only IO surface.
2799
+ * @packageDocumentation
2800
+ */
2801
+ /** Sidecar basename under the per-project state dir. */
2802
+ const SIDECAR$1 = "one-shot.json";
2803
+ /** Retention window: 7 days. Aggregates and pending denies older than this are pruned. */
2804
+ const WINDOW_MS = 10080 * 60 * 1e3;
2805
+ /** Load the state, or a fresh copy when missing/corrupt. */
2806
+ function loadState(path) {
2807
+ try {
2808
+ if (!existsSync(path)) return { ...EMPTY };
2809
+ const d = JSON.parse(readFileSync(path, "utf8"));
2810
+ return d && typeof d === "object" && !Array.isArray(d) ? {
2811
+ ...EMPTY,
2812
+ ...d
2813
+ } : { ...EMPTY };
2814
+ } catch {
2815
+ return { ...EMPTY };
2816
+ }
2817
+ }
2818
+ /**
2819
+ * Record a gate outcome: a `block` is a deny for its gate title; a `null` allow is
2820
+ * a fix (if the op was pending) or a one-shot (if gateable). `ask`/`inform` are
2821
+ * neither and are skipped. Fails silently — a metric write NEVER affects a decision.
2822
+ *
2823
+ * The op key is tool-INDEPENDENT (`filePath`/`command` only, constant `"op"` tool):
2824
+ * a deny (a `Write`) and its fix (an `Edit`) on the same file must link.
2825
+ * @param prompt - The gate's outcome (block, allow=null, or ask/inform).
2826
+ * @param input - Identifying tool input (content decides gateability only).
2827
+ * @param opts - Clock + state dir.
2828
+ */
2829
+ function recordOneShot(prompt, input, opts) {
2830
+ try {
2831
+ if (prompt && prompt.kind !== "block") return;
2832
+ const path = join(opts.dir, SIDECAR$1);
2833
+ let s = pruneState(loadState(path), opts.now, WINDOW_MS);
2834
+ const op = denyHash("op", {
2835
+ filePath: input.filePath,
2836
+ command: input.command
2837
+ });
2838
+ s = prompt ? applyDeny(s, prompt.title, op, opts.now) : applyAllow(s, op, opts.now, input.content != null || input.command != null);
2839
+ atomicWrite(path, JSON.stringify(s));
2840
+ } catch {}
2841
+ }
2842
+ /**
2843
+ * Compact, injection-ready one-shot summary for the project rooted at `cwd`. The
2844
+ * state dir is derived EXACTLY like the runtime writer ({@link defaultStateDir},
2845
+ * mirroring `handle.ts` `trackFile(sid, defaultStateDir(cwd))`), so the file read
2846
+ * here is the same one {@link recordOneShot} wrote. "" when no data or read error.
2847
+ * @param cwd - The project working directory (Claude `cwd`), NOT the state dir.
2848
+ * @returns One line, e.g. `gates 7d: 88% one-shot (44/50 clean); ...`, or "".
2849
+ */
2850
+ function oneShotSummary(cwd) {
2851
+ try {
2852
+ return formatSummary(pruneState(loadState(join(defaultStateDir(cwd), SIDECAR$1)), Date.now(), WINDOW_MS));
2853
+ } catch {
2854
+ return "";
2855
+ }
2856
+ }
2857
+ //#endregion
2858
+ //#region src/runtime/lifecycle/snapshot/git.ts
2859
+ /**
2860
+ * Run a git subcommand at `root` with a short timeout, returning trimmed stdout.
2861
+ * Uses `node:child_process` (the Bun shell can hang on some git plumbing) and
2862
+ * swallows every failure — a non-repo, missing git, or timeout yields `""` so
2863
+ * the caller omits the section instead of throwing inside the hook.
2864
+ * @param root - Directory to run git in.
2865
+ * @param args - The git args (e.g. `"log --oneline -3"`).
2866
+ * @returns Trimmed stdout, or `""` on any error.
2867
+ */
2868
+ function git(root, args) {
2869
+ try {
2870
+ return execSync(`git ${args}`, {
2871
+ cwd: root,
2872
+ encoding: "utf8",
2873
+ timeout: 150,
2874
+ stdio: [
2875
+ "ignore",
2876
+ "pipe",
2877
+ "ignore"
2878
+ ]
2879
+ }).trim();
2880
+ } catch {
2881
+ return "";
2882
+ }
2883
+ }
2884
+ /** Count staged/unstaged/untracked files from porcelain v1 output (skips the `##` branch line). */
2885
+ function countWip(porcelain) {
2886
+ const w = {
2887
+ staged: 0,
2888
+ unstaged: 0,
2889
+ untracked: 0
2890
+ };
2891
+ for (const line of porcelain.split("\n")) {
2892
+ if (!line || line.startsWith("#")) continue;
2893
+ if (line.startsWith("??")) {
2894
+ w.untracked++;
2895
+ continue;
2896
+ }
2897
+ const x = line[0], y = line[1];
2898
+ if (x && x !== " " && x !== "?") w.staged++;
2899
+ if (y === "M" || y === "D") w.unstaged++;
2900
+ }
2901
+ return w;
2902
+ }
2903
+ /** Parse the current branch from the leading `## branch...upstream` porcelain line. */
2904
+ function parseBranch(porcelain) {
2905
+ const head = porcelain.split("\n")[0] ?? "";
2906
+ if (!head.startsWith("## ")) return "";
2907
+ const rest = head.slice(3);
2908
+ const dots = rest.indexOf("...");
2909
+ return (dots >= 0 ? rest.slice(0, dots) : rest).split(" ")[0] ?? "";
2910
+ }
2911
+ /**
2912
+ * Collect a compact git reconciliation section for `root`: current branch, the
2913
+ * last three commits (oneline), and staged/unstaged/untracked WIP counts. When
2914
+ * `root` is not a git repo (status fails) the whole section is omitted (`""`).
2915
+ * @param root - The project/repo root.
2916
+ * @returns The rendered git section body, or `""` when not a repo.
2917
+ */
2918
+ function collectGit(root) {
2919
+ const status = git(root, "status --porcelain=v1 --branch");
2920
+ if (!status) return "";
2921
+ const branch = parseBranch(status) || "(unknown)";
2922
+ const w = countWip(status);
2923
+ const log = git(root, "log --oneline -3");
2924
+ const lines = [`- branch: ${branch}`];
2925
+ if (log) lines.push("- recent:", ...log.split("\n").map((l) => ` ${l}`));
2926
+ lines.push(`- WIP: ${w.staged} staged, ${w.unstaged} unstaged, ${w.untracked} untracked`);
2927
+ return lines.join("\n");
2928
+ }
2929
+ //#endregion
2930
+ //#region src/cli/doctor.ts
2931
+ /**
2932
+ * `harness doctor` — diagnose which `@fusengine/harness` is actually running.
2933
+ *
2934
+ * A confirmed, still-open bun bug (oven-sh/bun #5791; scoped-pkg behaviour
2935
+ * reinforced by #32019/#32150) makes `bunx <pkg>` (unpinned) prefer a stale
2936
+ * GLOBAL install over npm-latest, so a consumer can silently run an old harness
2937
+ * after a publish. This command surfaces the truth: the resolved version +
2938
+ * package path of the code executing right now, the runtime binary, and the
2939
+ * latest version published on npm. It queries the registry over HTTP (not
2940
+ * `npm view`, whose exit code is 0 even on an empty result — npm/cli#6408) and
2941
+ * never throws: an offline environment yields `latest: null`, never a crash.
2942
+ */
2943
+ const PKG = "@fusengine/harness";
2944
+ /** Walk up from `startDir` for the `@fusengine/harness` `package.json`. */
2945
+ function findPackage(startDir) {
2946
+ let dir = startDir;
2947
+ for (let depth = 0; depth < 6; depth++) {
2948
+ try {
2949
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
2950
+ if (pkg.name === PKG) return {
2951
+ version: pkg.version ?? "unknown",
2952
+ path: dir
2953
+ };
2954
+ } catch {}
2955
+ const parent = dirname(dir);
2956
+ if (parent === dir) break;
2957
+ dir = parent;
2958
+ }
2959
+ return null;
2960
+ }
2961
+ /** Resolve the running version + package path (no network), from a module URL. */
2962
+ function runningVersion(moduleUrl) {
2963
+ const found = findPackage(dirname(fileURLToPath(moduleUrl)));
2964
+ return {
2965
+ version: found?.version ?? "unknown",
2966
+ path: found?.path ?? "unknown"
2967
+ };
2968
+ }
2969
+ /** One-line `pkg vX.Y.Z` banner (stderr, only on explicit `--version`/`doctor` commands — never on `hook`, to avoid spamming automated invocations). */
2970
+ function versionBanner(moduleUrl) {
2971
+ return `${PKG} v${runningVersion(moduleUrl).version}`;
2972
+ }
2973
+ /** Latest published version via the npm registry HTTP API. `null` on any failure. */
2974
+ async function npmLatest() {
2975
+ try {
2976
+ const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(8e3) });
2977
+ if (!res.ok) return null;
2978
+ return (await res.json()).version ?? null;
2979
+ } catch {
2980
+ return null;
2981
+ }
2982
+ }
2983
+ /** Build the full diagnostic report for the module at `moduleUrl`. */
2984
+ async function buildDoctorReport(moduleUrl) {
2985
+ const { version, path } = runningVersion(moduleUrl);
2986
+ const latest = await npmLatest();
2987
+ return {
2988
+ running: version,
2989
+ packagePath: path,
2990
+ runtime: process.execPath,
2991
+ latest,
2992
+ stale: latest !== null && latest !== version
2993
+ };
2994
+ }
2995
+ /** Render a {@link DoctorReport} as human-readable stdout text. */
2996
+ function formatDoctor(r) {
2997
+ const lines = [
2998
+ `${PKG} doctor`,
2999
+ ` running: ${r.running}`,
3000
+ ` package: ${r.packagePath}`,
3001
+ ` runtime: ${r.runtime}`,
3002
+ ` npm latest: ${r.latest ?? "(unavailable — offline or unreachable)"}`
3003
+ ];
3004
+ if (r.stale) lines.push(` ! stale — npm serves ${r.latest}. Pin "@fusengine/harness@${r.latest}" in hooks.json (see README).`);
3005
+ else if (r.latest !== null) lines.push(` ok — running the latest published version.`);
3006
+ return lines.join("\n");
3007
+ }
3008
+ /** Run `harness doctor`: print the diagnostic to stdout. Always resolves 0 (pure info). */
3009
+ async function runDoctor(moduleUrl) {
3010
+ process.stdout.write(formatDoctor(await buildDoctorReport(moduleUrl)) + "\n");
3011
+ return 0;
3012
+ }
3013
+ //#endregion
3014
+ //#region src/runtime/lifecycle/snapshot/version.ts
3015
+ /** Read the `version` field of `<root>/package.json`, or `""` if absent/unreadable. */
3016
+ function pkgVersion(root) {
3017
+ try {
3018
+ return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version ?? "";
3019
+ } catch {
3020
+ return "";
3021
+ }
3022
+ }
3023
+ /**
3024
+ * Collect the version reconciliation section: the harness version actually
3025
+ * running (resolved from {@link runningVersion}, no network) and, when `root`
3026
+ * carries its own `package.json`, whether that project's version has drifted
3027
+ * from the running harness.
3028
+ * @param root - The project root (cwd repo).
3029
+ * @param moduleUrl - `import.meta.url` of the calling module (locates the running package.json).
3030
+ * @returns The rendered version section body (never `""`).
3031
+ */
3032
+ function collectVersion(root, moduleUrl) {
3033
+ const running = runningVersion(moduleUrl).version;
3034
+ const lines = [`- harness running: v${running}`];
3035
+ const project = pkgVersion(root);
3036
+ if (project && project !== running) lines.push(`- project package.json: v${project} (DRIFT — running harness differs)`);
3037
+ else if (project) lines.push(`- project package.json: v${project} (in sync)`);
3038
+ return lines.join("\n");
3039
+ }
3040
+ //#endregion
3041
+ //#region src/runtime/lifecycle/snapshot/board.ts
3042
+ /** Max board characters injected — a persistent board should stay small; over-long boards are truncated. */
3043
+ const MAX_BOARD = 4e3;
3044
+ /**
3045
+ * Collect the persistent task board: the contents of `<root>/.claude/BOARD.md`
3046
+ * (truncated to {@link MAX_BOARD}) plus an instruction to keep it current. The
3047
+ * board lives on disk so it survives context purges — rehydrated every session.
3048
+ * Missing/empty/unreadable board → `""` (section omitted).
3049
+ * @param root - The project root.
3050
+ * @returns The rendered board section body, or `""` when there is no board.
3051
+ */
3052
+ function collectBoard(root) {
3053
+ const path = join(root, ".claude", "BOARD.md");
3054
+ try {
3055
+ if (!existsSync(path)) return "";
3056
+ let body = readFileSync(path, "utf8").trim();
3057
+ if (!body) return "";
3058
+ if (body.length > MAX_BOARD) body = `${body.slice(0, MAX_BOARD)}\n… (truncated)`;
3059
+ return `- .claude/BOARD.md (keep current — Write to it as tasks start/finish):\n\n${body}`;
3060
+ } catch {
3061
+ return "";
3062
+ }
3063
+ }
3064
+ //#endregion
3065
+ //#region src/runtime/lifecycle/snapshot/format.ts
3066
+ /**
3067
+ * Render the non-empty `sections` under one reconciliation heading. Empty
3068
+ * sections are dropped; when every section is empty the whole snapshot is `""`.
3069
+ * @param sections - The collected sections in display order.
3070
+ * @returns The assembled markdown block, or `""` when nothing to report.
3071
+ */
3072
+ function renderSections(sections) {
3073
+ const parts = sections.filter((s) => s.body.trim()).map((s) => `### ${s.title}\n${s.body.trim()}`);
3074
+ if (!parts.length) return "";
3075
+ return `# Reconciliation snapshot
3076
+ Real state of the world at session start — reconcile against this instead of re-discovering it.\n\n${parts.join("\n\n")}`;
3077
+ }
3078
+ /**
3079
+ * Concatenate `snapshot` onto an existing SessionStart stdout's
3080
+ * `additionalContext` — it never replaces prior injected context (CLAUDE.md,
3081
+ * dev-context). When `stdout` is empty a fresh {@link contextResponse} is made;
3082
+ * a non-empty but unparseable `stdout` is returned UNCHANGED (the snapshot is
3083
+ * dropped) — fabricating a fresh response there would discard the very CLAUDE.md
3084
+ * injection the invariant protects, so preserving prior context always wins.
3085
+ * @param stdout - The core SessionStart JSON stdout (may be `""`).
3086
+ * @param snapshot - The snapshot markdown to append (no-op when `""`).
3087
+ * @returns The merged hook stdout JSON.
3088
+ */
3089
+ function attachSnapshot(stdout, snapshot) {
3090
+ if (!snapshot) return stdout;
3091
+ if (!stdout) return contextResponse("SessionStart", snapshot);
3092
+ try {
3093
+ const parsed = JSON.parse(stdout);
3094
+ const prev = parsed.hookSpecificOutput?.additionalContext ?? "";
3095
+ const merged = prev ? `${prev}\n\n${snapshot}` : snapshot;
3096
+ return JSON.stringify({
3097
+ ...parsed,
3098
+ hookSpecificOutput: {
3099
+ ...parsed.hookSpecificOutput,
3100
+ hookEventName: "SessionStart",
3101
+ additionalContext: merged
3102
+ }
3103
+ });
3104
+ } catch {
3105
+ return stdout;
3106
+ }
3107
+ }
3108
+ //#endregion
3109
+ //#region src/runtime/lifecycle/snapshot/index.ts
3110
+ /** Run `fn`, swallowing any throw into `""` so no single collector can break the hook. */
3111
+ function safe(fn) {
3112
+ try {
3113
+ return fn();
3114
+ } catch {
3115
+ return "";
3116
+ }
3117
+ }
3118
+ /**
3119
+ * Build the reconciliation snapshot markdown for `cwd`: git state, running
3120
+ * harness version + drift, the persistent board, and one-shot gate status. Each
3121
+ * collector is isolated by {@link safe}; an all-empty result yields `""`.
3122
+ * @param cwd - The session working directory.
3123
+ * @param moduleUrl - `import.meta.url` of the caller (locates the running package).
3124
+ * @returns The snapshot markdown, or `""` when nothing to report.
3125
+ */
3126
+ function renderSnapshot(cwd, moduleUrl) {
3127
+ const root = projectRootOrNull(cwd) ?? cwd;
3128
+ return renderSections([
3129
+ {
3130
+ title: "Git",
3131
+ body: safe(() => collectGit(root))
3132
+ },
3133
+ {
3134
+ title: "Version",
3135
+ body: safe(() => collectVersion(root, moduleUrl))
3136
+ },
3137
+ {
3138
+ title: "Board",
3139
+ body: safe(() => collectBoard(root))
3140
+ },
3141
+ {
3142
+ title: "One-shot gates",
3143
+ body: safe(() => oneShotSummary(cwd))
3144
+ }
3145
+ ]);
3146
+ }
3147
+ /**
3148
+ * Concatenate the reconciliation snapshot onto a core SessionStart stdout. Fully
3149
+ * fail-safe: any error returns `stdout` unchanged so the hook never breaks.
3150
+ * @param stdout - The core SessionStart JSON stdout (may be `""`).
3151
+ * @param cwd - The session working directory.
3152
+ * @param moduleUrl - `import.meta.url` of the caller.
3153
+ * @returns The merged hook stdout.
3154
+ */
3155
+ function withSnapshot(stdout, cwd, moduleUrl) {
3156
+ try {
3157
+ return attachSnapshot(stdout, renderSnapshot(cwd, moduleUrl));
3158
+ } catch {
3159
+ return stdout;
3160
+ }
3161
+ }
3162
+ //#endregion
2340
3163
  //#region src/runtime/lifecycle/aipilot/inject-apex.ts
2341
3164
  /**
2342
3165
  * SubagentStart (matcher "") for the ai-pilot scope: inject APEX AGENTS.md +
@@ -3596,7 +4419,8 @@ function sessionStart(input) {
3596
4419
  if (input.scope === "rules") return injectRules(process.env.CLAUDE_PLUGIN_ROOT ?? input.cwd);
3597
4420
  if (input.scope === "carto") return cartoSessionStart(input.cwd, input.now);
3598
4421
  if (input.scope === "lessons") return dispatchLessons("SessionStart", input.payload, input.cwd, input.now);
3599
- return sessionStartCore(input.cwd, void 0, input.now);
4422
+ const core = sessionStartCore(input.cwd, void 0, input.now);
4423
+ return input.scope === "core" ? withSnapshot(core, input.cwd, import.meta.url) : core;
3600
4424
  }
3601
4425
  /**
3602
4426
  * Route a lifecycle/session/context hook event to its ported handler. Returns
@@ -4984,73 +5808,6 @@ async function apexScopedGate(input, track, window) {
4984
5808
  }
4985
5809
  }
4986
5810
  //#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
5811
  //#region src/runtime/deny-loop-store.ts
5055
5812
  /**
5056
5813
  * @module deny-loop-store
@@ -5138,18 +5895,25 @@ const DEFAULT_WINDOW_MS = 12e4;
5138
5895
  /** Trivial edits allowed within the window before the full APEX gates apply. */
5139
5896
  const TRIVIAL_BUDGET = 4;
5140
5897
  /**
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`).
5898
+ * Full gate: {@link runGates} yields the first blocking prompt (or null); the tail
5899
+ * records the one-shot metric ({@link recordOneShot}, observation-only) then lets
5900
+ * {@link withDenyLoop} rewrite an identical retried deny (decision unchanged).
5144
5901
  */
5145
5902
  async function gate(input) {
5146
- return withDenyLoop(await runGates(input), input.tool, {
5903
+ const prompt = await runGates(input);
5904
+ const op = {
5147
5905
  filePath: input.filePath,
5148
5906
  content: input.content,
5149
5907
  command: input.command
5150
- }, {
5908
+ };
5909
+ const dir = dirname(input.trackFile);
5910
+ recordOneShot(prompt, op, {
5911
+ now: input.now,
5912
+ dir
5913
+ });
5914
+ return withDenyLoop(prompt, input.tool, op, {
5151
5915
  now: input.now,
5152
- dir: dirname(input.trackFile),
5916
+ dir,
5153
5917
  windowMs: input.windowMs ?? 12e4
5154
5918
  });
5155
5919
  }
@@ -5276,7 +6040,7 @@ function docSourceOf$1(tool) {
5276
6040
  function cacheHitText(web, hit) {
5277
6041
  const kb = Math.floor(hit.body.length / 1024) + 1;
5278
6042
  const hours = Math.floor(hit.ageMs / 36e5);
5279
- return web ? `CACHE HIT WebFetch (~${kb}KB economise, cached il y a ${hours}h):\n\n${hit.body}\n\nPour forcer un nouveau fetch, modifie l'URL ou la query.` : `CACHE HIT (~${kb}KB economise, cached il y a ${hours}h): ${hit.body}\n\nReformule pour forcer un re-call.`;
6043
+ return web ? `CACHE HIT WebFetch (~${kb}KB saved, cached ${hours}h ago):\n\n${hit.body}\n\nModify the URL or query to force a fresh fetch.` : `CACHE HIT (~${kb}KB saved, cached ${hours}h ago): ${hit.body}\n\nRephrase to force a re-call.`;
5280
6044
  }
5281
6045
  /**
5282
6046
  * Pre-event MCP interception: serve a fresh cache hit (deny + cached content),
@@ -6420,4 +7184,4 @@ async function handleHook(id, payload, opts) {
6420
7184
  });
6421
7185
  }
6422
7186
  //#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 };
7187
+ export { postEditTypescript as $, trackSkillRead as A, trackFile as At, lessonsFileFor as B, seoPostToolUse as C, gitContext as Ct, postTrackingSideEffects as D, taskContext as Dt, securityAdvisory as E, promptSubmitContext as Et, runDoctor as F, securityStateDir as Ft, generateProjectMap as G, cartoSessionStart as H, runningVersion as I, securityStatePath as It, loadEnriched as J, isProject as K, versionBanner as L, todayUtc as Lt, dispatchLifecycle as M, isoUtc as Mt, aipilotPostToolUse as N, loadSecurityState as Nt, trackWatchResearch as O, defaultStateDir as Ot, dispatchAipilot as P, saveSecurityState as Pt, listChildren as Q, dispatchLessons as R, postEditContext as S, devContext as St, dispatchMemory as T, claudeMdKey as Tt, generateEcosystemMap as U, lessonsStateFileFor as V, writePluginMap as W, countFiles as X, mergeLines as Y, getFileDesc as Z, preCommitGate as _, sessionStartCore as _t, recordActivity as a, validateTeammateOutput as at, extractSymbols as b, removeOldFiles as bt, MCP_TTL_MS as c, validateTailwind as ct, isMcpTool as d, countLoc as dt, trackSessionChanges as et, queryOf as f, detectSolidProfile as ft, gate as g, runSessionStartCleanups as gt, TRIVIAL_BUDGET as h, readRules as ht, respond as i, logToolFailure as it, trackEnrichment as j, normalizeEvent as jt, trackMcpResearch as k, projectHash$1 as kt, WEBFETCH_TTL_MS as l, validateSolidGate as lt, REQUIRED_AGENTS as m, injectRules as mt, activityFor as n, cleanupSession as nt, mcpPostStore as o, trackAgentMemory as ot, DEFAULT_WINDOW_MS as p, solidDetectStart as pt, writeTree as q, handlePre as r, saveApexState as rt, mcpPreIntercept as s, subagentCacheContext as st, handleHook as t, validateRulesLoaded as tt, cacheQueryOf as u, checkFileSize as ut, detectDuplication as v, pruneEmptyDirs as vt, seoPostToolUseResponse as w, projectContext as wt, lifecycleStdout as x, trimLogFile as xt, dryGate as y, purgeTtlTree as yt, lessonsArchiveFileFor as z };