@fusengine/harness 0.1.55 → 0.1.57

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.
@@ -373,6 +373,68 @@ function oncePerWindow(key, windowMs, opts = {}) {
373
373
  return true;
374
374
  }
375
375
  //#endregion
376
+ //#region src/runtime/inject-budget.ts
377
+ /**
378
+ * @module inject-budget
379
+ * Hard per-fragment ceiling for harness-PRODUCED context injections (lessons,
380
+ * snapshot sections, APEX task context, cartographer/dev-context blocks) —
381
+ * inspired by Codex's "ContextualUserFragment, no item > 10K tokens" pattern.
382
+ * NEVER apply this to owner-authored content (CLAUDE.md/rules): that path is
383
+ * a locked invariant (see the "owner invariant" tests in
384
+ * test/dedup-inject.test.ts) and must always ship uncapped and in full.
385
+ *
386
+ * A real regression motivated this: MEMORY/LESSON.md's SessionStart injection
387
+ * had grown to ~44k tokens with no per-fragment or total alarm, silently
388
+ * bloating every SessionStart/SubagentStart turn.
389
+ * @packageDocumentation
390
+ */
391
+ /**
392
+ * Hard cap per fragment, in characters. ~8000 chars ≈ 2000 tokens at the
393
+ * conservative ~4 chars/token ratio for mixed FR/EN prose — the accepted
394
+ * approximation absent a real tokenizer (cf. LangChain's `trim_messages`
395
+ * fallback token_counter). Chosen because a SINGLE SessionStart already
396
+ * stacks several harness fragments (lessons, snapshot, dev-context,
397
+ * cartographer) on top of the uncapped CLAUDE.md block, and the known 11x
398
+ * multi-plugin hook fan-out can multiply whatever ships — 2k tokens/fragment
399
+ * keeps the harness-produced share bounded even under that fan-out, while
400
+ * still leaving room for a few dozen useful lines of content.
401
+ */
402
+ const FRAGMENT_CHAR_CAP = 8e3;
403
+ /**
404
+ * Cap `text` at {@link FRAGMENT_CHAR_CAP} characters, cutting at the last
405
+ * newline within budget so no line is chopped mid-sentence. Text at or under
406
+ * the cap is returned byte-identical (no-op). Over the cap, a single English
407
+ * truncation line is appended so the reader knows content was dropped and
408
+ * that the SOURCE FILE itself is untouched (only this injected view is cut).
409
+ * @param label - Short identifier of the fragment (e.g. "lessons", "Git").
410
+ * @param text - The candidate fragment body.
411
+ * @returns `text` unchanged, or a truncated copy ending in the cut notice — always ≤ the cap.
412
+ */
413
+ function capFragment(label, text) {
414
+ if (text.length <= 8e3) return text;
415
+ const totalLen = text.length;
416
+ const safeLabel = label.length <= 80 ? label : `${label.slice(0, 77)}...`;
417
+ const suffixFor = (keptLen) => `\n[truncated ${safeLabel}: kept ${keptLen} of ${totalLen} chars — source file unchanged]`;
418
+ const reserve = suffixFor(totalLen).length;
419
+ const budget = Math.max(0, FRAGMENT_CHAR_CAP - reserve);
420
+ const slice = text.slice(0, budget);
421
+ const lastNl = slice.lastIndexOf("\n");
422
+ const kept = (lastNl > 0 ? slice.slice(0, lastNl) : slice).trimEnd();
423
+ return kept + suffixFor(kept.length);
424
+ }
425
+ /**
426
+ * One-line numeric recap of what a batch of fragments actually injected —
427
+ * the owner-requested visibility so a silent blowup (like the 44k-token
428
+ * lessons block) shows up as a number instead of going unnoticed.
429
+ * @param fragments - The injected fragments (post-cap sizes).
430
+ * @returns e.g. `"injected 5 fragments, 14.2k chars"`, or `"injected 0 fragments"` when empty.
431
+ */
432
+ function budgetReport(fragments) {
433
+ if (fragments.length === 0) return "injected 0 fragments";
434
+ const total = fragments.reduce((sum, f) => sum + f.chars, 0);
435
+ return `injected ${fragments.length} fragments, ${(total / 1e3).toFixed(1)}k chars`;
436
+ }
437
+ //#endregion
376
438
  //#region src/runtime/inject-context.ts
377
439
  /**
378
440
  * Build the {@link oncePerWindow} key for the CLAUDE.md preamble gate. The
@@ -408,12 +470,14 @@ function promptSubmitContext(prompt, cwd) {
408
470
  /**
409
471
  * PreToolUse Task context injection: render the APEX sub-agent context as a
410
472
  * Claude `additionalContext` response when `.claude/apex/` exists, else "".
473
+ * Harness-produced (not owner CLAUDE.md content), so it is subject to the
474
+ * per-fragment {@link capFragment} budget — unlike {@link promptSubmitContext}.
411
475
  * @param cwd - Fallback project root when `CLAUDE_PROJECT_DIR` is unset.
412
476
  * @returns The native hook stdout (possibly empty).
413
477
  */
414
478
  function taskContext(cwd) {
415
479
  const ctx = buildApexTaskInjection(process.env.CLAUDE_PROJECT_DIR ?? cwd);
416
- return ctx ? contextResponse("PreToolUse", ctx) : "";
480
+ return ctx ? contextResponse("PreToolUse", capFragment("apex-task", ctx)) : "";
417
481
  }
418
482
  //#endregion
419
483
  //#region src/runtime/dev-context.ts
@@ -459,7 +523,7 @@ function projectContext(cwd) {
459
523
  * @returns The joined additionalContext text (possibly empty).
460
524
  */
461
525
  function devContext(cwd) {
462
- return [...gitContext(cwd), ...projectContext(cwd)].join("\n");
526
+ return capFragment("dev-context", [...gitContext(cwd), ...projectContext(cwd)].join("\n"));
463
527
  }
464
528
  //#endregion
465
529
  //#region src/runtime/fs-cleanup.ts
@@ -1314,6 +1378,76 @@ function validateRulesLoaded(data, home = homedir()) {
1314
1378
  /** Fan-out dedup window (ms). The ~11 sibling hooks for one event land in <2s. */
1315
1379
  const BURST_DEDUP_MS = 2e3;
1316
1380
  //#endregion
1381
+ //#region src/runtime/notices.ts
1382
+ /**
1383
+ * @module notices
1384
+ * Compact, uniform "compliance" notices for the user-visible `systemMessage`
1385
+ * channel — the visual counterpart to the additionalContext-only gate/credit
1386
+ * signals, which land in agent-only context and stay invisible to the human in
1387
+ * the Claude Code UI (deny reasons show in red; these did not show at all).
1388
+ * Owner-reported gap: pass-notices existed in v0.1.49 for design gates, but
1389
+ * nothing surfaced skill credits, freshness, or the sniper reminder to the human.
1390
+ *
1391
+ * The text builders are pure. `refCreditNoticeFor` is the one exception — like
1392
+ * `pre-allow.ts`/`track-changes.ts` already do, it calls the existing
1393
+ * {@link oncePerWindow} file-backed cooldown gate directly, so a caller can drop
1394
+ * it straight into a PostToolUse loop without re-deriving the dedup. Rendering
1395
+ * onto a harness's native stdout always goes through the existing adapter
1396
+ * helpers (`respond`/`attachSystemMessage`); a harness with no `systemMessage`
1397
+ * channel (e.g. cline) silently drops the notice there (documented no-op, never
1398
+ * a crash) — nothing in this module renders directly.
1399
+ */
1400
+ /** One compliance line: `✓ <gate> — <detail>` (detail omitted when empty). */
1401
+ function complianceNotice(gate, detail) {
1402
+ return detail ? `✓ ${gate} — ${detail}` : `✓ ${gate}`;
1403
+ }
1404
+ /** One non-blocking requirement line: `⚠ <requirement> — <detail>`. */
1405
+ function requirementNotice(requirement, detail) {
1406
+ return detail ? `⚠ ${requirement} — ${detail}` : `⚠ ${requirement}`;
1407
+ }
1408
+ /** Extract the skill name from a `.md` ref path (`.../skills/<name>/...`), or null when it isn't a skill reference (a banal doc Read — no notice). */
1409
+ function skillNameFromRefPath(path) {
1410
+ return /skills\/([^/]+)\//.exec(path)?.[1] ?? null;
1411
+ }
1412
+ /** Notice for a SOLID/skill reference credited via an in-session Read, or null for a non-skill `.md`. */
1413
+ function refCreditedNotice(path) {
1414
+ const skill = skillNameFromRefPath(path);
1415
+ return skill ? complianceNotice("SOLID refs read", skill) : null;
1416
+ }
1417
+ /** Notice for the APEX freshness gate (explore+research) currently satisfied. */
1418
+ function evidenceFreshNotice() {
1419
+ return complianceNotice("evidence fresh", "explore+research");
1420
+ }
1421
+ /** Notice mirroring the existing sniper-required additionalContext reminder. */
1422
+ function sniperRequiredNotice(fileName) {
1423
+ return requirementNotice("sniper required", fileName);
1424
+ }
1425
+ /**
1426
+ * The one `✓ SOLID refs read (<skill>)` notice to show for this PostToolUse
1427
+ * call, or null. Scans the activities `activityFor` recorded from this event
1428
+ * for a skill-ref Read, deduped per (session, path) against the same burst
1429
+ * window as the sniper reminder — the ~11 sibling-plugin fan-out for one real
1430
+ * Read must never repeat it (lesson 2026-07-05 15:21).
1431
+ * @param activities - This event's recorded activities (only `ref` entries matter).
1432
+ * @param sessionId - Current session id (dedup scope).
1433
+ * @param now - Event clock.
1434
+ * @param dir - State-dir override for the dedup sidecar (tests MUST pass an
1435
+ * isolated dir; production passes the per-project state dir).
1436
+ */
1437
+ function refCreditNoticeFor(activities, sessionId, now, dir) {
1438
+ for (const a of activities) {
1439
+ if (a.kind !== "ref" || !a.path) continue;
1440
+ const notice = refCreditedNotice(a.path);
1441
+ if (!notice) continue;
1442
+ if (!oncePerWindow(`ref-credited:${sessionId}:${a.path}`, 2e3, {
1443
+ now,
1444
+ dir
1445
+ })) continue;
1446
+ return notice;
1447
+ }
1448
+ return null;
1449
+ }
1450
+ //#endregion
1317
1451
  //#region src/runtime/lifecycle/track-changes.ts
1318
1452
  /** Code-file extensions tracked for sniper (mirrors track-session-changes.py). */
1319
1453
  const CODE_EXT$1 = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|vue|svelte|astro)$/;
@@ -1352,7 +1486,8 @@ function trackSessionChanges(sessionIdRaw, filePath, home = homedir(), now = Dat
1352
1486
  now,
1353
1487
  dir: sessionsDir(home)
1354
1488
  })) return "";
1355
- return contextResponse("PostToolUse", `SNIPER VALIDATION REQUIRED: Code file '${basename(filePath)}' was modified. You MUST now run the sniper agent (fuse-ai-pilot:sniper) to validate this modification before continuing. This is mandatory per CLAUDE.md rules.`);
1489
+ const fname = basename(filePath);
1490
+ return attachSystemMessage(contextResponse("PostToolUse", `SNIPER VALIDATION REQUIRED: Code file '${fname}' was modified. You MUST now run the sniper agent (fuse-ai-pilot:sniper) to validate this modification before continuing. This is mandatory per CLAUDE.md rules.`), sniperRequiredNotice(fname));
1356
1491
  }
1357
1492
  //#endregion
1358
1493
  //#region src/runtime/lifecycle/post-edit-ts.ts
@@ -1698,1558 +1833,1918 @@ function harvestSubagentTrack(payload, cwd, now, baseDir = defaultStateDir(cwd))
1698
1833
  } catch {}
1699
1834
  }
1700
1835
  //#endregion
1701
- //#region src/runtime/lifecycle/task-completed.ts
1702
- /** Code-file extensions audited on task completion (mirrors validate-task-solid.py). */
1703
- const CODE_EXTENSIONS$2 = /* @__PURE__ */ new Set([
1704
- ".ts",
1705
- ".tsx",
1706
- ".js",
1707
- ".jsx",
1708
- ".py",
1709
- ".go",
1710
- ".rs",
1711
- ".java",
1712
- ".php",
1713
- ".cpp",
1714
- ".c",
1715
- ".rb",
1716
- ".swift",
1717
- ".kt",
1718
- ".dart",
1719
- ".vue",
1720
- ".svelte",
1721
- ".astro"
1722
- ]);
1723
- /** Freshness multiple on `FUSE_ENFORCE_TTL_SEC` for receipts (no new env var); a tsc+test run precedes the "done" by more than one edit window. */
1724
- const RECEIPT_TTL_MULTIPLIER = 5;
1725
- /** The modified files that are code (by extension) — the receipt gate's trigger set. */
1726
- function codeFiles(files) {
1727
- return files.filter((fp) => CODE_EXTENSIONS$2.has(extname(fp)));
1728
- }
1836
+ //#region src/runtime/lifecycle/teammate-idle-check.ts
1729
1837
  /**
1730
- * Refuse completion when code files changed but no fresh, passing verification
1731
- * receipt (`tsc`/test, exit 0, zero failures, within TTL×{@link RECEIPT_TTL_MULTIPLIER})
1732
- * exists in the signed track. TaskCompleted does NOT honor `decision:"block"`
1733
- * (verified against the official hooks docs `TeammateIdle/TaskCreated/
1734
- * TaskCompleted` are excluded from that list); the documented stdout refusal is
1735
- * `{"continue":false,"stopReason":…}`, which halts the teammate with the reason
1736
- * shown to the user. Returns that JSON, or `null` when the session is clear.
1838
+ * @module teammate-idle-check
1839
+ * TeammateIdle anti-"false done": alongside the existing sniper suggestion
1840
+ * ({@link validateTeammateOutput}), verify that the files this teammate ANNOUNCED
1841
+ * (session-changes `modifiedFiles`) actually exist on disk. A claimed deliverable
1842
+ * missing on disk is a mechanically-verifiable false-done signal warn the lead.
1843
+ * Deduped across the fan-out; silent when nothing is verifiable. Fail-open.
1844
+ *
1845
+ * Claude-Code-only: no equivalent `TeammateIdle` hook exists on Codex or Hermes,
1846
+ * so this handler is never reached through those adapters.
1847
+ * @packageDocumentation
1737
1848
  */
1738
- function receiptGate(sid, files, now, stateDir) {
1739
- if (codeFiles(files).length === 0) return null;
1740
- const windowMs = resolveTtlSec(process.env) * 1e3 * RECEIPT_TTL_MULTIPLIER;
1741
- if (freshReceiptFromFile(trackFile(sid, stateDir), windowMs, now)) return null;
1742
- return JSON.stringify({
1743
- continue: false,
1744
- stopReason: "VERIFICATION RECEIPT REQUIRED: code files changed but no fresh passing tsc/test receipt exists. Run `bun test` + `tsc --noEmit` (exit 0, 0 failures) and re-complete."
1745
- });
1849
+ /** Re-warn about the same idle teammate at most once per 30s (fan-out + retries). */
1850
+ const IDLE_DEDUP_MS = 3e4;
1851
+ /** Announced files (session changes) that are ABSENT on disk (the false-done set). */
1852
+ function missingDeliverables(sessionId, home) {
1853
+ return (loadSessionState(sessionId, home).changes?.modifiedFiles ?? []).filter((f) => typeof f === "string" && f !== "" && !existsSync(f));
1746
1854
  }
1747
- /**
1748
- * Re-count physical lines of every modified code file and collect SOLID
1749
- * violations (`<basename>: <n> lines (max <max>)`) for those exceeding `max`.
1750
- * @param files - Candidate modified file paths.
1751
- * @param max - The SOLID line ceiling.
1752
- * @returns The list of violation strings (empty when all files comply).
1753
- */
1754
- function collectViolations(files, max) {
1755
- const violations = [];
1756
- for (const fp of files) {
1757
- if (!CODE_EXTENSIONS$2.has(extname(fp)) || !existsSync(fp)) continue;
1758
- try {
1759
- const lines = countLines(readFileSync(fp, "utf-8"));
1760
- if (lines > max) violations.push(`${basename(fp)}: ${lines} lines (max ${max})`);
1761
- } catch {}
1855
+ /** Pull the `additionalContext` body out of a `contextResponse` stdout ("" when empty/unparseable). */
1856
+ function bodyOf(stdout) {
1857
+ if (!stdout) return "";
1858
+ try {
1859
+ return JSON.parse(stdout).hookSpecificOutput?.additionalContext ?? "";
1860
+ } catch {
1861
+ return "";
1762
1862
  }
1763
- return violations;
1764
1863
  }
1765
1864
  /**
1766
- * Handle TaskCompleted (ports `task-completed/validate-task-solid.py`, plus the
1767
- * receipt gate). SOLID violations surface first as `SOLID VIOLATION`
1768
- * additionalContext; once the files comply, {@link receiptGate} refuses a "done"
1769
- * that has no fresh passing tsc/test receipt.
1770
- * @param payload - The TaskCompleted payload (`task_id`, `task_subject`, `session_id`).
1865
+ * Handle TeammateIdle: merge the existing sniper suggestion with a missing-
1866
+ * deliverable warning (deduped) into one `additionalContext` response, or "" when
1867
+ * neither fires.
1868
+ * @param data - The raw TeammateIdle payload (`teammate_name`, `session_id`).
1869
+ * @param cwd - Project root (state dir for the dedup sidecar).
1771
1870
  * @param home - Home dir (defaults to `~`).
1772
1871
  * @param now - Clock (defaults to `Date.now()`).
1773
- * @param stateDir - Track base dir (defaults to the cwd-derived state dir; matches `handleHook`).
1774
- * @returns The native hook stdout, or `""` when the session is clean.
1872
+ * @returns The native hook stdout, or "".
1775
1873
  */
1776
- function validateTaskSolid(payload, home = homedir(), now = Date.now(), stateDir = defaultStateDir(process.cwd())) {
1777
- const sid = sanitizeSessionId(payload.session_id ?? "unknown");
1778
- if (!sid) return "";
1779
- const files = loadSessionState(sid, home).changes?.modifiedFiles ?? [];
1780
- if (files.length === 0) return "";
1781
- const max = resolveMaxLines();
1782
- const violations = collectViolations(files, max);
1783
- if (violations.length === 0) return receiptGate(sid, files, now, stateDir) ?? "";
1784
- const taskId = String(payload.task_id ?? "");
1785
- return contextResponse("TaskCompleted", `SOLID VIOLATION in task '${String(payload.task_subject ?? "")}' (${taskId}): ${violations.length} file(s) exceed ${max} lines: ` + violations.slice(0, 5).join("; "));
1874
+ function teammateIdleContext(data, cwd, home = homedir(), now = Date.now()) {
1875
+ const sniper = bodyOf(validateTeammateOutput(data, home));
1876
+ const sessionId = sanitizeSessionId(data.session_id);
1877
+ const teammate = String(data.teammate_name ?? data.team_name ?? "unknown");
1878
+ let notice = "";
1879
+ if (sessionId) {
1880
+ const missing = missingDeliverables(sessionId, home);
1881
+ if (missing.length > 0 && oncePerWindow(`idle:${sessionId}:${teammate}`, IDLE_DEDUP_MS, {
1882
+ now,
1883
+ dir: defaultStateDir(cwd)
1884
+ })) notice = `Teammate '${teammate}' idle but expected deliverable(s) not found on disk: ${missing.slice(0, 5).join(", ")} — verify before treating as done.`;
1885
+ }
1886
+ const merged = [sniper, notice].filter(Boolean).join("\n\n");
1887
+ return merged ? contextResponse("TeammateIdle", merged) : "";
1786
1888
  }
1787
1889
  //#endregion
1788
- //#region src/runtime/lifecycle/cartographer/fs-util.ts
1890
+ //#region src/policy/lessons/trigger-index.ts
1789
1891
  /**
1790
- * Filesystem helpers for the cartographer tree walk. Ports the fs parts of
1791
- * `describe.py` (file desc) and `write_recursive.py` (children + counts).
1892
+ * Compile the triggered-lesson index from `MEMORY/LESSON.md`. A lesson is a
1893
+ * bullet (`- [YYYY-MM-DD HH:MM] ...`); it opts into decision-time injection by
1894
+ * ending with a `[TRIGGERS tool:.. path:.. error:.. keyword:..]` line. Lessons
1895
+ * WITHOUT that tag are skipped here (they keep the SessionStart block behavior —
1896
+ * zero regression). Parsed once per file version (mtime-memoized).
1792
1897
  */
1898
+ /** Matches a trailing `[TRIGGERS ...]` line (its body captured). */
1899
+ const TRIGGER_RE = /^\[TRIGGERS\s+(.+?)\]$/;
1900
+ /** Comma list for `key:` in a trigger body (values are space-delimited). */
1901
+ function list(body, key) {
1902
+ const val = body.match(new RegExp(`\\b${key}:([^\\s\\]]+)`))?.[1];
1903
+ return val ? val.split(",").filter(Boolean) : [];
1904
+ }
1905
+ /** Parse a `[TRIGGERS ...]` body into predicates (error is a single regex). */
1906
+ function parseTriggers(body) {
1907
+ const err = body.match(/\berror:([^\s\]]+)/);
1908
+ return {
1909
+ tools: list(body, "tool"),
1910
+ paths: list(body, "path"),
1911
+ error: err?.[1],
1912
+ keywords: list(body, "keyword")
1913
+ };
1914
+ }
1915
+ /** Collapse to a single ≤3-line compact string (cap length). */
1916
+ function compact(text) {
1917
+ const one = text.replace(/\s+/g, " ").trim();
1918
+ return one.length > 280 ? `${one.slice(0, 277)}…` : one;
1919
+ }
1793
1920
  /**
1794
- * Read a file and derive its one-line description (frontmatter / heading /
1795
- * comment). "" on any error or when nothing is found.
1796
- * @param filePath - Absolute path to the file.
1797
- * @returns The description, or "".
1921
+ * Parse LESSON.md content into triggered entries. A bullet's text spans its
1922
+ * `- ` line plus any following non-blank continuation lines up to the next
1923
+ * bullet; a `[TRIGGERS ...]` continuation line arms it.
1924
+ * @param content - Raw LESSON.md text.
1925
+ * @returns Entries that declared triggers (others skipped).
1798
1926
  */
1799
- function getFileDesc(filePath) {
1800
- let text = "";
1801
- try {
1802
- text = readFileSync(filePath, "utf-8");
1803
- } catch {
1804
- return "";
1927
+ function parseLessons(content) {
1928
+ const lines = content.split("\n");
1929
+ const out = [];
1930
+ for (let i = 0; i < lines.length; i++) {
1931
+ const line = lines[i];
1932
+ if (line === void 0 || !line.startsWith("- ")) continue;
1933
+ let text = line.slice(2);
1934
+ let triggers = null;
1935
+ for (let j = i + 1; j < lines.length; j++) {
1936
+ const cont = lines[j];
1937
+ if (cont === void 0 || cont.trim() === "" || cont.startsWith("- ")) break;
1938
+ const body = cont.trim().match(TRIGGER_RE)?.[1];
1939
+ if (body !== void 0) triggers = parseTriggers(body);
1940
+ else text += ` ${cont.trim()}`;
1941
+ }
1942
+ if (triggers) out.push({
1943
+ text: compact(text),
1944
+ triggers
1945
+ });
1805
1946
  }
1806
- const suffix = extname(filePath);
1807
- const mdField = suffix === ".md" ? parseField(text, "description") : "";
1808
- return descFromText(suffix, text, mdField);
1947
+ return out;
1809
1948
  }
1949
+ let memo = null;
1810
1950
  /**
1811
- * Recursively count files whose relative path parts are all visible (no leading
1812
- * "." or "_") and none excluded. Best-effort (partial count on errors).
1813
- * @param dir - Directory to count under.
1814
- * @param exclude - Directory/name set to skip.
1815
- * @returns The file count.
1951
+ * Compile (once per file version) the triggered-lesson index from `file`.
1952
+ * Memoized by path+mtime: re-parses only when LESSON.md changes.
1953
+ * @param file - Absolute path to MEMORY/LESSON.md.
1954
+ * @returns The compiled entries (missing/unreadable file empty).
1816
1955
  */
1817
- function countFiles(dir, exclude) {
1818
- let total = 0;
1819
- try {
1820
- for (const e of readdirSync(dir, { withFileTypes: true })) {
1821
- if (e.name.startsWith(".") || e.name.startsWith("_") || exclude.has(e.name)) continue;
1822
- if (e.isDirectory()) total += countFiles(join(dir, e.name), exclude);
1823
- else if (e.isFile()) total += 1;
1824
- }
1825
- } catch {}
1826
- return total;
1827
- }
1828
- /** Absolute children of `source`, split into dirs/files, sorted by full path. */
1829
- function listChildren(source, exclude) {
1830
- const dirs = [];
1831
- const files = [];
1832
- let entries;
1956
+ function lessonIndex(file) {
1957
+ let key;
1833
1958
  try {
1834
- entries = readdirSync(source, { withFileTypes: true });
1959
+ key = `${file}:${statSync(file).mtimeMs}`;
1835
1960
  } catch {
1836
- return {
1837
- dirs,
1838
- files
1839
- };
1961
+ return [];
1840
1962
  }
1841
- for (const e of entries) {
1842
- if (e.name.startsWith(".") || e.name.startsWith("_") || exclude.has(e.name)) continue;
1843
- const abs = join(source, e.name);
1844
- if (e.isDirectory()) dirs.push(abs);
1845
- else if (e.isFile()) files.push(abs);
1963
+ if (memo?.key === key) return memo.entries;
1964
+ let entries = [];
1965
+ try {
1966
+ entries = parseLessons(readFileSync(file, "utf-8"));
1967
+ } catch {
1968
+ entries = [];
1846
1969
  }
1847
- return {
1848
- dirs: dirs.sort(),
1849
- files: files.sort()
1970
+ memo = {
1971
+ key,
1972
+ entries
1850
1973
  };
1974
+ return entries;
1851
1975
  }
1852
- //#endregion
1853
- //#region src/runtime/lifecycle/cartographer/merge.ts
1854
- /**
1855
- * Index merge — preserves enriched descriptions across regenerations. Ports
1856
- * `merge_index.py` (merge_lines + .enriched.json sidecar).
1857
- */
1858
- /**
1859
- * Load the `.enriched.json` sidecar's `entries` map for an output index.
1860
- * @param outputIndexPath - Path to the index.md being written.
1861
- * @returns The path→desc enrichment map (possibly empty).
1862
- */
1863
- function loadEnriched(outputIndexPath) {
1864
- const sidecar = join(dirname(outputIndexPath), ".enriched.json");
1976
+ /** Glob (`*`/`**`) → RegExp, matching a path segment/tail. */
1977
+ function globToRe(glob) {
1978
+ const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(//g, ".*");
1979
+ return new RegExp(`(^|/)${esc}$`);
1980
+ }
1981
+ /** Safe case-insensitive regex test (absent source or invalid → false). */
1982
+ function safeTest(src, s) {
1983
+ if (!src) return false;
1865
1984
  try {
1866
- if (!existsSync(sidecar)) return {};
1867
- return JSON.parse(readFileSync(sidecar, "utf-8")).entries ?? {};
1985
+ return new RegExp(src, "i").test(s);
1868
1986
  } catch {
1869
- return {};
1987
+ return false;
1870
1988
  }
1871
1989
  }
1872
- /**
1873
- * Merge freshly generated lines with prior descriptions: enriched sidecar wins,
1874
- * else a longer pre-existing description is preserved.
1875
- * @param newLines - The freshly generated index lines.
1876
- * @param outputIndexPath - Path to the existing index.md (if any).
1877
- * @returns The merged lines.
1878
- */
1879
- function mergeLines(newLines, outputIndexPath) {
1880
- const enriched = loadEnriched(outputIndexPath);
1881
- const existingDescs = {};
1882
- if (existsSync(outputIndexPath)) try {
1883
- for (const line of readFileSync(outputIndexPath, "utf-8").split("\n")) {
1884
- const e = parseEntry(line);
1885
- if (e) existingDescs[e.path] = e.desc;
1886
- }
1887
- } catch {}
1888
- return newLines.map((line) => {
1889
- const e = parseEntry(line);
1890
- if (!e) return line;
1891
- if (e.path in enriched) return `${e.prefix}[${e.name}](${e.path}) — ${enriched[e.path]}`;
1892
- const old = existingDescs[e.path] ?? "";
1893
- if (old.length > e.desc.length) return `${e.prefix}[${e.name}](${e.path}) — ${old}`;
1894
- return line;
1895
- });
1990
+ /** Score one entry against the call; null = no predicate matched. */
1991
+ function scoreEntry(e, tool, filePath, inputJson, prevError) {
1992
+ const tr = e.triggers;
1993
+ if (tr.tools.includes(tool)) return {
1994
+ entry: e,
1995
+ rank: 3
1996
+ };
1997
+ if (filePath && tr.paths.some((g) => globToRe(g).test(filePath))) return {
1998
+ entry: e,
1999
+ rank: 2
2000
+ };
2001
+ if (prevError && safeTest(tr.error, prevError)) return {
2002
+ entry: e,
2003
+ rank: 1
2004
+ };
2005
+ if (tr.keywords.some((k) => inputJson.includes(k))) return {
2006
+ entry: e,
2007
+ rank: 0
2008
+ };
2009
+ return null;
2010
+ }
2011
+ /** Stable, filesystem-safe cooldown key from a lesson's compact text (djb2). */
2012
+ function cooldownKey(text) {
2013
+ let h = 5381;
2014
+ for (let i = 0; i < text.length; i++) h = (h << 5) + h + text.charCodeAt(i) | 0;
2015
+ return `lesson:${(h >>> 0).toString(36)}`;
1896
2016
  }
1897
- //#endregion
1898
- //#region src/runtime/lifecycle/cartographer/write-tree.ts
1899
- /**
1900
- * Recursive index.md tree writer. Ports `write_recursive.py`.
1901
- */
1902
2017
  /**
1903
- * Write `index.md` files mirroring `source` under `output`, recursing into
1904
- * subdirectories. Directory lines carry a file-count hint; file lines carry a
1905
- * derived description and link to the real absolute source path.
1906
- * @param source - Absolute source directory.
1907
- * @param output - Absolute output directory for the index tree.
1908
- * @param back - Relative `← back` link target ("" at the root).
1909
- * @param exclude - Directory/name set to skip.
2018
+ * The single most-specific lesson for this PreToolUse call, or null. Matching
2019
+ * priority: exact tool > path glob > error regex > input-JSON keyword. Cooldown
2020
+ * suppresses a lesson already injected within the window.
2021
+ * @param tool - The tool being called (e.g. `Write`).
2022
+ * @param toolInput - The raw `tool_input`.
2023
+ * @param opts - Index file, cooldown gate, and optional prior error.
2024
+ * @returns An `inform` prompt, or null when nothing matches / in cooldown.
1910
2025
  */
1911
- function writeTree(source, output, back = "", exclude) {
1912
- const ex = exclude ?? /* @__PURE__ */ new Set();
1913
- mkdirSync(output, { recursive: true });
1914
- const { dirs, files } = listChildren(source, ex);
1915
- const lines = [`# ${basename(source)}\n`];
1916
- if (back) lines.push(`> [← back](${back})\n`);
1917
- const total = dirs.length + files.length;
1918
- let idx = 0;
1919
- for (const d of dirs) {
1920
- idx += 1;
1921
- const conn = idx === total ? "└──" : "├──";
1922
- const count = countFiles(d, ex);
1923
- const hint = count ? ` — ${count} files` : "";
1924
- lines.push(`${conn} [${basename(d)}/](./${basename(d)}/index.md)${hint}`);
1925
- writeTree(d, join(output, basename(d)), "../index.md", exclude);
1926
- }
1927
- for (const f of files) {
1928
- idx += 1;
1929
- const conn = idx === total ? "└──" : "├──";
1930
- const desc = getFileDesc(f);
1931
- const suffix = desc ? ` — ${desc}` : "";
1932
- lines.push(`${conn} [${basename(f)}](${f})${suffix}`);
2026
+ function lessonFor(tool, toolInput, opts) {
2027
+ const entries = lessonIndex(opts.file);
2028
+ if (entries.length === 0) return null;
2029
+ const filePath = typeof toolInput?.file_path === "string" ? toolInput.file_path : "";
2030
+ const inputJson = JSON.stringify(toolInput ?? {});
2031
+ let best = null;
2032
+ for (const e of entries) {
2033
+ const m = scoreEntry(e, tool, filePath, inputJson, opts.prevError);
2034
+ if (m && (!best || m.rank > best.rank)) best = m;
1933
2035
  }
1934
- const indexPath = join(output, "index.md");
1935
- writeFileSync(indexPath, mergeLines(lines, indexPath).join("\n") + "\n", "utf-8");
2036
+ if (!best) return null;
2037
+ if (!opts.once(cooldownKey(best.entry.text), opts.cooldownMs ?? 18e5)) return null;
2038
+ return {
2039
+ kind: "inform",
2040
+ title: `Project lesson${filePath ? ` (${basename(filePath)})` : ""}`,
2041
+ reason: best.entry.text
2042
+ };
1936
2043
  }
1937
2044
  //#endregion
1938
- //#region src/runtime/lifecycle/cartographer/project-map.ts
2045
+ //#region src/runtime/lifecycle/lessons/state.ts
1939
2046
  /**
1940
- * Project map generation. Ports `generate_project_map.py` (project map only).
2047
+ * Per-project lessons paths. The `fuse-lessons` plugin stores its lessons under
2048
+ * `<root>/MEMORY/` (NOT the harness `.harness/memory/`), so these two path
2049
+ * helpers override the layout while ALL state/gitignore/throttle logic is
2050
+ * reused from `src/memory` (`setStateField`, `ensureMemoryGitignore`,
2051
+ * `readState`, `nowStamp`, `throttleMs`).
1941
2052
  */
1942
- /** True when `dir` is a real directory. */
1943
- function isDirectory(dir) {
1944
- try {
1945
- return statSync(dir).isDirectory();
1946
- } catch {
1947
- return false;
1948
- }
2053
+ /** Absolute `<root>/MEMORY/LESSON.md` the curated, committable lessons file. */
2054
+ function lessonsFileFor(root) {
2055
+ return join(root, "MEMORY", "LESSON.md");
1949
2056
  }
1950
- /**
1951
- * True when `dir` looks like a project root (has an indicator file) and is not
1952
- * the home directory or filesystem root.
1953
- * @param dir - Directory to test.
1954
- * @returns Whether `dir` is a project root.
1955
- */
1956
- function isProject(dir) {
1957
- const resolved = resolve(dir);
1958
- if (resolved === resolve(homedir()) || resolved === "/") return false;
1959
- for (const f of PROJECT_INDICATORS) if (existsSync(join(dir, f))) return true;
1960
- return false;
2057
+ /** Absolute `<root>/MEMORY/LESSON-archive.md` — cold storage for capped-out bullets. */
2058
+ function lessonsArchiveFileFor(root) {
2059
+ return join(root, "MEMORY", "LESSON-archive.md");
1961
2060
  }
1962
- /**
1963
- * Generate the `.cartographer/project` index tree for `cwd` when it is a real
1964
- * project directory. Always returns "" (no additionalContext emitted).
1965
- * @param cwd - The working directory.
1966
- * @param outputDir - Override for the output tree root.
1967
- * @returns "" (side-effect only).
1968
- */
1969
- function generateProjectMap(cwd, outputDir) {
1970
- const projectDir = resolve(cwd);
1971
- const out = outputDir ?? join(projectDir, ".cartographer", "project");
1972
- if (!isDirectory(projectDir)) return "";
1973
- if (!isProject(projectDir)) return "";
1974
- writeTree(projectDir, out, "", EXCLUDE_DIRS$1);
1975
- return "";
2061
+ /** Absolute `<root>/MEMORY/state.json` — machine-local throttle counter. */
2062
+ function lessonsStateFileFor(root) {
2063
+ return join(root, "MEMORY", "state.json");
1976
2064
  }
1977
2065
  //#endregion
1978
- //#region src/policy/cartographer/build-tree.ts
1979
- const SECTION_ORDER = [
1980
- "agent",
1981
- "skill",
1982
- "command"
1983
- ];
2066
+ //#region src/tracking/one-shot-store.ts
2067
+ /** A fresh, empty state — always spread (`{ ...EMPTY }`) so the const is never shared. */
2068
+ const EMPTY = {
2069
+ gates: {},
2070
+ firstTry: 0,
2071
+ corrected: 0,
2072
+ pending: {},
2073
+ updatedAt: 0
2074
+ };
1984
2075
  /**
1985
- * Format grouped items with tree connectors and optional markdown links.
1986
- * Skill sections link to `./skills/<name>/index.md`; other sections to
1987
- * `./<folder>/<name>.md`; unlinked sections render the bare name.
1988
- * @param prefix - The line prefix (indent + branch glyphs).
1989
- * @param items - The `[name, desc]` pairs to render.
1990
- * @param folder - The link folder ("" disables linking).
1991
- * @param asDirs - Whether items link to a subdirectory `index.md`.
1992
- * @returns The rendered lines.
2076
+ * Drop stale data: whole-state idle reset past the window, else per-entry prune of
2077
+ * gates/pending older than `windowMs`. Keeps the "7d" window honest, bounds size.
1993
2078
  */
1994
- function printItems(prefix, items, folder, asDirs) {
1995
- return items.map(([name, desc], i) => {
1996
- const connector = i === items.length - 1 ? "└──" : "├──";
1997
- const safe = name.replace(/^\/+/, "");
1998
- let label = name;
1999
- if (folder && asDirs) label = `[${name}](./${folder}/${safe}/index.md)`;
2000
- else if (folder) label = `[${name}](./${folder}/${safe}.md)`;
2001
- const short = desc && desc !== "(no description)" ? ` — ${desc.slice(0, 80)}` : "";
2002
- return `${prefix}${connector} ${label}${short}`;
2003
- });
2079
+ function pruneState(s, now, windowMs) {
2080
+ if (now - s.updatedAt >= windowMs) return { ...EMPTY };
2081
+ const gates = {};
2082
+ for (const [k, g] of Object.entries(s.gates)) if (now - g.lastTs < windowMs) gates[k] = g;
2083
+ const pending = {};
2084
+ for (const [k, p] of Object.entries(s.pending)) if (now - p.ts < windowMs) pending[k] = p;
2085
+ return {
2086
+ ...s,
2087
+ gates,
2088
+ pending
2089
+ };
2004
2090
  }
2005
2091
  /**
2006
- * Build an indented tree from scanned items. The `hooks` row renders as a single
2007
- * trailing `└── hooks: …` line; agents/skills/commands render as folder sections.
2008
- * @param items - The scanned `[type, name, desc]` rows.
2009
- * @param linked - When true, leaf names become markdown links.
2010
- * @returns The joined tree text.
2092
+ * Record a deny for gate `title` on operation `op` (content-free tool identity):
2093
+ * bump the gate's deny count and mark `op` pending for a later fix.
2011
2094
  */
2012
- function buildTree(items, linked = false) {
2013
- const groups = {};
2014
- let hooksLine = "";
2015
- for (const [typ, name, desc] of items) if (typ === "hooks") hooksLine = name;
2016
- else (groups[typ] ??= []).push([name, desc]);
2017
- const sections = SECTION_ORDER.filter((s) => s in groups);
2018
- if (hooksLine) sections.push("hooks");
2019
- const lines = [];
2020
- const total = sections.length;
2021
- for (let idx = 0; idx < total; idx++) {
2022
- const section = sections[idx] ?? "";
2023
- if (section === "hooks") {
2024
- lines.push(`└── hooks: ${hooksLine}`);
2025
- continue;
2026
- }
2027
- const isLast = idx === total - 1;
2028
- const folder = `${section}s`;
2029
- const prefix = isLast ? "└──" : "├──";
2030
- const subPrefix = isLast ? " " : "│ ";
2031
- lines.push(`${prefix} ${folder}/`);
2032
- const linkFolder = linked ? folder : "";
2033
- const isDirSection = section === "skill";
2034
- lines.push(...printItems(subPrefix, groups[section] ?? [], linkFolder, linked && isDirSection));
2035
- }
2036
- return lines.join("\n");
2095
+ function applyDeny(s, title, op, now) {
2096
+ const g = s.gates[title] ?? {
2097
+ denies: 0,
2098
+ corrected: 0,
2099
+ lastTs: 0
2100
+ };
2101
+ return {
2102
+ ...s,
2103
+ gates: {
2104
+ ...s.gates,
2105
+ [title]: {
2106
+ denies: g.denies + 1,
2107
+ corrected: g.corrected,
2108
+ lastTs: now
2109
+ }
2110
+ },
2111
+ pending: {
2112
+ ...s.pending,
2113
+ [op]: {
2114
+ title,
2115
+ ts: now
2116
+ }
2117
+ },
2118
+ updatedAt: now
2119
+ };
2037
2120
  }
2038
- //#endregion
2039
- //#region src/runtime/lifecycle/cartographer/write-plugin-map.ts
2040
2121
  /**
2041
- * Per-plugin map writer (fs). Ports `write_plugin_map.py`: writes a level-2
2042
- * `<plugin>/index.md` (indented linked tree) then recurses agents/skills/
2043
- * commands into deeper index trees. Reuses `buildTree`, `mergeLines`, `writeTree`.
2044
- */
2045
- /** True when `dir` is a real directory. */
2046
- function isDir(dir) {
2047
- try {
2048
- return statSync(dir).isDirectory();
2049
- } catch {
2050
- return false;
2122
+ * Record an allow for a gateable `op`. A non-gateable allow (Read/Task/MCP) leaves
2123
+ * state untouched it never counts and never clears a pending deny. Otherwise: a
2124
+ * pending deny `corrected` (a fix, credited to the blocking gate); no pending →
2125
+ * `firstTry` (one-shot).
2126
+ */
2127
+ function applyAllow(s, op, now, gateable) {
2128
+ if (!gateable) return s;
2129
+ const pend = s.pending[op];
2130
+ if (pend) {
2131
+ const g = s.gates[pend.title] ?? {
2132
+ denies: 0,
2133
+ corrected: 0,
2134
+ lastTs: 0
2135
+ };
2136
+ const { [op]: _drop, ...pending } = s.pending;
2137
+ return {
2138
+ ...s,
2139
+ gates: {
2140
+ ...s.gates,
2141
+ [pend.title]: {
2142
+ ...g,
2143
+ corrected: g.corrected + 1,
2144
+ lastTs: now
2145
+ }
2146
+ },
2147
+ corrected: s.corrected + 1,
2148
+ pending,
2149
+ updatedAt: now
2150
+ };
2051
2151
  }
2152
+ return gateable ? {
2153
+ ...s,
2154
+ firstTry: s.firstTry + 1,
2155
+ updatedAt: now
2156
+ } : s;
2052
2157
  }
2053
2158
  /**
2054
- * Write `<outputDir>/<pluginName>/index.md` (indented linked tree) and recurse
2055
- * agents/skills/commands into their own index trees rooted there.
2056
- * @param outputDir - The map root directory.
2057
- * @param pluginName - Display name of the plugin (the index subfolder).
2058
- * @param version - Plugin version ("" to omit).
2059
- * @param items - The scanned `[type, name, desc]` rows.
2060
- * @param pluginPath - Absolute source plugin directory (for recursion).
2159
+ * Compact injectable summary (one line); "" when there is nothing to report.
2160
+ * @returns e.g. `gates 7d: 88% one-shot (44/50 clean); SOLID file-size limit 4den/3fix`.
2061
2161
  */
2062
- function writePluginMap(outputDir, pluginName, version, items, pluginPath) {
2063
- const pluginDir = join(outputDir, pluginName);
2064
- mkdirSync(pluginDir, { recursive: true });
2065
- const newLines = `# ${pluginName}${version ? ` (v${version})` : ""}\n\n${items.length ? buildTree(items, true) : "└── (empty)"}`.split("\n");
2066
- const indexPath = join(pluginDir, "index.md");
2067
- writeFileSync(indexPath, mergeLines(newLines, indexPath).join("\n") + "\n", "utf-8");
2068
- for (const section of [
2069
- "agents",
2070
- "skills",
2071
- "commands"
2072
- ]) {
2073
- const src = join(pluginPath, section);
2074
- if (isDir(src)) writeTree(src, join(pluginDir, section), "../index.md");
2075
- }
2162
+ function formatSummary(s) {
2163
+ const keys = Object.keys(s.gates);
2164
+ const total = s.firstTry + s.corrected;
2165
+ if (keys.length === 0 && total === 0) return "";
2166
+ const head = total > 0 ? `${Math.round(s.firstTry / total * 100)}% one-shot (${s.firstTry}/${total} clean)` : "no clean pass yet";
2167
+ const parts = keys.map((k) => ({
2168
+ k,
2169
+ g: s.gates[k]
2170
+ })).sort((a, b) => b.g.denies - a.g.denies).map(({ k, g }) => `${k} ${g.denies}den/${g.corrected}fix`);
2171
+ return `gates 7d: ${head}${parts.length ? `; ${parts.join("; ")}` : ""}`;
2076
2172
  }
2077
2173
  //#endregion
2078
- //#region src/runtime/lifecycle/cartographer/ecosystem-map.ts
2174
+ //#region src/policy/deny-loop.ts
2079
2175
  /**
2080
- * Ecosystem (plugin) map generation (fs). Ports `generate_map.py`: scans every
2081
- * installed plugin into a level-1 `.cartographer/index.md` + per-plugin level-2+
2082
- * trees, preserving enriched descriptions. Reuses `findMarketplacePlugins`,
2083
- * `readPluginMeta`, `scanPlugin`, `mergeLines`, `writePluginMap`.
2176
+ * @module deny-loop
2177
+ * Pure anti-loop logic: hash a tool-call, decide if it repeats a prior deny, and
2178
+ * enrich the repeated block's message.
2179
+ *
2180
+ * The proprietary rule "NEVER propose the same fix twice" is prose a model under
2181
+ * pressure ignores. This makes it machine-enforced: when a call whose
2182
+ * `(tool + normalized input)` hash was ALREADY denied in-window is retried, the
2183
+ * harness keeps the deny but rewrites the message — `[REPEAT]` title, STOP
2184
+ * prefix, forced `research-expert` action. State + wiring live in the sidecar
2185
+ * store ({@link module:deny-loop-store}); this file is IO-free and pure.
2186
+ * @packageDocumentation
2084
2187
  */
2085
- function pluginDirs(dir) {
2086
- let entries = [];
2087
- try {
2088
- entries = readdirSync(dir);
2089
- } catch {
2090
- return [];
2091
- }
2092
- return entries.filter((n) => !n.startsWith("_") && !n.startsWith(".")).filter((n) => {
2093
- try {
2094
- return statSync(join(dir, n)).isDirectory();
2095
- } catch {
2096
- return false;
2097
- }
2098
- }).sort((a, b) => a.localeCompare(b, "en"));
2099
- }
2100
- function utcStamp(now) {
2101
- return new Date(now).toISOString().slice(0, 16).replace("T", " ");
2188
+ /** Stable JSON: keys sorted at every depth so `{a,b}` and `{b,a}` hash identically. */
2189
+ function stableStringify(v) {
2190
+ if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
2191
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
2192
+ const o = v;
2193
+ return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(",")}}`;
2102
2194
  }
2103
2195
  /**
2104
- * Generate the plugin ecosystem map under `<pluginsDir>/.cartographer`.
2105
- * @param now - Clock for the banner timestamp.
2106
- * @param pluginsDirOverride - Override for the marketplace plugins directory.
2107
- * @returns The map navigation context, or "".
2196
+ * Stable identity hash of a tool-call = tool name + normalized (key-sorted) input,
2197
+ * so re-ordered keys never mask a repeat.
2198
+ * @param tool - Tool name (e.g. "Write", "Bash").
2199
+ * @param input - Identifying tool input (filePath/content/command...).
2200
+ * @returns 8-char hex hash.
2108
2201
  */
2109
- function generateEcosystemMap(now, pluginsDirOverride) {
2110
- const pluginsDir = resolve(pluginsDirOverride ?? findMarketplacePlugins());
2111
- try {
2112
- if (!statSync(pluginsDir).isDirectory()) return "";
2113
- } catch {
2114
- return "";
2115
- }
2116
- const outputDir = join(pluginsDir, ".cartographer");
2117
- mkdirSync(outputDir, { recursive: true });
2118
- const dirs = pluginDirs(pluginsDir);
2119
- const lines = [`# Ecosystem Map (${dirs.length} plugins)\n`, `> Auto-generated by cartographer — ${utcStamp(now)}\n`];
2120
- for (const name of dirs) {
2121
- const pluginPath = join(pluginsDir, name);
2122
- const [version, pkgName] = readPluginMeta(pluginPath);
2123
- const display = pkgName || name;
2124
- const items = scanPlugin(pluginPath);
2125
- const agents = items.filter(([t]) => t === "agent").map(([, n]) => n);
2126
- const ver = version ? ` (v${version})` : "";
2127
- lines.push(`- [${display}](./${display}/index.md)${ver} → ${agents.length ? agents.join(", ") : "(no agents)"}`);
2128
- writePluginMap(outputDir, display, version, items, pluginPath);
2129
- writePluginMap(pluginPath, ".cartographer", version, items, pluginPath);
2130
- }
2131
- const indexPath = join(outputDir, "index.md");
2132
- writeFileSync(indexPath, mergeLines(lines, indexPath).join("\n") + "\n", "utf-8");
2133
- return `Project map: .cartographer/project/index.md — navigate project files. Plugin skills map: ${outputDir}/index.md — navigate agent skills. Branches link to deeper index.md, leaves link to real files.`;
2202
+ function denyHash(tool, input) {
2203
+ return hashText(`${tool}\n${stableStringify(input)}`);
2134
2204
  }
2135
- //#endregion
2136
- //#region src/runtime/lifecycle/cartographer/session-start.ts
2137
2205
  /**
2138
- * Cartographer SessionStart handler. Ports BOTH halves of the Python maps:
2139
- * `generate_project_map.py` (regenerate `.cartographer/project`) and
2140
- * `generate_map.py` (regenerate the plugin ecosystem map), emitting the
2141
- * navigation context from the latter as additionalContext.
2206
+ * Pure loop check: given the already-pruned in-window map, compute the running
2207
+ * count for `hash` and whether it repeats (count > 1). No IO — the caller persists.
2208
+ *
2209
+ * When `dedupMs` is set (>0) and an identical prior deny landed within that
2210
+ * window, the current call is a sibling hook echoing the SAME event (see
2211
+ * {@link module:burst-window}): it returns the prior verdict VERBATIM with
2212
+ * `deduped:true` and does NOT bump the count, so all N fan-out processes agree
2213
+ * on one number instead of counting to N. Absent `dedupMs` (mono-process
2214
+ * callers / unit tests) the historical increment-every-time behaviour holds.
2215
+ * @param hash - {@link denyHash}-derived map key of the current call.
2216
+ * @param priorDenies - The `{ hash -> DenyEntry }` map, pruned to `now`/`windowMs`.
2217
+ * @param opts - Clock + window, plus an optional burst-dedup window.
2218
+ * @returns `{ isRepeat, count, hash, deduped? }`.
2142
2219
  */
2220
+ function denyLoopCheck(hash, priorDenies, opts) {
2221
+ const prev = priorDenies[hash];
2222
+ if (!(prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs)) return {
2223
+ isRepeat: false,
2224
+ count: 1,
2225
+ hash
2226
+ };
2227
+ if ((opts.dedupMs ?? 0) > 0 && opts.now - prev.lastTs < (opts.dedupMs ?? 0)) return {
2228
+ isRepeat: prev.count > 1,
2229
+ count: prev.count,
2230
+ hash,
2231
+ deduped: true
2232
+ };
2233
+ const count = prev.count + 1;
2234
+ return {
2235
+ isRepeat: count > 1,
2236
+ count,
2237
+ hash
2238
+ };
2239
+ }
2143
2240
  /**
2144
- * Resolve the marketplace plugins dir from `CLAUDE_PLUGIN_ROOT`, mirroring the
2145
- * Python hook which passes `${CLAUDE_PLUGIN_ROOT}/..` to `generate_map.py`.
2146
- * @returns The plugins dir (env `/..`), or `undefined` to fall back to auto-detect.
2241
+ * Enrich a REPEATED block prompt a NEW object, never a mutation (the input may
2242
+ * be a shared const like FAIL_CLOSED). The decision stays `block`; only the
2243
+ * message changes, so every harness renders it through the same adapter.
2244
+ * @param prompt - The original block prompt.
2245
+ * @param count - The running identical-deny count (n).
2246
+ * @returns A block prompt with `[REPEAT]` title, STOP-prefixed reason, forced research action.
2147
2247
  */
2148
- function pluginsDirFromEnv() {
2149
- const root = process.env.CLAUDE_PLUGIN_ROOT;
2150
- return root ? resolve(root, "..") : void 0;
2248
+ function enrichRepeatDeny(prompt, count) {
2249
+ const stop = `Identical attempt #${count} already denied for the same reason. STOP: do not retry this same call. `;
2250
+ const action = "Launch fuse-ai-pilot:research-expert to find a DIFFERENT approach";
2251
+ return {
2252
+ ...prompt,
2253
+ title: prompt.title.startsWith("[REPEAT]") ? prompt.title : `[REPEAT] ${prompt.title}`,
2254
+ reason: stop + prompt.reason,
2255
+ actions: [action, ...prompt.actions ?? []]
2256
+ };
2151
2257
  }
2258
+ //#endregion
2259
+ //#region src/tracking/one-shot-dedup.ts
2152
2260
  /**
2153
- * Regenerate the project map + plugin ecosystem map for `cwd` on SessionStart.
2154
- * Emits the ecosystem navigation context as additionalContext (or "").
2155
- * @param cwd - The working directory.
2156
- * @param now - Clock for the ecosystem map banner timestamp.
2157
- * @returns The SessionStart additionalContext response, or "".
2261
+ * @module one-shot-dedup
2262
+ * Burst-dedup guard for the one-shot metric ({@link module:one-shot}).
2263
+ *
2264
+ * ONE Claude tool event fans out to ~11 sibling plugin-hook processes, each
2265
+ * calling {@link recordOneShot}; without this the metric would count a single
2266
+ * deny/allow ~11×. Reuses the proven {@link oncePerWindow} cooldown sidecar:
2267
+ * the FIRST process in the {@link module:burst-window} window mutates the
2268
+ * metric, the rest skip. The dedup key includes the outcome KIND (deny-title vs
2269
+ * allow) so a deny and its later fix — different kinds — are never folded into
2270
+ * each other. No `sessionId` → always the first (mono-process + unit-test
2271
+ * parity; a burst can only exist when a real session drives the fan-out).
2272
+ * @packageDocumentation
2158
2273
  */
2159
- function cartoSessionStart(cwd, now = Date.now()) {
2160
- generateProjectMap(cwd);
2161
- const ctx = generateEcosystemMap(now, pluginsDirFromEnv());
2162
- return ctx ? contextResponse("SessionStart", ctx) : "";
2274
+ /**
2275
+ * True when this `(op, kind)` is the FIRST of its burst for the session — the
2276
+ * process that should actually mutate the metric. Sibling processes firing the
2277
+ * SAME event within {@link BURST_DEDUP_MS} return false and skip the write.
2278
+ * @param op - Content-free operation key ({@link denyHash}("op", …)).
2279
+ * @param kind - Outcome discriminator (`deny:<title>` or `allow`).
2280
+ * @param opts - Clock + state dir + optional session id.
2281
+ * @returns `true` to apply the record, `false` to skip (already counted).
2282
+ */
2283
+ function burstFirst(op, kind, opts) {
2284
+ const sid = opts.sessionId?.trim();
2285
+ if (!sid) return true;
2286
+ return oncePerWindow(`oneshot:${sid}:${op}:${kind}`, BURST_DEDUP_MS, {
2287
+ now: opts.now,
2288
+ dir: opts.dir
2289
+ });
2163
2290
  }
2164
2291
  //#endregion
2165
- //#region src/runtime/lifecycle/aipilot/lesson-parse.ts
2166
- /** Milliseconds in a day. */
2167
- const DAY_MS = 864e5;
2168
- /** Case-sensitive decision-time tag line (`[TRIGGERS …]`) opus-lessons format. */
2169
- const TRIG = /^\[TRIGGERS\s+.+\]$/;
2170
- /** Epoch ms for a `[YYYY-MM-DD HH:MM]` stamp; `NaN` if absent or out of range. */
2171
- function parseTs$1(line) {
2172
- const m = line.match(/\[(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/);
2173
- if (!m) return NaN;
2174
- const mo = +(m[2] ?? 0), d = +(m[3] ?? 0);
2175
- if (mo < 1 || mo > 12 || d < 1 || d > 31) return NaN;
2176
- return Date.UTC(+(m[1] ?? 0), mo - 1, d, +(m[4] ?? 0), +(m[5] ?? 0));
2177
- }
2178
- /** Content words (>=4 chars), timestamp & TRIGGERS marker stripped. */
2179
- function tokenize(text) {
2180
- return new Set(text.toLowerCase().replace(/\[triggers[^\]]*\]/g, " ").replace(/\[\d{4}-\d{2}-\d{2}[^\]]*\]/g, " ").replace(/[^a-z0-9àâäéèêëîïôöùûüç/._-]+/gi, " ").split(/\s+/).filter((t) => t.length >= 4));
2181
- }
2182
- /** Jaccard overlap of two token sets (0 when both empty). */
2183
- function jaccard(a, b) {
2184
- if (a.size === 0 && b.size === 0) return 0;
2185
- const inter = [...a].filter((t) => b.has(t)).length;
2186
- return inter / (a.size + b.size - inter);
2187
- }
2188
- /** Repo-relative cited paths (slash + extension) referenced in a block. */
2189
- function citedPaths(text) {
2190
- const out = /* @__PURE__ */ new Set();
2191
- for (const m of text.matchAll(/`([^`]+)`/g)) if (m[1]) out.add(m[1]);
2192
- for (const m of text.matchAll(/[\w./@-]+\.\w{1,5}/g)) if (m[0]) out.add(m[0]);
2193
- return [...out].filter((p) => p.includes("/") && /\.\w{1,5}$/.test(p));
2194
- }
2195
- /** True when a block carries a `[TRIGGERS …]` continuation line. */
2196
- function hasTrigger(b) {
2197
- return b.raw.some((l) => TRIG.test(l.trim()));
2198
- }
2199
- /** Split content into a verbatim preamble and one Block per `- ` bullet. */
2200
- function parse(content) {
2201
- const lines = content.split("\n");
2202
- const blocks = [];
2203
- let i = 0;
2204
- while (i < lines.length && !/^-\s/.test(lines[i] ?? "")) i++;
2205
- const preamble = lines.slice(0, i).join("\n");
2206
- for (; i < lines.length; i++) {
2207
- const l = lines[i] ?? "", last = blocks[blocks.length - 1];
2208
- if (/^-\s/.test(l)) blocks.push({
2209
- raw: [l],
2210
- ts: parseTs$1(l),
2211
- tokens: tokenize(l)
2212
- });
2213
- else if (l.trim() && last) last.raw.push(l);
2292
+ //#region src/tracking/one-shot.ts
2293
+ /**
2294
+ * @module one-shot
2295
+ * Sidecar store + gate wiring for the per-gate one-shot metric.
2296
+ *
2297
+ * STATE a standalone sidecar (`one-shot.json`) in the same per-project state dir
2298
+ * as the session track, mirroring {@link module:deny-loop-store} (atomicWrite,
2299
+ * prune-by-window, fail-safe). A write error NEVER changes a gate decision nor its
2300
+ * prompt metrics are pure observation.
2301
+ *
2302
+ * KEY the operation identity is content-FREE (`tool + filePath/command`): a fix
2303
+ * changes the content, so a content hash would make every retry a new op and hide
2304
+ * the deny→allow transition this metric exists to see. The pure model lives in
2305
+ * {@link module:one-shot-store}; this file is the only IO surface.
2306
+ * @packageDocumentation
2307
+ */
2308
+ /** Sidecar basename under the per-project state dir. */
2309
+ const SIDECAR$1 = "one-shot.json";
2310
+ /** Retention window: 7 days. Aggregates and pending denies older than this are pruned. */
2311
+ const WINDOW_MS = 10080 * 60 * 1e3;
2312
+ /** Load the state, or a fresh copy when missing/corrupt. */
2313
+ function loadState(path) {
2314
+ try {
2315
+ if (!existsSync(path)) return { ...EMPTY };
2316
+ const d = JSON.parse(readFileSync(path, "utf8"));
2317
+ return d && typeof d === "object" && !Array.isArray(d) ? {
2318
+ ...EMPTY,
2319
+ ...d
2320
+ } : { ...EMPTY };
2321
+ } catch {
2322
+ return { ...EMPTY };
2214
2323
  }
2215
- return {
2216
- preamble,
2217
- blocks
2218
- };
2219
2324
  }
2220
- //#endregion
2221
- //#region src/runtime/lifecycle/aipilot/lesson-archive.ts
2222
2325
  /**
2223
- * Stage 1 cap→archive split for LESSON.md. When deduped bullets exceed CAP the
2224
- * OLDEST excess is MOVED (never deleted) to LESSON-archive.md, EXCEPT a
2225
- * `[TRIGGERS …]` bullet younger than STALE_DAYS: archiving it would blind the
2226
- * PreToolUse trigger index (src/policy/lessons/trigger-index reads LESSON.md), so
2227
- * it stays even past the cap. Pure: this module decides the partition and renders
2228
- * the archive block; the fail-safe, archive-first file write is the caller's job.
2326
+ * Record a gate outcome: a `block` is a deny for its gate title; a `null` allow is
2327
+ * a fix (if the op was pending) or a one-shot (if gateable). `ask`/`inform` are
2328
+ * neither and are skipped. Fails silently a metric write NEVER affects a decision.
2329
+ *
2330
+ * The op key is tool-INDEPENDENT (`filePath`/`command` only, constant `"op"` tool):
2331
+ * a deny (a `Write`) and its fix (an `Edit`) on the same file must link.
2332
+ * @param prompt - The gate's outcome (block, allow=null, or ask/inform).
2333
+ * @param input - Identifying tool input (content decides gateability only).
2334
+ * @param opts - Clock + state dir.
2229
2335
  */
2230
- /** Sort key: undated bullets sort oldest, so malformed entries archive first. */
2231
- function age(b) {
2232
- return Number.isNaN(b.ts) ? -Infinity : b.ts;
2233
- }
2234
- /** A TRIGGERS bullet is protected from archival until older than STALE_DAYS. */
2235
- function isProtected(b, staleBefore) {
2236
- return hasTrigger(b) && !(b.ts <= staleBefore);
2336
+ function recordOneShot(prompt, input, opts) {
2337
+ try {
2338
+ if (prompt && prompt.kind !== "block") return;
2339
+ const op = denyHash("op", {
2340
+ filePath: input.filePath,
2341
+ command: input.command
2342
+ });
2343
+ if (!burstFirst(op, prompt ? `deny:${prompt.title}` : "allow", opts)) return;
2344
+ const path = join(opts.dir, SIDECAR$1);
2345
+ let s = pruneState(loadState(path), opts.now, WINDOW_MS);
2346
+ s = prompt ? applyDeny(s, prompt.title, op, opts.now) : applyAllow(s, op, opts.now, input.content != null || input.command != null);
2347
+ atomicWrite(path, JSON.stringify(s));
2348
+ } catch {}
2237
2349
  }
2238
2350
  /**
2239
- * Partition deduped `blocks` (newest-first file order) into the bullets that
2240
- * stay in LESSON.md and the oldest excess to archive. Archives only enough to
2241
- * reach CAP, skipping protected TRIGGERS bullets (so the file MAY stay slightly
2242
- * over cap by design). Order is preserved in both halves; `keep archive` is
2243
- * exactly `blocks` with no loss and no mutation.
2244
- * @param blocks - Deduped bullets, newest first.
2245
- * @param now - Clock (ms) for the STALE_DAYS protection window.
2246
- * @returns `{ keep, archive }` — a lossless partition of `blocks`.
2351
+ * Compact, injection-ready one-shot summary for the project rooted at `cwd`. The
2352
+ * state dir is derived EXACTLY like the runtime writer ({@link defaultStateDir},
2353
+ * mirroring `handle.ts` `trackFile(sid, defaultStateDir(cwd))`), so the file read
2354
+ * here is the same one {@link recordOneShot} wrote. "" when no data or read error.
2355
+ * @param cwd - The project working directory (Claude `cwd`), NOT the state dir.
2356
+ * @returns One line, e.g. `gates 7d: 88% one-shot (44/50 clean); ...`, or "".
2247
2357
  */
2248
- function splitAtCap(blocks, now) {
2249
- if (blocks.length <= 50) return {
2250
- keep: blocks,
2251
- archive: []
2252
- };
2253
- const staleBefore = now - 90 * DAY_MS;
2254
- const oldestFirst = [...blocks].sort((a, b) => age(a) - age(b));
2255
- const toArchive = /* @__PURE__ */ new Set();
2256
- let excess = blocks.length - 50;
2257
- for (const b of oldestFirst) {
2258
- if (excess <= 0) break;
2259
- if (isProtected(b, staleBefore)) continue;
2260
- toArchive.add(b);
2261
- excess--;
2358
+ function oneShotSummary(cwd) {
2359
+ try {
2360
+ return formatSummary(pruneState(loadState(join(defaultStateDir(cwd), SIDECAR$1)), Date.now(), WINDOW_MS));
2361
+ } catch {
2362
+ return "";
2262
2363
  }
2263
- return {
2264
- keep: blocks.filter((b) => !toArchive.has(b)),
2265
- archive: blocks.filter((b) => toArchive.has(b))
2266
- };
2267
2364
  }
2365
+ //#endregion
2366
+ //#region src/tracking/one-shot-failure.ts
2268
2367
  /**
2269
- * Render `archive` bullets as a dated block to PREPEND to LESSON-archive.md
2270
- * (newest archive session on top). Bullets are emitted BYTE-IDENTICAL (raw lines
2271
- * rejoined) zero mutation, so the move stays reversible/auditable.
2272
- * @param archive - Bullets chosen by {@link splitAtCap}.
2273
- * @param now - Clock (ms) for the archival header date.
2274
- * @returns The block text (trailing newline), or "" when nothing is archived.
2368
+ * @module one-shot-failure
2369
+ * PostToolUseFailure tally for the one-shot sidecar. A pure per-tool counter
2370
+ * ({@link applyFailure}) plus its burst-deduped IO writer ({@link recordFailure}),
2371
+ * kept out of {@link module:one-shot-store} so that file stays under the SOLID
2372
+ * size limit. Reuses the SAME sidecar, prune window, and burst-dedup as
2373
+ * {@link module:one-shot} failures never touch the deny/allow gate rate.
2374
+ * @packageDocumentation
2275
2375
  */
2276
- function formatArchive(archive, now) {
2277
- if (archive.length === 0) return "";
2278
- 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`;
2279
- }
2280
- //#endregion
2281
- //#region src/runtime/lifecycle/aipilot/curate-lessons.ts
2282
2376
  /**
2283
- * Stage-0 mechanical, LLM-free dedup of MEMORY/LESSON.md bullets + cap→archive
2284
- * orchestration. Strict-dedup near-identical bullets (keep newest, `[TRIGGERS …]`
2285
- * preserved), then hand the deduped set to lesson-archive's cap split. Returns the
2286
- * rewritten LESSON.md content, the archive block to move out, and a human report.
2287
- * Pure: all file I/O (archive-first, fail-safe) lives in the dispatch caller.
2377
+ * Bump the per-tool failure count. Orthogonal to gates/pending, so it can never
2378
+ * distort the one-shot rate.
2379
+ * @param s - Current state.
2380
+ * @param tool - The failing tool name (`unknown` when absent).
2381
+ * @param now - Clock.
2382
+ * @returns The next state with `failures[tool]` incremented.
2288
2383
  */
2289
- const SIM_THRESHOLD = .8;
2290
- const MIN_TOKENS = 4;
2291
- /** Report lines for bullets older than STALE_DAYS whose only cited path is gone. */
2292
- function staleReport(blocks, now, root) {
2293
- const cutoff = now - 90 * DAY_MS;
2294
- return blocks.flatMap((b) => {
2295
- if (!(b.ts <= cutoff)) return [];
2296
- const paths = citedPaths(b.raw.join(" "));
2297
- if (paths.length === 0 || paths.some((p) => existsSync(join(root, p)))) return [];
2298
- return [`[STALE?] ${(b.raw[0] ?? "").slice(0, 90)} — missing path(s): ${paths.join(", ")}`];
2299
- });
2300
- }
2301
- /** Strict-dedup: keep the newest of each near-identical pair (TRIGGERS carried over). Returns kept blocks + merge report lines. */
2302
- function dedup(blocks) {
2303
- const kept = [];
2304
- const fused = [];
2305
- for (const b of blocks) {
2306
- const hit = b.tokens.size >= MIN_TOKENS ? kept.find((k) => k.tokens.size >= MIN_TOKENS && jaccard(k.tokens, b.tokens) >= SIM_THRESHOLD) : void 0;
2307
- if (!hit) {
2308
- kept.push(b);
2309
- continue;
2310
- }
2311
- const [win, drop] = b.ts > hit.ts || Number.isNaN(hit.ts) ? [b, hit] : [hit, b];
2312
- if (win !== hit) kept[kept.indexOf(hit)] = win;
2313
- if (!win.raw.some((l) => TRIG.test(l.trim()))) {
2314
- const t = drop.raw.find((l) => TRIG.test(l.trim()));
2315
- if (t) win.raw.push(t);
2316
- }
2317
- fused.push(`merged: kept ${(win.raw[0] ?? "").slice(0, 60)} · dropped ${(drop.raw[0] ?? "").slice(0, 60)}`);
2318
- }
2384
+ function applyFailure(s, tool, now) {
2319
2385
  return {
2320
- kept,
2321
- fused
2386
+ ...s,
2387
+ failures: {
2388
+ ...s.failures ?? {},
2389
+ [tool]: (s.failures?.[tool] ?? 0) + 1
2390
+ },
2391
+ updatedAt: now
2322
2392
  };
2323
2393
  }
2324
2394
  /**
2325
- * Dedup LESSON.md bullets, then archive the oldest excess over CAP (via
2326
- * lesson-archive). `content` is byte-identical to the input when nothing is
2327
- * deduped or archived. The `archive` block (possibly "") is what the caller must
2328
- * PREPEND to LESSON-archive.md, archive-first, before writing `content`.
2329
- * @param content - Raw LESSON.md text.
2330
- * @param now - Clock (ms) for stale/archival windows.
2331
- * @param root - Project root, for resolving cited paths in the stale report.
2332
- * @returns The rewritten content, the archive block, and the report.
2395
+ * Persist a PostToolUseFailure into the one-shot sidecar, burst-deduped across the
2396
+ * ~11-process fan-out (same window/store as {@link recordOneShot}). Fail-safe: a
2397
+ * write error never propagates out of the hook.
2398
+ * @param tool - The failing tool name.
2399
+ * @param opts - Clock + state dir + optional session id (arms the burst dedup).
2333
2400
  */
2334
- function curateLessons(content, now, root = process.cwd()) {
2335
- const { preamble, blocks } = parse(content);
2336
- const { kept, fused } = dedup(blocks);
2337
- const { keep, archive } = splitAtCap(kept, now);
2338
- const rebuilt = fused.length > 0 || archive.length > 0 ? `${preamble}\n${keep.map((b) => b.raw.join("\n")).join("\n\n")}\n` : content;
2339
- const capReport = archive.length ? [`${kept.length} bullets (> 50) — ${archive.length} oldest archived → LESSON-archive.md`] : [];
2340
- const report = [
2341
- ...fused,
2342
- ...capReport,
2343
- ...staleReport(blocks, now, root)
2344
- ].join("\n");
2345
- return {
2346
- content: rebuilt,
2347
- archive: formatArchive(archive, now),
2348
- report
2349
- };
2350
- }
2351
- /** The `[YYYY-MM-DD HH:MM]` (or date-only) stamp of a bullet, "" if absent. */
2352
- function stamp(block) {
2353
- return (block.raw[0] ?? "").match(/\[(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)\]/)?.[1] ?? "";
2354
- }
2355
- /** Bullet text: raw lines joined, leading "- ", date stamp & TRIGGERS lines stripped. */
2356
- function bodyText(block) {
2357
- return block.raw.filter((l) => !/^\s*\[TRIGGERS\s/.test(l)).join(" ").replace(/^-\s*/, "").replace(/\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*/, "").trim();
2358
- }
2359
- /** First sentence of `s` (split on a period + whitespace), whole string if none. */
2360
- function firstSentence(s) {
2361
- return (s.split(/(?<=\.)\s/)[0] ?? s).trim();
2401
+ function recordFailure(tool, opts) {
2402
+ try {
2403
+ if (!burstFirst(`fail:${tool}`, "failure", opts)) return;
2404
+ const path = join(opts.dir, SIDECAR$1);
2405
+ const next = applyFailure(pruneState(loadState(path), opts.now, WINDOW_MS), tool, opts.now);
2406
+ atomicWrite(path, JSON.stringify(next));
2407
+ } catch {}
2362
2408
  }
2409
+ //#endregion
2410
+ //#region src/runtime/lifecycle/failure-lesson.ts
2363
2411
  /**
2364
- * Distil the actionable rule from a bullet body. With no "→" the whole bullet is
2365
- * the rule its first sentence. Otherwise the rule is everything after the FIRST
2366
- * "→"; among its arrow-delimited segments (trimmed, empty dropped) take the first
2367
- * sentence of the LONGEST — the information-dense clause, not whichever short
2368
- * aside the author appended last. When that clause is under {@link MIN_RULE}
2369
- * chars, fall back to the first sentence of the WHOLE rule part (never the
2370
- * narrative), so a short trailing segment never yields an illegible stub yet a
2371
- * legitimately terse rule is still shown intact.
2412
+ * @module failure-lesson
2413
+ * PostToolUseFailure handler: keep the failure log, record the failure in the
2414
+ * one-shot metric, and inject the ONE most-specific lesson whose `error:` trigger
2415
+ * matches the failure message reusing the PreToolUse {@link lessonFor} index and
2416
+ * its cooldown (idempotent under the ~11-process fan-out). Fail-open throughout.
2417
+ *
2418
+ * Claude-Code-only: no equivalent `PostToolUseFailure` hook exists on Codex or
2419
+ * Hermes, so this handler is never reached through those adapters.
2420
+ * @packageDocumentation
2372
2421
  */
2373
- function distillRule(text) {
2374
- const arrow = text.indexOf("→");
2375
- if (arrow < 0) return firstSentence(text);
2376
- const rulePart = text.slice(arrow + 1);
2377
- const rule = firstSentence(rulePart.split("→").map((s) => s.trim()).filter(Boolean).reduce((a, b) => b.length > a.length ? b : a, ""));
2378
- return rule.length >= 40 ? rule : firstSentence(rulePart);
2379
- }
2380
- /** Collapse one older bullet to `- [date] <rule>`: {@link distillRule}, capped. */
2381
- function compressBullet(block) {
2382
- let rule = distillRule(bodyText(block));
2383
- if (rule.length > 200) rule = `${rule.slice(0, 199).trimEnd()}…`;
2384
- const date = stamp(block);
2385
- return `- ${date ? `[${date}] ` : ""}${rule}`;
2422
+ /** The failure message across the documented `error` field and defensive fallbacks; "" when none. */
2423
+ function failureError(data) {
2424
+ const raw = data.error ?? data.tool_error ?? data.tool_output;
2425
+ if (typeof raw === "string") return raw;
2426
+ return raw != null ? JSON.stringify(raw) : "";
2386
2427
  }
2387
2428
  /**
2388
- * Build the compressed injection body for `content`. The preamble comments are
2389
- * dropped (format docs, noise for the reader); the `recentFull` newest bullets
2390
- * stay whole, every older bullet becomes one distilled rule-line.
2391
- * @param content - Raw LESSON.md text.
2392
- * @param recentFull - Count of newest bullets to keep verbatim.
2393
- * @returns The compressed block (bullets only), or the trimmed content when there are no bullets.
2394
- */
2395
- function compressInjection(content, recentFull = 10) {
2396
- const { blocks } = parse(content);
2397
- if (blocks.length === 0) return content.trim();
2398
- const full = blocks.slice(0, recentFull).map((b) => b.raw.join("\n"));
2399
- const rest = blocks.slice(recentFull).map(compressBullet);
2400
- return [...full, ...rest].join("\n");
2401
- }
2402
- //#endregion
2403
- //#region src/runtime/lifecycle/lessons/state.ts
2404
- /**
2405
- * Per-project lessons paths. The `fuse-lessons` plugin stores its lessons under
2406
- * `<root>/MEMORY/` (NOT the harness `.harness/memory/`), so these two path
2407
- * helpers override the layout while ALL state/gitignore/throttle logic is
2408
- * reused from `src/memory` (`setStateField`, `ensureMemoryGitignore`,
2409
- * `readState`, `nowStamp`, `throttleMs`).
2429
+ * Handle PostToolUseFailure: log the failure, tally it per tool, and inject the
2430
+ * matching `error:`-triggered lesson as `additionalContext` ("" when none).
2431
+ * @param data - The raw PostToolUseFailure payload (`tool_name`, `error`, `session_id`).
2432
+ * @param cwd - Project root.
2433
+ * @param home - Home dir (defaults to `~`).
2434
+ * @param now - Clock (defaults to `Date.now()`).
2435
+ * @param once - Cooldown gate (injected for tests; defaults to {@link oncePerWindow}).
2436
+ * @returns The native hook stdout, or "" when nothing to inject.
2410
2437
  */
2411
- /** Absolute `<root>/MEMORY/LESSON.md` the curated, committable lessons file. */
2412
- function lessonsFileFor(root) {
2413
- return join(root, "MEMORY", "LESSON.md");
2414
- }
2415
- /** Absolute `<root>/MEMORY/LESSON-archive.md` — cold storage for capped-out bullets. */
2416
- function lessonsArchiveFileFor(root) {
2417
- return join(root, "MEMORY", "LESSON-archive.md");
2418
- }
2419
- /** Absolute `<root>/MEMORY/state.json` — machine-local throttle counter. */
2420
- function lessonsStateFileFor(root) {
2421
- return join(root, "MEMORY", "state.json");
2438
+ function failureLessonContext(data, cwd, home = homedir(), now = Date.now(), once = oncePerWindow) {
2439
+ logToolFailure(data, home, now);
2440
+ const tool = typeof data.tool_name === "string" ? data.tool_name : "unknown";
2441
+ const sessionId = typeof data.session_id === "string" ? data.session_id : void 0;
2442
+ try {
2443
+ recordFailure(tool, {
2444
+ now,
2445
+ dir: defaultStateDir(cwd),
2446
+ sessionId
2447
+ });
2448
+ } catch {}
2449
+ const errorMsg = failureError(data);
2450
+ if (!errorMsg) return "";
2451
+ const lesson = lessonFor("", {}, {
2452
+ file: lessonsFileFor(projectRoot(cwd)),
2453
+ once,
2454
+ prevError: errorMsg
2455
+ });
2456
+ return lesson?.reason ? contextResponse("PostToolUseFailure", lesson.reason) : "";
2422
2457
  }
2423
2458
  //#endregion
2424
- //#region src/memory/session-roots.ts
2459
+ //#region src/runtime/lifecycle/snapshot/git.ts
2425
2460
  /**
2426
- * Session-scoped lessons roots registry. The flat {@link module:memory/registry}
2427
- * keeps ONE global list of pending roots correct mono-session, but wrong with
2428
- * several concurrent Claude Code sessions: at Stop, one session lists (and, by
2429
- * bumping the throttle, STEALS) another session's pending lesson on a project it
2430
- * never touched. This registry keys "which project got code edits, and was its
2431
- * Stop reminder already fired" by `session_id`, so each Stop sees and consumes
2432
- * ONLY its own roots. Stored at `$HOME/.fuse-harness/cache/lessons/session-roots.json`;
2433
- * non-fatal on any I/O failure (a missed reminder never blocks a session).
2461
+ * Run a git subcommand at `root` with a short timeout, returning trimmed stdout.
2462
+ * Uses `node:child_process` (the Bun shell can hang on some git plumbing) and
2463
+ * swallows every failure a non-repo, missing git, or timeout yields `""` so
2464
+ * the caller omits the section instead of throwing inside the hook.
2465
+ * @param root - Directory to run git in.
2466
+ * @param args - The git args (e.g. `"log --oneline -3"`).
2467
+ * @returns Trimmed stdout, or `""` on any error.
2434
2468
  */
2435
- /** Registry path (rel. home) + stale-bucket purge horizon (bounds growth). */
2436
- const SUBPATH = ".fuse-harness/cache/lessons/session-roots.json";
2437
- const PURGE_MS = 10080 * 60 * 1e3;
2438
- /** Absolute registry path, or null when home is unusable. */
2439
- function file(home) {
2440
- const h = home?.trim();
2441
- return h && h.startsWith("/") ? `${h}/${SUBPATH}` : null;
2442
- }
2443
- /** Read the registry; missing/corrupt/legacy (array) shapes collapse to `{}`. */
2444
- function read(home) {
2445
- const f = file(home);
2446
- if (!f) return {};
2469
+ function git(root, args) {
2447
2470
  try {
2448
- const parsed = JSON.parse(readFileSync(f, "utf8"));
2449
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2471
+ return execSync(`git ${args}`, {
2472
+ cwd: root,
2473
+ encoding: "utf8",
2474
+ timeout: 150,
2475
+ stdio: [
2476
+ "ignore",
2477
+ "pipe",
2478
+ "ignore"
2479
+ ]
2480
+ }).trim();
2450
2481
  } catch {
2451
- return {};
2482
+ return "";
2452
2483
  }
2453
2484
  }
2454
- /** Purge stale buckets, then atomically persist (unique tmp + rename). Non-throwing. */
2455
- function write(home, reg, now) {
2456
- const f = file(home);
2457
- if (!f) return;
2458
- for (const [sid, entry] of Object.entries(reg)) if (!entry || now - (entry.updatedAt ?? 0) > PURGE_MS) delete reg[sid];
2459
- try {
2460
- mkdirSync(dirname(f), { recursive: true });
2461
- atomicWrite(f, JSON.stringify(reg));
2462
- } catch {}
2463
- }
2464
- /** Record `field` for `(sid, root)`, refreshing the purge cursor. `home` defaults to `$HOME`. */
2465
- function markSessionRoot(sid, root, field, value, home = process.env.HOME) {
2466
- const reg = read(home);
2467
- const prev = reg[sid];
2468
- const entry = prev && typeof prev.roots === "object" && prev.roots !== null ? prev : {
2469
- updatedAt: value,
2470
- roots: {}
2471
- };
2472
- const mark = entry.roots[root] ?? {
2473
- editedAt: 0,
2474
- remindedAt: 0
2475
- };
2476
- entry.roots[root] = {
2477
- ...mark,
2478
- [field]: value
2485
+ /** Count staged/unstaged/untracked files from porcelain v1 output (skips the `##` branch line). */
2486
+ function countWip(porcelain) {
2487
+ const w = {
2488
+ staged: 0,
2489
+ unstaged: 0,
2490
+ untracked: 0
2479
2491
  };
2480
- entry.updatedAt = value;
2481
- reg[sid] = entry;
2482
- write(home, reg, value);
2492
+ for (const line of porcelain.split("\n")) {
2493
+ if (!line || line.startsWith("#")) continue;
2494
+ if (line.startsWith("??")) {
2495
+ w.untracked++;
2496
+ continue;
2497
+ }
2498
+ const x = line[0], y = line[1];
2499
+ if (x && x !== " " && x !== "?") w.staged++;
2500
+ if (y === "M" || y === "D") w.unstaged++;
2501
+ }
2502
+ return w;
2503
+ }
2504
+ /** Parse the current branch from the leading `## branch...upstream` porcelain line. */
2505
+ function parseBranch(porcelain) {
2506
+ const head = porcelain.split("\n")[0] ?? "";
2507
+ if (!head.startsWith("## ")) return "";
2508
+ const rest = head.slice(3);
2509
+ const dots = rest.indexOf("...");
2510
+ return (dots >= 0 ? rest.slice(0, dots) : rest).split(" ")[0] ?? "";
2483
2511
  }
2484
2512
  /**
2485
- * Roots of `sid` with an unsaved code edit past the `window`; each returned
2486
- * root's `remindedAt` is bumped to `now` so the reminder fires at most once per
2487
- * window and is consumed ONLY by this session. `home` defaults to `$HOME`.
2513
+ * Collect a compact git reconciliation section for `root`: current branch, the
2514
+ * last three commits (oneline), and staged/unstaged/untracked WIP counts. When
2515
+ * `root` is not a git repo (status fails) the whole section is omitted (`""`).
2516
+ * @param root - The project/repo root.
2517
+ * @returns The rendered git section body, or `""` when not a repo.
2488
2518
  */
2489
- function collectSessionPending(sid, now, window, home = process.env.HOME) {
2490
- const reg = read(home);
2491
- const entry = reg[sid];
2492
- if (!entry || typeof entry.roots !== "object" || entry.roots === null) return [];
2493
- const pending = [];
2494
- for (const [root, mark] of Object.entries(entry.roots)) {
2495
- if (mark.editedAt <= mark.remindedAt) continue;
2496
- if (now - mark.remindedAt < window) continue;
2497
- pending.push(root);
2498
- entry.roots[root] = {
2499
- ...mark,
2500
- remindedAt: now
2501
- };
2502
- }
2503
- if (pending.length > 0) write(home, reg, now);
2504
- return pending;
2519
+ function collectGit(root) {
2520
+ const status = git(root, "status --porcelain=v1 --branch");
2521
+ if (!status) return "";
2522
+ const branch = parseBranch(status) || "(unknown)";
2523
+ const w = countWip(status);
2524
+ const log = git(root, "log --oneline -3");
2525
+ const lines = [`- branch: ${branch}`];
2526
+ if (log) lines.push("- recent:", ...log.split("\n").map((l) => ` ${l}`));
2527
+ lines.push(`- WIP: ${w.staged} staged, ${w.unstaged} unstaged, ${w.untracked} untracked`);
2528
+ return lines.join("\n");
2505
2529
  }
2506
2530
  //#endregion
2507
- //#region src/runtime/lifecycle/lessons/reminder.ts
2531
+ //#region src/cli/doctor.ts
2508
2532
  /**
2509
- * fuse-lessons write-mark + Stop-reminder, scoped by `session_id` when present.
2533
+ * `harness doctor` diagnose which `@fusengine/harness` is actually running.
2510
2534
  *
2511
- * WITH a session id (normal Claude Code): each `(session, root)` pair carries
2512
- * its own edit/reminder throttle in {@link module:memory/session-roots}, so a
2513
- * Stop lists and silences ONLY the roots THAT session edited concurrent
2514
- * sessions on different projects never cross-remind nor steal each other's
2515
- * throttle. WITHOUT a usable session id (a harness that omits it, or the legacy
2516
- * on-disk state) it falls back to the original mono-session behavior: the global
2517
- * flat root registry + the per-project `MEMORY/state.json` throttle.
2535
+ * A confirmed, still-open bun bug (oven-sh/bun #5791; scoped-pkg behaviour
2536
+ * reinforced by #32019/#32150) makes `bunx <pkg>` (unpinned) prefer a stale
2537
+ * GLOBAL install over npm-latest, so a consumer can silently run an old harness
2538
+ * after a publish. This command surfaces the truth: the resolved version +
2539
+ * package path of the code executing right now, the runtime binary, and the
2540
+ * latest version published on npm. It queries the registry over HTTP (not
2541
+ * `npm view`, whose exit code is 0 even on an empty result — npm/cli#6408) and
2542
+ * never throws: an offline environment yields `latest: null`, never a crash.
2518
2543
  */
2519
- /** Sanitized session id from a raw hook payload, or null (→ legacy fallback). */
2520
- function sessionOf(payload) {
2521
- return sanitizeSessionId(payload.session_id);
2522
- }
2523
- /** Legacy (no session id): pending roots across the global flat registry. */
2524
- function collectLegacyPending(now, window) {
2525
- const pending = [];
2526
- for (const root of readRoots()) {
2527
- const stateFile = lessonsStateFileFor(root);
2528
- const { lastRemindedAt, lastCodeEditAt } = readState(stateFile);
2529
- if (lastCodeEditAt <= lastRemindedAt) continue;
2530
- if (now - lastRemindedAt < window) continue;
2531
- pending.push(root);
2532
- setStateField(stateFile, "lastRemindedAt", now);
2544
+ const PKG = "@fusengine/harness";
2545
+ /** Walk up from `startDir` for the `@fusengine/harness` `package.json`. */
2546
+ function findPackage(startDir) {
2547
+ let dir = startDir;
2548
+ for (let depth = 0; depth < 6; depth++) {
2549
+ try {
2550
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
2551
+ if (pkg.name === PKG) return {
2552
+ version: pkg.version ?? "unknown",
2553
+ path: dir
2554
+ };
2555
+ } catch {}
2556
+ const parent = dirname(dir);
2557
+ if (parent === dir) break;
2558
+ dir = parent;
2533
2559
  }
2534
- return pending;
2560
+ return null;
2535
2561
  }
2536
- /** Stop reminder body listing each pending project's lessons file. */
2537
- function reminderText(pending) {
2538
- 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")}`;
2562
+ /** Resolve the running version + package path (no network), from a module URL. */
2563
+ function runningVersion(moduleUrl) {
2564
+ const found = findPackage(dirname(fileURLToPath(moduleUrl)));
2565
+ return {
2566
+ version: found?.version ?? "unknown",
2567
+ path: found?.path ?? "unknown"
2568
+ };
2539
2569
  }
2540
- /**
2541
- * Stop: emit one reminder covering the stopping session's pending projects.
2542
- * @param payload - Raw hook payload (`session_id` selects the scoped path).
2543
- * @param now - Clock.
2544
- * @returns Native Stop stdout, or "" when nothing is pending.
2545
- */
2546
- function remindWrite(payload, now) {
2547
- const window = throttleMs();
2548
- const sid = sessionOf(payload);
2549
- const pending = sid ? collectSessionPending(sid, now, window) : collectLegacyPending(now, window);
2550
- if (pending.length === 0) return "";
2551
- return contextResponse("Stop", reminderText(pending));
2570
+ /** One-line `pkg vX.Y.Z` banner (stderr, only on explicit `--version`/`doctor` commands — never on `hook`, to avoid spamming automated invocations). */
2571
+ function versionBanner(moduleUrl) {
2572
+ return `${PKG} v${runningVersion(moduleUrl).version}`;
2552
2573
  }
2553
- /**
2554
- * PostToolUse: record the edit against the throttle. A code file arms the
2555
- * reminder; writing `MEMORY/LESSON.md` silences it (the lesson was just saved).
2556
- * Session-scoped when `session_id` is present, else the legacy global path.
2557
- * @param payload - Raw hook payload (`tool_input.file_path`, `session_id`).
2558
- * @param now - Clock.
2559
- */
2560
- function markWrite(payload, now) {
2561
- const input = payload.tool_input;
2562
- if (!input?.file_path) return;
2563
- const abs = resolve(input.file_path);
2564
- const root = projectRootOrNull(dirname(abs));
2565
- if (!root) return;
2566
- const isLesson = abs === resolve(root, "MEMORY", "LESSON.md");
2567
- if (!isLesson && !isCodeFile(abs)) return;
2568
- const sid = sessionOf(payload);
2569
- if (sid) markSessionRoot(sid, root, isLesson ? "remindedAt" : "editedAt", now);
2570
- else if (isLesson) setStateField(lessonsStateFileFor(root), "lastRemindedAt", now);
2571
- else {
2572
- setStateField(lessonsStateFileFor(root), "lastCodeEditAt", now);
2573
- addRoot(root);
2574
+ /** Latest published version via the npm registry HTTP API. `null` on any failure. */
2575
+ async function npmLatest() {
2576
+ try {
2577
+ const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(8e3) });
2578
+ if (!res.ok) return null;
2579
+ return (await res.json()).version ?? null;
2580
+ } catch {
2581
+ return null;
2574
2582
  }
2575
2583
  }
2584
+ /** Build the full diagnostic report for the module at `moduleUrl`. */
2585
+ async function buildDoctorReport(moduleUrl) {
2586
+ const { version, path } = runningVersion(moduleUrl);
2587
+ const latest = await npmLatest();
2588
+ return {
2589
+ running: version,
2590
+ packagePath: path,
2591
+ runtime: process.execPath,
2592
+ latest,
2593
+ stale: latest !== null && latest !== version
2594
+ };
2595
+ }
2596
+ /** Render a {@link DoctorReport} as human-readable stdout text. */
2597
+ function formatDoctor(r) {
2598
+ const lines = [
2599
+ `${PKG} doctor`,
2600
+ ` running: ${r.running}`,
2601
+ ` package: ${r.packagePath}`,
2602
+ ` runtime: ${r.runtime}`,
2603
+ ` npm latest: ${r.latest ?? "(unavailable — offline or unreachable)"}`
2604
+ ];
2605
+ if (r.stale) lines.push(` ! stale — npm serves ${r.latest}. Pin "@fusengine/harness@${r.latest}" in hooks.json (see README).`);
2606
+ else if (r.latest !== null) lines.push(` ok — running the latest published version.`);
2607
+ return lines.join("\n");
2608
+ }
2609
+ /** Run `harness doctor`: print the diagnostic to stdout. Always resolves 0 (pure info). */
2610
+ async function runDoctor(moduleUrl) {
2611
+ process.stdout.write(formatDoctor(await buildDoctorReport(moduleUrl)) + "\n");
2612
+ return 0;
2613
+ }
2576
2614
  //#endregion
2577
- //#region src/runtime/lifecycle/lessons/dispatch.ts
2615
+ //#region src/runtime/lifecycle/snapshot/version.ts
2616
+ /** Read the `version` field of `<root>/package.json`, or `""` if absent/unreadable. */
2617
+ function pkgVersion(root) {
2618
+ try {
2619
+ return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version ?? "";
2620
+ } catch {
2621
+ return "";
2622
+ }
2623
+ }
2578
2624
  /**
2579
- * fuse-lessons scope dispatch (TS port of the 4 handler scripts). Routes by
2580
- * event: SessionStart/SubagentStart inject `MEMORY/LESSON.md`; Stop reminds the
2581
- * stopping session about ITS OWN projects with unsaved code edits; PostToolUse
2582
- * marks the write to arm/silence the throttle. The reminder + mark logic (incl.
2583
- * the per-`session_id` scoping that fixes the multi-session misdirection) lives
2584
- * in {@link module:runtime/lifecycle/lessons/reminder}; this module keeps the
2585
- * event router + lesson-file injection. Non-fatal by design.
2625
+ * Collect the version reconciliation section: the harness version actually
2626
+ * running (resolved from {@link runningVersion}, no network) and, when `root`
2627
+ * carries its own `package.json`, whether that project's version has drifted
2628
+ * from the running harness.
2629
+ * @param root - The project root (cwd repo).
2630
+ * @param moduleUrl - `import.meta.url` of the calling module (locates the running package.json).
2631
+ * @returns The rendered version section body (never `""`).
2586
2632
  */
2633
+ function collectVersion(root, moduleUrl) {
2634
+ const running = runningVersion(moduleUrl).version;
2635
+ const lines = [`- harness running: v${running}`];
2636
+ const project = pkgVersion(root);
2637
+ if (project && project !== running) lines.push(`- project package.json: v${project} (DRIFT — running harness differs)`);
2638
+ else if (project) lines.push(`- project package.json: v${project} (in sync)`);
2639
+ return lines.join("\n");
2640
+ }
2641
+ //#endregion
2642
+ //#region src/runtime/lifecycle/snapshot/board.ts
2643
+ /** Max board characters injected — a persistent board should stay small; over-long boards are truncated. */
2644
+ const MAX_BOARD = 4e3;
2587
2645
  /**
2588
- * Persist a curation ATOMICALLY and ARCHIVE-FIRST for zero-loss: prepend the
2589
- * moved bullets to LESSON-archive.md, THEN rewrite LESSON.md. On ANY write error
2590
- * the original file is left untouched (returns `original`) so a bullet is never
2591
- * lost a rare archive-then-trim-fail leaves a duplicate (never a loss), which
2592
- * the next dedup pass reconciles.
2646
+ * Collect the persistent task board: the contents of `<root>/.claude/BOARD.md`
2647
+ * (truncated to {@link MAX_BOARD}) plus an instruction to keep it current. The
2648
+ * board lives on disk so it survives context purges rehydrated every session.
2649
+ * Missing/empty/unreadable board `""` (section omitted).
2650
+ * @param root - The project root.
2651
+ * @returns The rendered board section body, or `""` when there is no board.
2593
2652
  */
2594
- function persistCuration(file, root, curated, archive, original) {
2653
+ function collectBoard(root) {
2654
+ const path = join(root, ".claude", "BOARD.md");
2595
2655
  try {
2596
- if (archive) {
2597
- const af = lessonsArchiveFileFor(root);
2598
- const prev = existsSync(af) ? readFileSync(af, "utf-8") : "";
2599
- atomicWrite(af, prev ? `${archive}\n${prev}` : archive);
2600
- }
2601
- atomicWrite(file, curated);
2602
- return curated;
2656
+ if (!existsSync(path)) return "";
2657
+ let body = readFileSync(path, "utf8").trim();
2658
+ if (!body) return "";
2659
+ if (body.length > MAX_BOARD) body = `${body.slice(0, MAX_BOARD)}\n (truncated)`;
2660
+ return `- .claude/BOARD.md (keep current — Write to it as tasks start/finish):\n\n${body}`;
2603
2661
  } catch {
2604
- return original;
2662
+ return "";
2605
2663
  }
2606
2664
  }
2665
+ //#endregion
2666
+ //#region src/runtime/lifecycle/snapshot/format.ts
2607
2667
  /**
2608
- * Inject `MEMORY/LESSON.md` for `event`. Mechanical curation (dedup + cap→archive)
2609
- * rewrites the FILE; the injected BLOCK is then COMPRESSED (newest bullets whole,
2610
- * older ones distilled to their rule) so a growing file never inflates the
2611
- * SessionStart/SubagentStart context. Any curation report surfaces via systemMessage.
2668
+ * Render the non-empty `sections` under one reconciliation heading. Empty
2669
+ * sections are dropped; when every section is empty the whole snapshot is `""`.
2670
+ * Each surviving section is passed through {@link capFragment} (harness-produced
2671
+ * content, not owner CLAUDE.md), and a trailing {@link budgetReport} line gives
2672
+ * the owner-requested numeric visibility into what was actually injected.
2673
+ * @param sections - The collected sections in display order.
2674
+ * @returns The assembled markdown block, or `""` when nothing to report.
2612
2675
  */
2613
- function injectMemory(cwd, event, now) {
2614
- const root = projectRoot(cwd);
2615
- const file = lessonsFileFor(root);
2616
- if (!existsSync(file)) return "";
2617
- let content = "";
2676
+ function renderSections(sections) {
2677
+ const kept = sections.filter((s) => s.body.trim()).map((s) => ({
2678
+ title: s.title,
2679
+ body: capFragment(s.title, s.body.trim())
2680
+ }));
2681
+ if (!kept.length) return "";
2682
+ const header = "# Reconciliation snapshot\nReal state of the world at session start — reconcile against this instead of re-discovering it.";
2683
+ const parts = kept.map((s) => `### ${s.title}\n${s.body}`);
2684
+ const report = budgetReport(kept.map((s) => ({
2685
+ label: s.title,
2686
+ chars: s.body.length
2687
+ })));
2688
+ return `${header}\n\n${parts.join("\n\n")}\n\n_${report}_`;
2689
+ }
2690
+ /**
2691
+ * Concatenate `snapshot` onto an existing SessionStart stdout's
2692
+ * `additionalContext` — it never replaces prior injected context (CLAUDE.md,
2693
+ * dev-context). When `stdout` is empty a fresh {@link contextResponse} is made;
2694
+ * a non-empty but unparseable `stdout` is returned UNCHANGED (the snapshot is
2695
+ * dropped) — fabricating a fresh response there would discard the very CLAUDE.md
2696
+ * injection the invariant protects, so preserving prior context always wins.
2697
+ * @param stdout - The core SessionStart JSON stdout (may be `""`).
2698
+ * @param snapshot - The snapshot markdown to append (no-op when `""`).
2699
+ * @returns The merged hook stdout JSON.
2700
+ */
2701
+ function attachSnapshot(stdout, snapshot) {
2702
+ if (!snapshot) return stdout;
2703
+ if (!stdout) return contextResponse("SessionStart", snapshot);
2618
2704
  try {
2619
- content = readFileSync(file, "utf-8").trim();
2705
+ const parsed = JSON.parse(stdout);
2706
+ const prev = parsed.hookSpecificOutput?.additionalContext ?? "";
2707
+ const merged = prev ? `${prev}\n\n${snapshot}` : snapshot;
2708
+ return JSON.stringify({
2709
+ ...parsed,
2710
+ hookSpecificOutput: {
2711
+ ...parsed.hookSpecificOutput,
2712
+ hookEventName: "SessionStart",
2713
+ additionalContext: merged
2714
+ }
2715
+ });
2716
+ } catch {
2717
+ return stdout;
2718
+ }
2719
+ }
2720
+ //#endregion
2721
+ //#region src/runtime/lifecycle/snapshot/index.ts
2722
+ /** Run `fn`, swallowing any throw into `""` so no single collector can break the hook. */
2723
+ function safe(fn) {
2724
+ try {
2725
+ return fn();
2620
2726
  } catch {
2621
2727
  return "";
2622
2728
  }
2623
- if (!content) return "";
2624
- const { content: curated, archive, report } = curateLessons(content, now, root);
2625
- if (curated !== content) content = persistCuration(file, root, curated, archive, content);
2626
- 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.`;
2627
- return report ? attachSystemMessage(contextResponse(event, ctx), `LESSON.md curation:\n${report}`) : contextResponse(event, ctx);
2628
2729
  }
2629
2730
  /**
2630
- * Route a fuse-lessons event to its handler. Returns the native stdout for
2631
- * context-injecting events (SessionStart/SubagentStart/Stop) or "" for the
2632
- * side-effect-only PostToolUse mark.
2633
- * @param event - The raw hook event name.
2634
- * @param payload - The raw hook payload.
2635
- * @param cwd - Project root for memory injection.
2636
- * @param now - Clock.
2637
- * @returns The native stdout (possibly empty).
2731
+ * Build the reconciliation snapshot markdown for `cwd`: git state, running
2732
+ * harness version + drift, the persistent board, and one-shot gate status. Each
2733
+ * collector is isolated by {@link safe}; an all-empty result yields `""`.
2734
+ * @param cwd - The session working directory.
2735
+ * @param moduleUrl - `import.meta.url` of the caller (locates the running package).
2736
+ * @returns The snapshot markdown, or `""` when nothing to report.
2638
2737
  */
2639
- function dispatchLessons(event, payload, cwd, now) {
2640
- switch (event) {
2641
- case "SessionStart":
2642
- case "SubagentStart": return injectMemory(cwd, event, now);
2643
- case "Stop": return remindWrite(payload, now);
2644
- case "PostToolUse":
2645
- markWrite(payload, now);
2646
- return "";
2647
- default: return "";
2738
+ function renderSnapshot(cwd, moduleUrl) {
2739
+ const root = projectRootOrNull(cwd) ?? cwd;
2740
+ return renderSections([
2741
+ {
2742
+ title: "Git",
2743
+ body: safe(() => collectGit(root))
2744
+ },
2745
+ {
2746
+ title: "Version",
2747
+ body: safe(() => collectVersion(root, moduleUrl))
2748
+ },
2749
+ {
2750
+ title: "Board",
2751
+ body: safe(() => collectBoard(root))
2752
+ },
2753
+ {
2754
+ title: "One-shot gates",
2755
+ body: safe(() => oneShotSummary(cwd))
2756
+ }
2757
+ ]);
2758
+ }
2759
+ /**
2760
+ * Concatenate the reconciliation snapshot onto a core SessionStart stdout. Fully
2761
+ * fail-safe: any error returns `stdout` unchanged so the hook never breaks.
2762
+ * @param stdout - The core SessionStart JSON stdout (may be `""`).
2763
+ * @param cwd - The session working directory.
2764
+ * @param moduleUrl - `import.meta.url` of the caller.
2765
+ * @returns The merged hook stdout.
2766
+ */
2767
+ function withSnapshot(stdout, cwd, moduleUrl) {
2768
+ try {
2769
+ return attachSnapshot(stdout, renderSnapshot(cwd, moduleUrl));
2770
+ } catch {
2771
+ return stdout;
2648
2772
  }
2649
2773
  }
2650
2774
  //#endregion
2651
- //#region src/policy/deny-loop.ts
2775
+ //#region src/runtime/lifecycle/post-compact.ts
2652
2776
  /**
2653
- * @module deny-loop
2654
- * Pure anti-loop logic: hash a tool-call, decide if it repeats a prior deny, and
2655
- * enrich the repeated block's message.
2656
- *
2657
- * The proprietary rule "NEVER propose the same fix twice" is prose a model under
2658
- * pressure ignores. This makes it machine-enforced: when a call whose
2659
- * `(tool + normalized input)` hash was ALREADY denied in-window is retried, the
2660
- * harness keeps the deny but rewrites the message — `[REPEAT]` title, STOP
2661
- * prefix, forced `research-expert` action. State + wiring live in the sidecar
2662
- * store ({@link module:deny-loop-store}); this file is IO-free and pure.
2777
+ * @module post-compact
2778
+ * PostCompact handler (core scope): re-inject the reconciliation snapshot plus a
2779
+ * one-line reminder that read-state may have been reset by compaction, so the
2780
+ * agent re-reads files before editing. Deduped per session/window (compaction can
2781
+ * fan out too). Fully fail-open any error yields "".
2663
2782
  * @packageDocumentation
2664
2783
  */
2665
- /** Stable JSON: keys sorted at every depth so `{a,b}` and `{b,a}` hash identically. */
2666
- function stableStringify(v) {
2667
- if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
2668
- if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
2669
- const o = v;
2670
- return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(",")}}`;
2671
- }
2784
+ /** Re-inject at most once per 30s per session (compaction fan-out + retries). */
2785
+ const COMPACT_DEDUP_MS = 3e4;
2786
+ /** One-line reminder prepended to the re-injected snapshot. */
2787
+ const REMINDER = "Context was compacted — reread files before editing (read-state may be reset).";
2672
2788
  /**
2673
- * Stable identity hash of a tool-call = tool name + normalized (key-sorted) input,
2674
- * so re-ordered keys never mask a repeat.
2675
- * @param tool - Tool name (e.g. "Write", "Bash").
2676
- * @param input - Identifying tool input (filePath/content/command...).
2677
- * @returns 8-char hex hash.
2789
+ * Handle PostCompact: emit the reminder + reconciliation snapshot as
2790
+ * `additionalContext`, deduped per session. "" on dedup-suppress or any error.
2791
+ * @param data - The raw PostCompact payload (`session_id`, `trigger`).
2792
+ * @param cwd - Project root.
2793
+ * @param moduleUrl - `import.meta.url` of the caller (locates the running package for the version line).
2794
+ * @param now - Clock (defaults to `Date.now()`).
2795
+ * @returns The native hook stdout, or "".
2678
2796
  */
2679
- function denyHash(tool, input) {
2680
- return hashText(`${tool}\n${stableStringify(input)}`);
2681
- }
2682
- /**
2683
- * Pure loop check: given the already-pruned in-window map, compute the running
2684
- * count for `hash` and whether it repeats (count > 1). No IO — the caller persists.
2685
- *
2686
- * When `dedupMs` is set (>0) and an identical prior deny landed within that
2687
- * window, the current call is a sibling hook echoing the SAME event (see
2688
- * {@link module:burst-window}): it returns the prior verdict VERBATIM with
2689
- * `deduped:true` and does NOT bump the count, so all N fan-out processes agree
2690
- * on one number instead of counting to N. Absent `dedupMs` (mono-process
2691
- * callers / unit tests) the historical increment-every-time behaviour holds.
2692
- * @param hash - {@link denyHash}-derived map key of the current call.
2693
- * @param priorDenies - The `{ hash -> DenyEntry }` map, pruned to `now`/`windowMs`.
2694
- * @param opts - Clock + window, plus an optional burst-dedup window.
2695
- * @returns `{ isRepeat, count, hash, deduped? }`.
2696
- */
2697
- function denyLoopCheck(hash, priorDenies, opts) {
2698
- const prev = priorDenies[hash];
2699
- if (!(prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs)) return {
2700
- isRepeat: false,
2701
- count: 1,
2702
- hash
2703
- };
2704
- if ((opts.dedupMs ?? 0) > 0 && opts.now - prev.lastTs < (opts.dedupMs ?? 0)) return {
2705
- isRepeat: prev.count > 1,
2706
- count: prev.count,
2707
- hash,
2708
- deduped: true
2709
- };
2710
- const count = prev.count + 1;
2711
- return {
2712
- isRepeat: count > 1,
2713
- count,
2714
- hash
2715
- };
2716
- }
2717
- /**
2718
- * Enrich a REPEATED block prompt — a NEW object, never a mutation (the input may
2719
- * be a shared const like FAIL_CLOSED). The decision stays `block`; only the
2720
- * message changes, so every harness renders it through the same adapter.
2721
- * @param prompt - The original block prompt.
2722
- * @param count - The running identical-deny count (n).
2723
- * @returns A block prompt with `[REPEAT]` title, STOP-prefixed reason, forced research action.
2724
- */
2725
- function enrichRepeatDeny(prompt, count) {
2726
- const stop = `Identical attempt #${count} already denied for the same reason. STOP: do not retry this same call. `;
2727
- const action = "Launch fuse-ai-pilot:research-expert to find a DIFFERENT approach";
2728
- return {
2729
- ...prompt,
2730
- title: prompt.title.startsWith("[REPEAT]") ? prompt.title : `[REPEAT] ${prompt.title}`,
2731
- reason: stop + prompt.reason,
2732
- actions: [action, ...prompt.actions ?? []]
2733
- };
2797
+ function postCompactContext(data, cwd, moduleUrl, now = Date.now()) {
2798
+ try {
2799
+ if (!oncePerWindow(`postcompact:${typeof data.session_id === "string" ? data.session_id : "unknown"}`, COMPACT_DEDUP_MS, {
2800
+ now,
2801
+ dir: defaultStateDir(cwd)
2802
+ })) return "";
2803
+ const snapshot = renderSnapshot(cwd, moduleUrl);
2804
+ return contextResponse("PostCompact", snapshot ? `${REMINDER}\n\n${snapshot}` : REMINDER);
2805
+ } catch {
2806
+ return "";
2807
+ }
2734
2808
  }
2735
2809
  //#endregion
2736
- //#region src/tracking/one-shot-store.ts
2737
- /** A fresh, empty state always spread (`{ ...EMPTY }`) so the const is never shared. */
2738
- const EMPTY = {
2739
- gates: {},
2740
- firstTry: 0,
2741
- corrected: 0,
2742
- pending: {},
2743
- updatedAt: 0
2744
- };
2745
- /**
2746
- * Drop stale data: whole-state idle reset past the window, else per-entry prune of
2747
- * gates/pending older than `windowMs`. Keeps the "7d" window honest, bounds size.
2748
- */
2749
- function pruneState(s, now, windowMs) {
2750
- if (now - s.updatedAt >= windowMs) return { ...EMPTY };
2751
- const gates = {};
2752
- for (const [k, g] of Object.entries(s.gates)) if (now - g.lastTs < windowMs) gates[k] = g;
2753
- const pending = {};
2754
- for (const [k, p] of Object.entries(s.pending)) if (now - p.ts < windowMs) pending[k] = p;
2755
- return {
2756
- ...s,
2757
- gates,
2758
- pending
2759
- };
2810
+ //#region src/runtime/lifecycle/task-completed.ts
2811
+ /** Code-file extensions audited on task completion (mirrors validate-task-solid.py). */
2812
+ const CODE_EXTENSIONS$2 = /* @__PURE__ */ new Set([
2813
+ ".ts",
2814
+ ".tsx",
2815
+ ".js",
2816
+ ".jsx",
2817
+ ".py",
2818
+ ".go",
2819
+ ".rs",
2820
+ ".java",
2821
+ ".php",
2822
+ ".cpp",
2823
+ ".c",
2824
+ ".rb",
2825
+ ".swift",
2826
+ ".kt",
2827
+ ".dart",
2828
+ ".vue",
2829
+ ".svelte",
2830
+ ".astro"
2831
+ ]);
2832
+ /** Freshness multiple on `FUSE_ENFORCE_TTL_SEC` for receipts (no new env var); a tsc+test run precedes the "done" by more than one edit window. */
2833
+ const RECEIPT_TTL_MULTIPLIER = 5;
2834
+ /** The modified files that are code (by extension) — the receipt gate's trigger set. */
2835
+ function codeFiles(files) {
2836
+ return files.filter((fp) => CODE_EXTENSIONS$2.has(extname(fp)));
2760
2837
  }
2761
2838
  /**
2762
- * Record a deny for gate `title` on operation `op` (content-free tool identity):
2763
- * bump the gate's deny count and mark `op` pending for a later fix.
2839
+ * Refuse completion when code files changed but no fresh, passing verification
2840
+ * receipt (`tsc`/test, exit 0, zero failures, within TTL×{@link RECEIPT_TTL_MULTIPLIER})
2841
+ * exists in the signed track. TaskCompleted does NOT honor `decision:"block"`
2842
+ * (verified against the official hooks docs — `TeammateIdle/TaskCreated/
2843
+ * TaskCompleted` are excluded from that list); the documented stdout refusal is
2844
+ * `{"continue":false,"stopReason":…}`, which halts the teammate with the reason
2845
+ * shown to the user. Returns that JSON, or `null` when the session is clear.
2764
2846
  */
2765
- function applyDeny(s, title, op, now) {
2766
- const g = s.gates[title] ?? {
2767
- denies: 0,
2768
- corrected: 0,
2769
- lastTs: 0
2770
- };
2771
- return {
2772
- ...s,
2773
- gates: {
2774
- ...s.gates,
2775
- [title]: {
2776
- denies: g.denies + 1,
2777
- corrected: g.corrected,
2778
- lastTs: now
2779
- }
2780
- },
2781
- pending: {
2782
- ...s.pending,
2783
- [op]: {
2784
- title,
2785
- ts: now
2786
- }
2787
- },
2788
- updatedAt: now
2789
- };
2847
+ function receiptGate(sid, files, now, stateDir) {
2848
+ if (codeFiles(files).length === 0) return null;
2849
+ const windowMs = resolveTtlSec(process.env) * 1e3 * RECEIPT_TTL_MULTIPLIER;
2850
+ if (freshReceiptFromFile(trackFile(sid, stateDir), windowMs, now)) return null;
2851
+ return JSON.stringify({
2852
+ continue: false,
2853
+ stopReason: "VERIFICATION RECEIPT REQUIRED: code files changed but no fresh passing tsc/test receipt exists. Run `bun test` + `tsc --noEmit` (exit 0, 0 failures) and re-complete."
2854
+ });
2790
2855
  }
2791
2856
  /**
2792
- * Record an allow for a gateable `op`. A non-gateable allow (Read/Task/MCP) leaves
2793
- * state untouched it never counts and never clears a pending deny. Otherwise: a
2794
- * pending deny `corrected` (a fix, credited to the blocking gate); no pending →
2795
- * `firstTry` (one-shot).
2857
+ * Re-count physical lines of every modified code file and collect SOLID
2858
+ * violations (`<basename>: <n> lines (max <max>)`) for those exceeding `max`.
2859
+ * @param files - Candidate modified file paths.
2860
+ * @param max - The SOLID line ceiling.
2861
+ * @returns The list of violation strings (empty when all files comply).
2796
2862
  */
2797
- function applyAllow(s, op, now, gateable) {
2798
- if (!gateable) return s;
2799
- const pend = s.pending[op];
2800
- if (pend) {
2801
- const g = s.gates[pend.title] ?? {
2802
- denies: 0,
2803
- corrected: 0,
2804
- lastTs: 0
2805
- };
2806
- const { [op]: _drop, ...pending } = s.pending;
2807
- return {
2808
- ...s,
2809
- gates: {
2810
- ...s.gates,
2811
- [pend.title]: {
2812
- ...g,
2813
- corrected: g.corrected + 1,
2814
- lastTs: now
2815
- }
2816
- },
2817
- corrected: s.corrected + 1,
2818
- pending,
2819
- updatedAt: now
2820
- };
2863
+ function collectViolations(files, max) {
2864
+ const violations = [];
2865
+ for (const fp of files) {
2866
+ if (!CODE_EXTENSIONS$2.has(extname(fp)) || !existsSync(fp)) continue;
2867
+ try {
2868
+ const lines = countLines(readFileSync(fp, "utf-8"));
2869
+ if (lines > max) violations.push(`${basename(fp)}: ${lines} lines (max ${max})`);
2870
+ } catch {}
2821
2871
  }
2822
- return gateable ? {
2823
- ...s,
2824
- firstTry: s.firstTry + 1,
2825
- updatedAt: now
2826
- } : s;
2872
+ return violations;
2827
2873
  }
2828
2874
  /**
2829
- * Compact injectable summary (one line); "" when there is nothing to report.
2830
- * @returns e.g. `gates 7d: 88% one-shot (44/50 clean); SOLID file-size limit 4den/3fix`.
2875
+ * Handle TaskCompleted (ports `task-completed/validate-task-solid.py`, plus the
2876
+ * receipt gate). SOLID violations surface first as `SOLID VIOLATION`
2877
+ * additionalContext; once the files comply, {@link receiptGate} refuses a "done"
2878
+ * that has no fresh passing tsc/test receipt.
2879
+ * @param payload - The TaskCompleted payload (`task_id`, `task_subject`, `session_id`).
2880
+ * @param home - Home dir (defaults to `~`).
2881
+ * @param now - Clock (defaults to `Date.now()`).
2882
+ * @param stateDir - Track base dir (defaults to the cwd-derived state dir; matches `handleHook`).
2883
+ * @returns The native hook stdout, or `""` when the session is clean.
2831
2884
  */
2832
- function formatSummary(s) {
2833
- const keys = Object.keys(s.gates);
2834
- const total = s.firstTry + s.corrected;
2835
- if (keys.length === 0 && total === 0) return "";
2836
- const head = total > 0 ? `${Math.round(s.firstTry / total * 100)}% one-shot (${s.firstTry}/${total} clean)` : "no clean pass yet";
2837
- const parts = keys.map((k) => ({
2838
- k,
2839
- g: s.gates[k]
2840
- })).sort((a, b) => b.g.denies - a.g.denies).map(({ k, g }) => `${k} ${g.denies}den/${g.corrected}fix`);
2841
- return `gates 7d: ${head}${parts.length ? `; ${parts.join("; ")}` : ""}`;
2885
+ function validateTaskSolid(payload, home = homedir(), now = Date.now(), stateDir = defaultStateDir(process.cwd())) {
2886
+ const sid = sanitizeSessionId(payload.session_id ?? "unknown");
2887
+ if (!sid) return "";
2888
+ const files = loadSessionState(sid, home).changes?.modifiedFiles ?? [];
2889
+ if (files.length === 0) return "";
2890
+ const max = resolveMaxLines();
2891
+ const violations = collectViolations(files, max);
2892
+ if (violations.length === 0) return receiptGate(sid, files, now, stateDir) ?? "";
2893
+ const taskId = String(payload.task_id ?? "");
2894
+ return contextResponse("TaskCompleted", `SOLID VIOLATION in task '${String(payload.task_subject ?? "")}' (${taskId}): ${violations.length} file(s) exceed ${max} lines: ` + violations.slice(0, 5).join("; "));
2842
2895
  }
2843
2896
  //#endregion
2844
- //#region src/tracking/one-shot-dedup.ts
2897
+ //#region src/runtime/lifecycle/cartographer/fs-util.ts
2845
2898
  /**
2846
- * @module one-shot-dedup
2847
- * Burst-dedup guard for the one-shot metric ({@link module:one-shot}).
2848
- *
2849
- * ONE Claude tool event fans out to ~11 sibling plugin-hook processes, each
2850
- * calling {@link recordOneShot}; without this the metric would count a single
2851
- * deny/allow ~11×. Reuses the proven {@link oncePerWindow} cooldown sidecar:
2852
- * the FIRST process in the {@link module:burst-window} window mutates the
2853
- * metric, the rest skip. The dedup key includes the outcome KIND (deny-title vs
2854
- * allow) so a deny and its later fix — different kinds — are never folded into
2855
- * each other. No `sessionId` → always the first (mono-process + unit-test
2856
- * parity; a burst can only exist when a real session drives the fan-out).
2857
- * @packageDocumentation
2899
+ * Filesystem helpers for the cartographer tree walk. Ports the fs parts of
2900
+ * `describe.py` (file desc) and `write_recursive.py` (children + counts).
2858
2901
  */
2859
2902
  /**
2860
- * True when this `(op, kind)` is the FIRST of its burst for the session the
2861
- * process that should actually mutate the metric. Sibling processes firing the
2862
- * SAME event within {@link BURST_DEDUP_MS} return false and skip the write.
2863
- * @param op - Content-free operation key ({@link denyHash}("op", …)).
2864
- * @param kind - Outcome discriminator (`deny:<title>` or `allow`).
2865
- * @param opts - Clock + state dir + optional session id.
2866
- * @returns `true` to apply the record, `false` to skip (already counted).
2903
+ * Read a file and derive its one-line description (frontmatter / heading /
2904
+ * comment). "" on any error or when nothing is found.
2905
+ * @param filePath - Absolute path to the file.
2906
+ * @returns The description, or "".
2867
2907
  */
2868
- function burstFirst(op, kind, opts) {
2869
- const sid = opts.sessionId?.trim();
2870
- if (!sid) return true;
2871
- return oncePerWindow(`oneshot:${sid}:${op}:${kind}`, BURST_DEDUP_MS, {
2872
- now: opts.now,
2873
- dir: opts.dir
2908
+ function getFileDesc(filePath) {
2909
+ let text = "";
2910
+ try {
2911
+ text = readFileSync(filePath, "utf-8");
2912
+ } catch {
2913
+ return "";
2914
+ }
2915
+ const suffix = extname(filePath);
2916
+ const mdField = suffix === ".md" ? parseField(text, "description") : "";
2917
+ return descFromText(suffix, text, mdField);
2918
+ }
2919
+ /**
2920
+ * Recursively count files whose relative path parts are all visible (no leading
2921
+ * "." or "_") and none excluded. Best-effort (partial count on errors).
2922
+ * @param dir - Directory to count under.
2923
+ * @param exclude - Directory/name set to skip.
2924
+ * @returns The file count.
2925
+ */
2926
+ function countFiles(dir, exclude) {
2927
+ let total = 0;
2928
+ try {
2929
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
2930
+ if (e.name.startsWith(".") || e.name.startsWith("_") || exclude.has(e.name)) continue;
2931
+ if (e.isDirectory()) total += countFiles(join(dir, e.name), exclude);
2932
+ else if (e.isFile()) total += 1;
2933
+ }
2934
+ } catch {}
2935
+ return total;
2936
+ }
2937
+ /** Absolute children of `source`, split into dirs/files, sorted by full path. */
2938
+ function listChildren(source, exclude) {
2939
+ const dirs = [];
2940
+ const files = [];
2941
+ let entries;
2942
+ try {
2943
+ entries = readdirSync(source, { withFileTypes: true });
2944
+ } catch {
2945
+ return {
2946
+ dirs,
2947
+ files
2948
+ };
2949
+ }
2950
+ for (const e of entries) {
2951
+ if (e.name.startsWith(".") || e.name.startsWith("_") || exclude.has(e.name)) continue;
2952
+ const abs = join(source, e.name);
2953
+ if (e.isDirectory()) dirs.push(abs);
2954
+ else if (e.isFile()) files.push(abs);
2955
+ }
2956
+ return {
2957
+ dirs: dirs.sort(),
2958
+ files: files.sort()
2959
+ };
2960
+ }
2961
+ //#endregion
2962
+ //#region src/runtime/lifecycle/cartographer/merge.ts
2963
+ /**
2964
+ * Index merge — preserves enriched descriptions across regenerations. Ports
2965
+ * `merge_index.py` (merge_lines + .enriched.json sidecar).
2966
+ */
2967
+ /**
2968
+ * Load the `.enriched.json` sidecar's `entries` map for an output index.
2969
+ * @param outputIndexPath - Path to the index.md being written.
2970
+ * @returns The path→desc enrichment map (possibly empty).
2971
+ */
2972
+ function loadEnriched(outputIndexPath) {
2973
+ const sidecar = join(dirname(outputIndexPath), ".enriched.json");
2974
+ try {
2975
+ if (!existsSync(sidecar)) return {};
2976
+ return JSON.parse(readFileSync(sidecar, "utf-8")).entries ?? {};
2977
+ } catch {
2978
+ return {};
2979
+ }
2980
+ }
2981
+ /**
2982
+ * Merge freshly generated lines with prior descriptions: enriched sidecar wins,
2983
+ * else a longer pre-existing description is preserved.
2984
+ * @param newLines - The freshly generated index lines.
2985
+ * @param outputIndexPath - Path to the existing index.md (if any).
2986
+ * @returns The merged lines.
2987
+ */
2988
+ function mergeLines(newLines, outputIndexPath) {
2989
+ const enriched = loadEnriched(outputIndexPath);
2990
+ const existingDescs = {};
2991
+ if (existsSync(outputIndexPath)) try {
2992
+ for (const line of readFileSync(outputIndexPath, "utf-8").split("\n")) {
2993
+ const e = parseEntry(line);
2994
+ if (e) existingDescs[e.path] = e.desc;
2995
+ }
2996
+ } catch {}
2997
+ return newLines.map((line) => {
2998
+ const e = parseEntry(line);
2999
+ if (!e) return line;
3000
+ if (e.path in enriched) return `${e.prefix}[${e.name}](${e.path}) — ${enriched[e.path]}`;
3001
+ const old = existingDescs[e.path] ?? "";
3002
+ if (old.length > e.desc.length) return `${e.prefix}[${e.name}](${e.path}) — ${old}`;
3003
+ return line;
2874
3004
  });
2875
3005
  }
2876
3006
  //#endregion
2877
- //#region src/tracking/one-shot.ts
3007
+ //#region src/runtime/lifecycle/cartographer/write-tree.ts
2878
3008
  /**
2879
- * @module one-shot
2880
- * Sidecar store + gate wiring for the per-gate one-shot metric.
2881
- *
2882
- * STATE — a standalone sidecar (`one-shot.json`) in the same per-project state dir
2883
- * as the session track, mirroring {@link module:deny-loop-store} (atomicWrite,
2884
- * prune-by-window, fail-safe). A write error NEVER changes a gate decision nor its
2885
- * prompt — metrics are pure observation.
2886
- *
2887
- * KEY — the operation identity is content-FREE (`tool + filePath/command`): a fix
2888
- * changes the content, so a content hash would make every retry a new op and hide
2889
- * the deny→allow transition this metric exists to see. The pure model lives in
2890
- * {@link module:one-shot-store}; this file is the only IO surface.
2891
- * @packageDocumentation
3009
+ * Recursive index.md tree writer. Ports `write_recursive.py`.
2892
3010
  */
2893
- /** Sidecar basename under the per-project state dir. */
2894
- const SIDECAR$1 = "one-shot.json";
2895
- /** Retention window: 7 days. Aggregates and pending denies older than this are pruned. */
2896
- const WINDOW_MS = 10080 * 60 * 1e3;
2897
- /** Load the state, or a fresh copy when missing/corrupt. */
2898
- function loadState(path) {
3011
+ /**
3012
+ * Write `index.md` files mirroring `source` under `output`, recursing into
3013
+ * subdirectories. Directory lines carry a file-count hint; file lines carry a
3014
+ * derived description and link to the real absolute source path.
3015
+ * @param source - Absolute source directory.
3016
+ * @param output - Absolute output directory for the index tree.
3017
+ * @param back - Relative `← back` link target ("" at the root).
3018
+ * @param exclude - Directory/name set to skip.
3019
+ */
3020
+ function writeTree(source, output, back = "", exclude) {
3021
+ const ex = exclude ?? /* @__PURE__ */ new Set();
3022
+ mkdirSync(output, { recursive: true });
3023
+ const { dirs, files } = listChildren(source, ex);
3024
+ const lines = [`# ${basename(source)}\n`];
3025
+ if (back) lines.push(`> [← back](${back})\n`);
3026
+ const total = dirs.length + files.length;
3027
+ let idx = 0;
3028
+ for (const d of dirs) {
3029
+ idx += 1;
3030
+ const conn = idx === total ? "└──" : "├──";
3031
+ const count = countFiles(d, ex);
3032
+ const hint = count ? ` — ${count} files` : "";
3033
+ lines.push(`${conn} [${basename(d)}/](./${basename(d)}/index.md)${hint}`);
3034
+ writeTree(d, join(output, basename(d)), "../index.md", exclude);
3035
+ }
3036
+ for (const f of files) {
3037
+ idx += 1;
3038
+ const conn = idx === total ? "└──" : "├──";
3039
+ const desc = getFileDesc(f);
3040
+ const suffix = desc ? ` — ${desc}` : "";
3041
+ lines.push(`${conn} [${basename(f)}](${f})${suffix}`);
3042
+ }
3043
+ const indexPath = join(output, "index.md");
3044
+ writeFileSync(indexPath, mergeLines(lines, indexPath).join("\n") + "\n", "utf-8");
3045
+ }
3046
+ //#endregion
3047
+ //#region src/runtime/lifecycle/cartographer/project-map.ts
3048
+ /**
3049
+ * Project map generation. Ports `generate_project_map.py` (project map only).
3050
+ */
3051
+ /** True when `dir` is a real directory. */
3052
+ function isDirectory(dir) {
2899
3053
  try {
2900
- if (!existsSync(path)) return { ...EMPTY };
2901
- const d = JSON.parse(readFileSync(path, "utf8"));
2902
- return d && typeof d === "object" && !Array.isArray(d) ? {
2903
- ...EMPTY,
2904
- ...d
2905
- } : { ...EMPTY };
3054
+ return statSync(dir).isDirectory();
2906
3055
  } catch {
2907
- return { ...EMPTY };
3056
+ return false;
2908
3057
  }
2909
3058
  }
2910
3059
  /**
2911
- * Record a gate outcome: a `block` is a deny for its gate title; a `null` allow is
2912
- * a fix (if the op was pending) or a one-shot (if gateable). `ask`/`inform` are
2913
- * neither and are skipped. Fails silently — a metric write NEVER affects a decision.
2914
- *
2915
- * The op key is tool-INDEPENDENT (`filePath`/`command` only, constant `"op"` tool):
2916
- * a deny (a `Write`) and its fix (an `Edit`) on the same file must link.
2917
- * @param prompt - The gate's outcome (block, allow=null, or ask/inform).
2918
- * @param input - Identifying tool input (content decides gateability only).
2919
- * @param opts - Clock + state dir.
3060
+ * True when `dir` looks like a project root (has an indicator file) and is not
3061
+ * the home directory or filesystem root.
3062
+ * @param dir - Directory to test.
3063
+ * @returns Whether `dir` is a project root.
2920
3064
  */
2921
- function recordOneShot(prompt, input, opts) {
3065
+ function isProject(dir) {
3066
+ const resolved = resolve(dir);
3067
+ if (resolved === resolve(homedir()) || resolved === "/") return false;
3068
+ for (const f of PROJECT_INDICATORS) if (existsSync(join(dir, f))) return true;
3069
+ return false;
3070
+ }
3071
+ /**
3072
+ * Generate the `.cartographer/project` index tree for `cwd` when it is a real
3073
+ * project directory. Always returns "" (no additionalContext emitted).
3074
+ * @param cwd - The working directory.
3075
+ * @param outputDir - Override for the output tree root.
3076
+ * @returns "" (side-effect only).
3077
+ */
3078
+ function generateProjectMap(cwd, outputDir) {
3079
+ const projectDir = resolve(cwd);
3080
+ const out = outputDir ?? join(projectDir, ".cartographer", "project");
3081
+ if (!isDirectory(projectDir)) return "";
3082
+ if (!isProject(projectDir)) return "";
3083
+ writeTree(projectDir, out, "", EXCLUDE_DIRS$1);
3084
+ return "";
3085
+ }
3086
+ //#endregion
3087
+ //#region src/policy/cartographer/build-tree.ts
3088
+ const SECTION_ORDER = [
3089
+ "agent",
3090
+ "skill",
3091
+ "command"
3092
+ ];
3093
+ /**
3094
+ * Format grouped items with tree connectors and optional markdown links.
3095
+ * Skill sections link to `./skills/<name>/index.md`; other sections to
3096
+ * `./<folder>/<name>.md`; unlinked sections render the bare name.
3097
+ * @param prefix - The line prefix (indent + branch glyphs).
3098
+ * @param items - The `[name, desc]` pairs to render.
3099
+ * @param folder - The link folder ("" disables linking).
3100
+ * @param asDirs - Whether items link to a subdirectory `index.md`.
3101
+ * @returns The rendered lines.
3102
+ */
3103
+ function printItems(prefix, items, folder, asDirs) {
3104
+ return items.map(([name, desc], i) => {
3105
+ const connector = i === items.length - 1 ? "└──" : "├──";
3106
+ const safe = name.replace(/^\/+/, "");
3107
+ let label = name;
3108
+ if (folder && asDirs) label = `[${name}](./${folder}/${safe}/index.md)`;
3109
+ else if (folder) label = `[${name}](./${folder}/${safe}.md)`;
3110
+ const short = desc && desc !== "(no description)" ? ` — ${desc.slice(0, 80)}` : "";
3111
+ return `${prefix}${connector} ${label}${short}`;
3112
+ });
3113
+ }
3114
+ /**
3115
+ * Build an indented tree from scanned items. The `hooks` row renders as a single
3116
+ * trailing `└── hooks: …` line; agents/skills/commands render as folder sections.
3117
+ * @param items - The scanned `[type, name, desc]` rows.
3118
+ * @param linked - When true, leaf names become markdown links.
3119
+ * @returns The joined tree text.
3120
+ */
3121
+ function buildTree(items, linked = false) {
3122
+ const groups = {};
3123
+ let hooksLine = "";
3124
+ for (const [typ, name, desc] of items) if (typ === "hooks") hooksLine = name;
3125
+ else (groups[typ] ??= []).push([name, desc]);
3126
+ const sections = SECTION_ORDER.filter((s) => s in groups);
3127
+ if (hooksLine) sections.push("hooks");
3128
+ const lines = [];
3129
+ const total = sections.length;
3130
+ for (let idx = 0; idx < total; idx++) {
3131
+ const section = sections[idx] ?? "";
3132
+ if (section === "hooks") {
3133
+ lines.push(`└── hooks: ${hooksLine}`);
3134
+ continue;
3135
+ }
3136
+ const isLast = idx === total - 1;
3137
+ const folder = `${section}s`;
3138
+ const prefix = isLast ? "└──" : "├──";
3139
+ const subPrefix = isLast ? " " : "│ ";
3140
+ lines.push(`${prefix} ${folder}/`);
3141
+ const linkFolder = linked ? folder : "";
3142
+ const isDirSection = section === "skill";
3143
+ lines.push(...printItems(subPrefix, groups[section] ?? [], linkFolder, linked && isDirSection));
3144
+ }
3145
+ return lines.join("\n");
3146
+ }
3147
+ //#endregion
3148
+ //#region src/runtime/lifecycle/cartographer/write-plugin-map.ts
3149
+ /**
3150
+ * Per-plugin map writer (fs). Ports `write_plugin_map.py`: writes a level-2
3151
+ * `<plugin>/index.md` (indented linked tree) then recurses agents/skills/
3152
+ * commands into deeper index trees. Reuses `buildTree`, `mergeLines`, `writeTree`.
3153
+ */
3154
+ /** True when `dir` is a real directory. */
3155
+ function isDir(dir) {
2922
3156
  try {
2923
- if (prompt && prompt.kind !== "block") return;
2924
- const op = denyHash("op", {
2925
- filePath: input.filePath,
2926
- command: input.command
3157
+ return statSync(dir).isDirectory();
3158
+ } catch {
3159
+ return false;
3160
+ }
3161
+ }
3162
+ /**
3163
+ * Write `<outputDir>/<pluginName>/index.md` (indented linked tree) and recurse
3164
+ * agents/skills/commands into their own index trees rooted there.
3165
+ * @param outputDir - The map root directory.
3166
+ * @param pluginName - Display name of the plugin (the index subfolder).
3167
+ * @param version - Plugin version ("" to omit).
3168
+ * @param items - The scanned `[type, name, desc]` rows.
3169
+ * @param pluginPath - Absolute source plugin directory (for recursion).
3170
+ */
3171
+ function writePluginMap(outputDir, pluginName, version, items, pluginPath) {
3172
+ const pluginDir = join(outputDir, pluginName);
3173
+ mkdirSync(pluginDir, { recursive: true });
3174
+ const newLines = `# ${pluginName}${version ? ` (v${version})` : ""}\n\n${items.length ? buildTree(items, true) : "└── (empty)"}`.split("\n");
3175
+ const indexPath = join(pluginDir, "index.md");
3176
+ writeFileSync(indexPath, mergeLines(newLines, indexPath).join("\n") + "\n", "utf-8");
3177
+ for (const section of [
3178
+ "agents",
3179
+ "skills",
3180
+ "commands"
3181
+ ]) {
3182
+ const src = join(pluginPath, section);
3183
+ if (isDir(src)) writeTree(src, join(pluginDir, section), "../index.md");
3184
+ }
3185
+ }
3186
+ //#endregion
3187
+ //#region src/runtime/lifecycle/cartographer/ecosystem-map.ts
3188
+ /**
3189
+ * Ecosystem (plugin) map generation (fs). Ports `generate_map.py`: scans every
3190
+ * installed plugin into a level-1 `.cartographer/index.md` + per-plugin level-2+
3191
+ * trees, preserving enriched descriptions. Reuses `findMarketplacePlugins`,
3192
+ * `readPluginMeta`, `scanPlugin`, `mergeLines`, `writePluginMap`.
3193
+ */
3194
+ function pluginDirs(dir) {
3195
+ let entries = [];
3196
+ try {
3197
+ entries = readdirSync(dir);
3198
+ } catch {
3199
+ return [];
3200
+ }
3201
+ return entries.filter((n) => !n.startsWith("_") && !n.startsWith(".")).filter((n) => {
3202
+ try {
3203
+ return statSync(join(dir, n)).isDirectory();
3204
+ } catch {
3205
+ return false;
3206
+ }
3207
+ }).sort((a, b) => a.localeCompare(b, "en"));
3208
+ }
3209
+ function utcStamp(now) {
3210
+ return new Date(now).toISOString().slice(0, 16).replace("T", " ");
3211
+ }
3212
+ /**
3213
+ * Generate the plugin ecosystem map under `<pluginsDir>/.cartographer`.
3214
+ * @param now - Clock for the banner timestamp.
3215
+ * @param pluginsDirOverride - Override for the marketplace plugins directory.
3216
+ * @returns The map navigation context, or "".
3217
+ */
3218
+ function generateEcosystemMap(now, pluginsDirOverride) {
3219
+ const pluginsDir = resolve(pluginsDirOverride ?? findMarketplacePlugins());
3220
+ try {
3221
+ if (!statSync(pluginsDir).isDirectory()) return "";
3222
+ } catch {
3223
+ return "";
3224
+ }
3225
+ const outputDir = join(pluginsDir, ".cartographer");
3226
+ mkdirSync(outputDir, { recursive: true });
3227
+ const dirs = pluginDirs(pluginsDir);
3228
+ const lines = [`# Ecosystem Map (${dirs.length} plugins)\n`, `> Auto-generated by cartographer — ${utcStamp(now)}\n`];
3229
+ for (const name of dirs) {
3230
+ const pluginPath = join(pluginsDir, name);
3231
+ const [version, pkgName] = readPluginMeta(pluginPath);
3232
+ const display = pkgName || name;
3233
+ const items = scanPlugin(pluginPath);
3234
+ const agents = items.filter(([t]) => t === "agent").map(([, n]) => n);
3235
+ const ver = version ? ` (v${version})` : "";
3236
+ lines.push(`- [${display}](./${display}/index.md)${ver} → ${agents.length ? agents.join(", ") : "(no agents)"}`);
3237
+ writePluginMap(outputDir, display, version, items, pluginPath);
3238
+ writePluginMap(pluginPath, ".cartographer", version, items, pluginPath);
3239
+ }
3240
+ const indexPath = join(outputDir, "index.md");
3241
+ writeFileSync(indexPath, mergeLines(lines, indexPath).join("\n") + "\n", "utf-8");
3242
+ return `Project map: .cartographer/project/index.md — navigate project files. Plugin skills map: ${outputDir}/index.md — navigate agent skills. Branches link to deeper index.md, leaves link to real files.`;
3243
+ }
3244
+ //#endregion
3245
+ //#region src/runtime/lifecycle/cartographer/session-start.ts
3246
+ /**
3247
+ * Cartographer SessionStart handler. Ports BOTH halves of the Python maps:
3248
+ * `generate_project_map.py` (regenerate `.cartographer/project`) and
3249
+ * `generate_map.py` (regenerate the plugin ecosystem map), emitting the
3250
+ * navigation context from the latter as additionalContext.
3251
+ */
3252
+ /**
3253
+ * Resolve the marketplace plugins dir from `CLAUDE_PLUGIN_ROOT`, mirroring the
3254
+ * Python hook which passes `${CLAUDE_PLUGIN_ROOT}/..` to `generate_map.py`.
3255
+ * @returns The plugins dir (env `/..`), or `undefined` to fall back to auto-detect.
3256
+ */
3257
+ function pluginsDirFromEnv() {
3258
+ const root = process.env.CLAUDE_PLUGIN_ROOT;
3259
+ return root ? resolve(root, "..") : void 0;
3260
+ }
3261
+ /**
3262
+ * Regenerate the project map + plugin ecosystem map for `cwd` on SessionStart.
3263
+ * Emits the ecosystem navigation context as additionalContext (or "").
3264
+ * @param cwd - The working directory.
3265
+ * @param now - Clock for the ecosystem map banner timestamp.
3266
+ * @returns The SessionStart additionalContext response, or "".
3267
+ */
3268
+ function cartoSessionStart(cwd, now = Date.now()) {
3269
+ generateProjectMap(cwd);
3270
+ const ctx = generateEcosystemMap(now, pluginsDirFromEnv());
3271
+ return ctx ? contextResponse("SessionStart", ctx) : "";
3272
+ }
3273
+ //#endregion
3274
+ //#region src/runtime/lifecycle/aipilot/lesson-parse.ts
3275
+ /** Milliseconds in a day. */
3276
+ const DAY_MS = 864e5;
3277
+ /** Case-sensitive decision-time tag line (`[TRIGGERS …]`) — opus-lessons format. */
3278
+ const TRIG = /^\[TRIGGERS\s+.+\]$/;
3279
+ /** Epoch ms for a `[YYYY-MM-DD HH:MM]` stamp; `NaN` if absent or out of range. */
3280
+ function parseTs$1(line) {
3281
+ const m = line.match(/\[(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/);
3282
+ if (!m) return NaN;
3283
+ const mo = +(m[2] ?? 0), d = +(m[3] ?? 0);
3284
+ if (mo < 1 || mo > 12 || d < 1 || d > 31) return NaN;
3285
+ return Date.UTC(+(m[1] ?? 0), mo - 1, d, +(m[4] ?? 0), +(m[5] ?? 0));
3286
+ }
3287
+ /** Content words (>=4 chars), timestamp & TRIGGERS marker stripped. */
3288
+ function tokenize(text) {
3289
+ return new Set(text.toLowerCase().replace(/\[triggers[^\]]*\]/g, " ").replace(/\[\d{4}-\d{2}-\d{2}[^\]]*\]/g, " ").replace(/[^a-z0-9àâäéèêëîïôöùûüç/._-]+/gi, " ").split(/\s+/).filter((t) => t.length >= 4));
3290
+ }
3291
+ /** Jaccard overlap of two token sets (0 when both empty). */
3292
+ function jaccard(a, b) {
3293
+ if (a.size === 0 && b.size === 0) return 0;
3294
+ const inter = [...a].filter((t) => b.has(t)).length;
3295
+ return inter / (a.size + b.size - inter);
3296
+ }
3297
+ /** Repo-relative cited paths (slash + extension) referenced in a block. */
3298
+ function citedPaths(text) {
3299
+ const out = /* @__PURE__ */ new Set();
3300
+ for (const m of text.matchAll(/`([^`]+)`/g)) if (m[1]) out.add(m[1]);
3301
+ for (const m of text.matchAll(/[\w./@-]+\.\w{1,5}/g)) if (m[0]) out.add(m[0]);
3302
+ return [...out].filter((p) => p.includes("/") && /\.\w{1,5}$/.test(p));
3303
+ }
3304
+ /** True when a block carries a `[TRIGGERS …]` continuation line. */
3305
+ function hasTrigger(b) {
3306
+ return b.raw.some((l) => TRIG.test(l.trim()));
3307
+ }
3308
+ /** Split content into a verbatim preamble and one Block per `- ` bullet. */
3309
+ function parse(content) {
3310
+ const lines = content.split("\n");
3311
+ const blocks = [];
3312
+ let i = 0;
3313
+ while (i < lines.length && !/^-\s/.test(lines[i] ?? "")) i++;
3314
+ const preamble = lines.slice(0, i).join("\n");
3315
+ for (; i < lines.length; i++) {
3316
+ const l = lines[i] ?? "", last = blocks[blocks.length - 1];
3317
+ if (/^-\s/.test(l)) blocks.push({
3318
+ raw: [l],
3319
+ ts: parseTs$1(l),
3320
+ tokens: tokenize(l)
2927
3321
  });
2928
- if (!burstFirst(op, prompt ? `deny:${prompt.title}` : "allow", opts)) return;
2929
- const path = join(opts.dir, SIDECAR$1);
2930
- let s = pruneState(loadState(path), opts.now, WINDOW_MS);
2931
- s = prompt ? applyDeny(s, prompt.title, op, opts.now) : applyAllow(s, op, opts.now, input.content != null || input.command != null);
2932
- atomicWrite(path, JSON.stringify(s));
2933
- } catch {}
3322
+ else if (l.trim() && last) last.raw.push(l);
3323
+ }
3324
+ return {
3325
+ preamble,
3326
+ blocks
3327
+ };
3328
+ }
3329
+ //#endregion
3330
+ //#region src/runtime/lifecycle/aipilot/lesson-archive.ts
3331
+ /**
3332
+ * Stage 1 — cap→archive split for LESSON.md. When deduped bullets exceed CAP the
3333
+ * OLDEST excess is MOVED (never deleted) to LESSON-archive.md, EXCEPT a
3334
+ * `[TRIGGERS …]` bullet younger than STALE_DAYS: archiving it would blind the
3335
+ * PreToolUse trigger index (src/policy/lessons/trigger-index reads LESSON.md), so
3336
+ * it stays even past the cap. Pure: this module decides the partition and renders
3337
+ * the archive block; the fail-safe, archive-first file write is the caller's job.
3338
+ */
3339
+ /** Sort key: undated bullets sort oldest, so malformed entries archive first. */
3340
+ function age(b) {
3341
+ return Number.isNaN(b.ts) ? -Infinity : b.ts;
3342
+ }
3343
+ /** A TRIGGERS bullet is protected from archival until older than STALE_DAYS. */
3344
+ function isProtected(b, staleBefore) {
3345
+ return hasTrigger(b) && !(b.ts <= staleBefore);
3346
+ }
3347
+ /**
3348
+ * Partition deduped `blocks` (newest-first file order) into the bullets that
3349
+ * stay in LESSON.md and the oldest excess to archive. Archives only enough to
3350
+ * reach CAP, skipping protected TRIGGERS bullets (so the file MAY stay slightly
3351
+ * over cap by design). Order is preserved in both halves; `keep ∪ archive` is
3352
+ * exactly `blocks` with no loss and no mutation.
3353
+ * @param blocks - Deduped bullets, newest first.
3354
+ * @param now - Clock (ms) for the STALE_DAYS protection window.
3355
+ * @returns `{ keep, archive }` — a lossless partition of `blocks`.
3356
+ */
3357
+ function splitAtCap(blocks, now) {
3358
+ if (blocks.length <= 50) return {
3359
+ keep: blocks,
3360
+ archive: []
3361
+ };
3362
+ const staleBefore = now - 90 * DAY_MS;
3363
+ const oldestFirst = [...blocks].sort((a, b) => age(a) - age(b));
3364
+ const toArchive = /* @__PURE__ */ new Set();
3365
+ let excess = blocks.length - 50;
3366
+ for (const b of oldestFirst) {
3367
+ if (excess <= 0) break;
3368
+ if (isProtected(b, staleBefore)) continue;
3369
+ toArchive.add(b);
3370
+ excess--;
3371
+ }
3372
+ return {
3373
+ keep: blocks.filter((b) => !toArchive.has(b)),
3374
+ archive: blocks.filter((b) => toArchive.has(b))
3375
+ };
2934
3376
  }
2935
3377
  /**
2936
- * Compact, injection-ready one-shot summary for the project rooted at `cwd`. The
2937
- * state dir is derived EXACTLY like the runtime writer ({@link defaultStateDir},
2938
- * mirroring `handle.ts` `trackFile(sid, defaultStateDir(cwd))`), so the file read
2939
- * here is the same one {@link recordOneShot} wrote. "" when no data or read error.
2940
- * @param cwd - The project working directory (Claude `cwd`), NOT the state dir.
2941
- * @returns One line, e.g. `gates 7d: 88% one-shot (44/50 clean); ...`, or "".
3378
+ * Render `archive` bullets as a dated block to PREPEND to LESSON-archive.md
3379
+ * (newest archive session on top). Bullets are emitted BYTE-IDENTICAL (raw lines
3380
+ * rejoined) zero mutation, so the move stays reversible/auditable.
3381
+ * @param archive - Bullets chosen by {@link splitAtCap}.
3382
+ * @param now - Clock (ms) for the archival header date.
3383
+ * @returns The block text (trailing newline), or "" when nothing is archived.
2942
3384
  */
2943
- function oneShotSummary(cwd) {
2944
- try {
2945
- return formatSummary(pruneState(loadState(join(defaultStateDir(cwd), SIDECAR$1)), Date.now(), WINDOW_MS));
2946
- } catch {
2947
- return "";
2948
- }
3385
+ function formatArchive(archive, now) {
3386
+ if (archive.length === 0) return "";
3387
+ 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`;
2949
3388
  }
2950
3389
  //#endregion
2951
- //#region src/runtime/lifecycle/snapshot/git.ts
3390
+ //#region src/runtime/lifecycle/aipilot/curate-lessons.ts
2952
3391
  /**
2953
- * Run a git subcommand at `root` with a short timeout, returning trimmed stdout.
2954
- * Uses `node:child_process` (the Bun shell can hang on some git plumbing) and
2955
- * swallows every failure a non-repo, missing git, or timeout yields `""` so
2956
- * the caller omits the section instead of throwing inside the hook.
2957
- * @param root - Directory to run git in.
2958
- * @param args - The git args (e.g. `"log --oneline -3"`).
2959
- * @returns Trimmed stdout, or `""` on any error.
3392
+ * Stage-0 mechanical, LLM-free dedup of MEMORY/LESSON.md bullets + cap→archive
3393
+ * orchestration. Strict-dedup near-identical bullets (keep newest, `[TRIGGERS …]`
3394
+ * preserved), then hand the deduped set to lesson-archive's cap split. Returns the
3395
+ * rewritten LESSON.md content, the archive block to move out, and a human report.
3396
+ * Pure: all file I/O (archive-first, fail-safe) lives in the dispatch caller.
2960
3397
  */
2961
- function git(root, args) {
2962
- try {
2963
- return execSync(`git ${args}`, {
2964
- cwd: root,
2965
- encoding: "utf8",
2966
- timeout: 150,
2967
- stdio: [
2968
- "ignore",
2969
- "pipe",
2970
- "ignore"
2971
- ]
2972
- }).trim();
2973
- } catch {
2974
- return "";
2975
- }
3398
+ const SIM_THRESHOLD = .8;
3399
+ const MIN_TOKENS = 4;
3400
+ /** Report lines for bullets older than STALE_DAYS whose only cited path is gone. */
3401
+ function staleReport(blocks, now, root) {
3402
+ const cutoff = now - 90 * DAY_MS;
3403
+ return blocks.flatMap((b) => {
3404
+ if (!(b.ts <= cutoff)) return [];
3405
+ const paths = citedPaths(b.raw.join(" "));
3406
+ if (paths.length === 0 || paths.some((p) => existsSync(join(root, p)))) return [];
3407
+ return [`[STALE?] ${(b.raw[0] ?? "").slice(0, 90)} — missing path(s): ${paths.join(", ")}`];
3408
+ });
2976
3409
  }
2977
- /** Count staged/unstaged/untracked files from porcelain v1 output (skips the `##` branch line). */
2978
- function countWip(porcelain) {
2979
- const w = {
2980
- staged: 0,
2981
- unstaged: 0,
2982
- untracked: 0
2983
- };
2984
- for (const line of porcelain.split("\n")) {
2985
- if (!line || line.startsWith("#")) continue;
2986
- if (line.startsWith("??")) {
2987
- w.untracked++;
3410
+ /** Strict-dedup: keep the newest of each near-identical pair (TRIGGERS carried over). Returns kept blocks + merge report lines. */
3411
+ function dedup(blocks) {
3412
+ const kept = [];
3413
+ const fused = [];
3414
+ for (const b of blocks) {
3415
+ const hit = b.tokens.size >= MIN_TOKENS ? kept.find((k) => k.tokens.size >= MIN_TOKENS && jaccard(k.tokens, b.tokens) >= SIM_THRESHOLD) : void 0;
3416
+ if (!hit) {
3417
+ kept.push(b);
2988
3418
  continue;
2989
3419
  }
2990
- const x = line[0], y = line[1];
2991
- if (x && x !== " " && x !== "?") w.staged++;
2992
- if (y === "M" || y === "D") w.unstaged++;
3420
+ const [win, drop] = b.ts > hit.ts || Number.isNaN(hit.ts) ? [b, hit] : [hit, b];
3421
+ if (win !== hit) kept[kept.indexOf(hit)] = win;
3422
+ if (!win.raw.some((l) => TRIG.test(l.trim()))) {
3423
+ const t = drop.raw.find((l) => TRIG.test(l.trim()));
3424
+ if (t) win.raw.push(t);
3425
+ }
3426
+ fused.push(`merged: kept ${(win.raw[0] ?? "").slice(0, 60)} · dropped ${(drop.raw[0] ?? "").slice(0, 60)}`);
2993
3427
  }
2994
- return w;
2995
- }
2996
- /** Parse the current branch from the leading `## branch...upstream` porcelain line. */
2997
- function parseBranch(porcelain) {
2998
- const head = porcelain.split("\n")[0] ?? "";
2999
- if (!head.startsWith("## ")) return "";
3000
- const rest = head.slice(3);
3001
- const dots = rest.indexOf("...");
3002
- return (dots >= 0 ? rest.slice(0, dots) : rest).split(" ")[0] ?? "";
3428
+ return {
3429
+ kept,
3430
+ fused
3431
+ };
3003
3432
  }
3004
3433
  /**
3005
- * Collect a compact git reconciliation section for `root`: current branch, the
3006
- * last three commits (oneline), and staged/unstaged/untracked WIP counts. When
3007
- * `root` is not a git repo (status fails) the whole section is omitted (`""`).
3008
- * @param root - The project/repo root.
3009
- * @returns The rendered git section body, or `""` when not a repo.
3434
+ * Dedup LESSON.md bullets, then archive the oldest excess over CAP (via
3435
+ * lesson-archive). `content` is byte-identical to the input when nothing is
3436
+ * deduped or archived. The `archive` block (possibly "") is what the caller must
3437
+ * PREPEND to LESSON-archive.md, archive-first, before writing `content`.
3438
+ * @param content - Raw LESSON.md text.
3439
+ * @param now - Clock (ms) for stale/archival windows.
3440
+ * @param root - Project root, for resolving cited paths in the stale report.
3441
+ * @returns The rewritten content, the archive block, and the report.
3010
3442
  */
3011
- function collectGit(root) {
3012
- const status = git(root, "status --porcelain=v1 --branch");
3013
- if (!status) return "";
3014
- const branch = parseBranch(status) || "(unknown)";
3015
- const w = countWip(status);
3016
- const log = git(root, "log --oneline -3");
3017
- const lines = [`- branch: ${branch}`];
3018
- if (log) lines.push("- recent:", ...log.split("\n").map((l) => ` ${l}`));
3019
- lines.push(`- WIP: ${w.staged} staged, ${w.unstaged} unstaged, ${w.untracked} untracked`);
3020
- return lines.join("\n");
3443
+ function curateLessons(content, now, root = process.cwd()) {
3444
+ const { preamble, blocks } = parse(content);
3445
+ const { kept, fused } = dedup(blocks);
3446
+ const { keep, archive } = splitAtCap(kept, now);
3447
+ const rebuilt = fused.length > 0 || archive.length > 0 ? `${preamble}\n${keep.map((b) => b.raw.join("\n")).join("\n\n")}\n` : content;
3448
+ const capReport = archive.length ? [`${kept.length} bullets (> 50) ${archive.length} oldest archived → LESSON-archive.md`] : [];
3449
+ const report = [
3450
+ ...fused,
3451
+ ...capReport,
3452
+ ...staleReport(blocks, now, root)
3453
+ ].join("\n");
3454
+ return {
3455
+ content: rebuilt,
3456
+ archive: formatArchive(archive, now),
3457
+ report
3458
+ };
3459
+ }
3460
+ /** The `[YYYY-MM-DD HH:MM]` (or date-only) stamp of a bullet, "" if absent. */
3461
+ function stamp(block) {
3462
+ return (block.raw[0] ?? "").match(/\[(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)\]/)?.[1] ?? "";
3463
+ }
3464
+ /** Bullet text: raw lines joined, leading "- ", date stamp & TRIGGERS lines stripped. */
3465
+ function bodyText(block) {
3466
+ return block.raw.filter((l) => !/^\s*\[TRIGGERS\s/.test(l)).join(" ").replace(/^-\s*/, "").replace(/\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*/, "").trim();
3467
+ }
3468
+ /** First sentence of `s` (split on a period + whitespace), whole string if none. */
3469
+ function firstSentence(s) {
3470
+ return (s.split(/(?<=\.)\s/)[0] ?? s).trim();
3021
3471
  }
3022
- //#endregion
3023
- //#region src/cli/doctor.ts
3024
3472
  /**
3025
- * `harness doctor` diagnose which `@fusengine/harness` is actually running.
3026
- *
3027
- * A confirmed, still-open bun bug (oven-sh/bun #5791; scoped-pkg behaviour
3028
- * reinforced by #32019/#32150) makes `bunx <pkg>` (unpinned) prefer a stale
3029
- * GLOBAL install over npm-latest, so a consumer can silently run an old harness
3030
- * after a publish. This command surfaces the truth: the resolved version +
3031
- * package path of the code executing right now, the runtime binary, and the
3032
- * latest version published on npm. It queries the registry over HTTP (not
3033
- * `npm view`, whose exit code is 0 even on an empty result — npm/cli#6408) and
3034
- * never throws: an offline environment yields `latest: null`, never a crash.
3473
+ * The bullet's `narrative → rule` delimiter: a SPACED arrow only. A GLUED arrow
3474
+ * between tokens (e.g. `120s→300s`, `s→3`) is prose the author wrote, never a
3475
+ * delimiter matching on `→` alone chopped rules mid-token (bug: `300s) pensant
3476
+ * corriger…`). Also used to split rule-internal clauses.
3035
3477
  */
3036
- const PKG = "@fusengine/harness";
3037
- /** Walk up from `startDir` for the `@fusengine/harness` `package.json`. */
3038
- function findPackage(startDir) {
3039
- let dir = startDir;
3040
- for (let depth = 0; depth < 6; depth++) {
3041
- try {
3042
- const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
3043
- if (pkg.name === PKG) return {
3044
- version: pkg.version ?? "unknown",
3045
- path: dir
3046
- };
3047
- } catch {}
3048
- const parent = dirname(dir);
3049
- if (parent === dir) break;
3050
- dir = parent;
3051
- }
3052
- return null;
3478
+ const RULE_ARROW = /\s+→\s+/;
3479
+ /**
3480
+ * Distil the actionable rule from a bullet body. With no spaced arrow the whole
3481
+ * bullet is the rule → its first sentence. Otherwise the rule is everything after
3482
+ * the FIRST spaced arrow; its spaced-arrow-delimited segments are kept whole
3483
+ * except for TRAILING short asides (< {@link MIN_RULE} chars, e.g. `→ (cf.
3484
+ * lecture).`) which are dropped — so an arrow used as PROSE inside a rule (`maps
3485
+ * X Y doit…`) is preserved intact rather than chopped at the arrow. When the
3486
+ * kept rule is still under {@link MIN_RULE} chars, fall back to the first sentence
3487
+ * of the WHOLE rule part (never the narrative), avoiding an illegible stub.
3488
+ */
3489
+ function distillRule(text) {
3490
+ const sep = text.search(RULE_ARROW);
3491
+ if (sep < 0) return firstSentence(text);
3492
+ const rulePart = text.slice(sep).replace(RULE_ARROW, "").trim();
3493
+ const segments = rulePart.split(RULE_ARROW).map((s) => s.trim()).filter(Boolean);
3494
+ while (segments.length > 1 && (segments[segments.length - 1]?.length ?? 0) < 40) segments.pop();
3495
+ const rule = firstSentence(segments.join(" → "));
3496
+ return rule.length >= 40 ? rule : firstSentence(rulePart);
3053
3497
  }
3054
- /** Resolve the running version + package path (no network), from a module URL. */
3055
- function runningVersion(moduleUrl) {
3056
- const found = findPackage(dirname(fileURLToPath(moduleUrl)));
3057
- return {
3058
- version: found?.version ?? "unknown",
3059
- path: found?.path ?? "unknown"
3060
- };
3498
+ /** Collapse one older bullet to `- [date] <rule>`: {@link distillRule}, capped. */
3499
+ function compressBullet(block) {
3500
+ let rule = distillRule(bodyText(block));
3501
+ if (rule.length > 200) rule = `${rule.slice(0, 199).trimEnd()}…`;
3502
+ const date = stamp(block);
3503
+ return `- ${date ? `[${date}] ` : ""}${rule}`;
3061
3504
  }
3062
- /** One-line `pkg vX.Y.Z` banner (stderr, only on explicit `--version`/`doctor` commands — never on `hook`, to avoid spamming automated invocations). */
3063
- function versionBanner(moduleUrl) {
3064
- return `${PKG} v${runningVersion(moduleUrl).version}`;
3505
+ /**
3506
+ * Build the compressed injection body for `content`. The preamble comments are
3507
+ * dropped (format docs, noise for the reader); the `recentFull` newest bullets
3508
+ * stay whole, every older bullet becomes one distilled rule-line.
3509
+ * @param content - Raw LESSON.md text.
3510
+ * @param recentFull - Count of newest bullets to keep verbatim.
3511
+ * @returns The compressed block (bullets only), or the trimmed content when there are no bullets.
3512
+ */
3513
+ function compressInjection(content, recentFull = 10) {
3514
+ const { blocks } = parse(content);
3515
+ if (blocks.length === 0) return content.trim();
3516
+ const full = blocks.slice(0, recentFull).map((b) => b.raw.join("\n"));
3517
+ const rest = blocks.slice(recentFull).map(compressBullet);
3518
+ return [...full, ...rest].join("\n");
3519
+ }
3520
+ //#endregion
3521
+ //#region src/memory/session-roots.ts
3522
+ /**
3523
+ * Session-scoped lessons roots registry. The flat {@link module:memory/registry}
3524
+ * keeps ONE global list of pending roots — correct mono-session, but wrong with
3525
+ * several concurrent Claude Code sessions: at Stop, one session lists (and, by
3526
+ * bumping the throttle, STEALS) another session's pending lesson on a project it
3527
+ * never touched. This registry keys "which project got code edits, and was its
3528
+ * Stop reminder already fired" by `session_id`, so each Stop sees and consumes
3529
+ * ONLY its own roots. Stored at `$HOME/.fuse-harness/cache/lessons/session-roots.json`;
3530
+ * non-fatal on any I/O failure (a missed reminder never blocks a session).
3531
+ */
3532
+ /** Registry path (rel. home) + stale-bucket purge horizon (bounds growth). */
3533
+ const SUBPATH = ".fuse-harness/cache/lessons/session-roots.json";
3534
+ const PURGE_MS = 10080 * 60 * 1e3;
3535
+ /** Absolute registry path, or null when home is unusable. */
3536
+ function file(home) {
3537
+ const h = home?.trim();
3538
+ return h && h.startsWith("/") ? `${h}/${SUBPATH}` : null;
3065
3539
  }
3066
- /** Latest published version via the npm registry HTTP API. `null` on any failure. */
3067
- async function npmLatest() {
3540
+ /** Read the registry; missing/corrupt/legacy (array) shapes collapse to `{}`. */
3541
+ function read(home) {
3542
+ const f = file(home);
3543
+ if (!f) return {};
3068
3544
  try {
3069
- const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(8e3) });
3070
- if (!res.ok) return null;
3071
- return (await res.json()).version ?? null;
3545
+ const parsed = JSON.parse(readFileSync(f, "utf8"));
3546
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3072
3547
  } catch {
3073
- return null;
3548
+ return {};
3074
3549
  }
3075
3550
  }
3076
- /** Build the full diagnostic report for the module at `moduleUrl`. */
3077
- async function buildDoctorReport(moduleUrl) {
3078
- const { version, path } = runningVersion(moduleUrl);
3079
- const latest = await npmLatest();
3080
- return {
3081
- running: version,
3082
- packagePath: path,
3083
- runtime: process.execPath,
3084
- latest,
3085
- stale: latest !== null && latest !== version
3086
- };
3551
+ /** Purge stale buckets, then atomically persist (unique tmp + rename). Non-throwing. */
3552
+ function write(home, reg, now) {
3553
+ const f = file(home);
3554
+ if (!f) return;
3555
+ for (const [sid, entry] of Object.entries(reg)) if (!entry || now - (entry.updatedAt ?? 0) > PURGE_MS) delete reg[sid];
3556
+ try {
3557
+ mkdirSync(dirname(f), { recursive: true });
3558
+ atomicWrite(f, JSON.stringify(reg));
3559
+ } catch {}
3087
3560
  }
3088
- /** Render a {@link DoctorReport} as human-readable stdout text. */
3089
- function formatDoctor(r) {
3090
- const lines = [
3091
- `${PKG} doctor`,
3092
- ` running: ${r.running}`,
3093
- ` package: ${r.packagePath}`,
3094
- ` runtime: ${r.runtime}`,
3095
- ` npm latest: ${r.latest ?? "(unavailable — offline or unreachable)"}`
3096
- ];
3097
- if (r.stale) lines.push(` ! stale — npm serves ${r.latest}. Pin "@fusengine/harness@${r.latest}" in hooks.json (see README).`);
3098
- else if (r.latest !== null) lines.push(` ok — running the latest published version.`);
3099
- return lines.join("\n");
3561
+ /** Record `field` for `(sid, root)`, refreshing the purge cursor. `home` defaults to `$HOME`. */
3562
+ function markSessionRoot(sid, root, field, value, home = process.env.HOME) {
3563
+ const reg = read(home);
3564
+ const prev = reg[sid];
3565
+ const entry = prev && typeof prev.roots === "object" && prev.roots !== null ? prev : {
3566
+ updatedAt: value,
3567
+ roots: {}
3568
+ };
3569
+ const mark = entry.roots[root] ?? {
3570
+ editedAt: 0,
3571
+ remindedAt: 0
3572
+ };
3573
+ entry.roots[root] = {
3574
+ ...mark,
3575
+ [field]: value
3576
+ };
3577
+ entry.updatedAt = value;
3578
+ reg[sid] = entry;
3579
+ write(home, reg, value);
3100
3580
  }
3101
- /** Run `harness doctor`: print the diagnostic to stdout. Always resolves 0 (pure info). */
3102
- async function runDoctor(moduleUrl) {
3103
- process.stdout.write(formatDoctor(await buildDoctorReport(moduleUrl)) + "\n");
3104
- return 0;
3581
+ /**
3582
+ * Roots of `sid` with an unsaved code edit past the `window`; each returned
3583
+ * root's `remindedAt` is bumped to `now` so the reminder fires at most once per
3584
+ * window and is consumed ONLY by this session. `home` defaults to `$HOME`.
3585
+ */
3586
+ function collectSessionPending(sid, now, window, home = process.env.HOME) {
3587
+ const reg = read(home);
3588
+ const entry = reg[sid];
3589
+ if (!entry || typeof entry.roots !== "object" || entry.roots === null) return [];
3590
+ const pending = [];
3591
+ for (const [root, mark] of Object.entries(entry.roots)) {
3592
+ if (mark.editedAt <= mark.remindedAt) continue;
3593
+ if (now - mark.remindedAt < window) continue;
3594
+ pending.push(root);
3595
+ entry.roots[root] = {
3596
+ ...mark,
3597
+ remindedAt: now
3598
+ };
3599
+ }
3600
+ if (pending.length > 0) write(home, reg, now);
3601
+ return pending;
3105
3602
  }
3106
3603
  //#endregion
3107
- //#region src/runtime/lifecycle/snapshot/version.ts
3108
- /** Read the `version` field of `<root>/package.json`, or `""` if absent/unreadable. */
3109
- function pkgVersion(root) {
3110
- try {
3111
- return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version ?? "";
3112
- } catch {
3113
- return "";
3604
+ //#region src/runtime/lifecycle/lessons/reminder.ts
3605
+ /**
3606
+ * fuse-lessons write-mark + Stop-reminder, scoped by `session_id` when present.
3607
+ *
3608
+ * WITH a session id (normal Claude Code): each `(session, root)` pair carries
3609
+ * its own edit/reminder throttle in {@link module:memory/session-roots}, so a
3610
+ * Stop lists and silences ONLY the roots THAT session edited — concurrent
3611
+ * sessions on different projects never cross-remind nor steal each other's
3612
+ * throttle. WITHOUT a usable session id (a harness that omits it, or the legacy
3613
+ * on-disk state) it falls back to the original mono-session behavior: the global
3614
+ * flat root registry + the per-project `MEMORY/state.json` throttle.
3615
+ */
3616
+ /** Sanitized session id from a raw hook payload, or null (→ legacy fallback). */
3617
+ function sessionOf(payload) {
3618
+ return sanitizeSessionId(payload.session_id);
3619
+ }
3620
+ /** Legacy (no session id): pending roots across the global flat registry. */
3621
+ function collectLegacyPending(now, window) {
3622
+ const pending = [];
3623
+ for (const root of readRoots()) {
3624
+ const stateFile = lessonsStateFileFor(root);
3625
+ const { lastRemindedAt, lastCodeEditAt } = readState(stateFile);
3626
+ if (lastCodeEditAt <= lastRemindedAt) continue;
3627
+ if (now - lastRemindedAt < window) continue;
3628
+ pending.push(root);
3629
+ setStateField(stateFile, "lastRemindedAt", now);
3114
3630
  }
3631
+ return pending;
3632
+ }
3633
+ /** Stop reminder body listing each pending project's lessons file. */
3634
+ function reminderText(pending) {
3635
+ 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")}`;
3115
3636
  }
3116
3637
  /**
3117
- * Collect the version reconciliation section: the harness version actually
3118
- * running (resolved from {@link runningVersion}, no network) and, when `root`
3119
- * carries its own `package.json`, whether that project's version has drifted
3120
- * from the running harness.
3121
- * @param root - The project root (cwd repo).
3122
- * @param moduleUrl - `import.meta.url` of the calling module (locates the running package.json).
3123
- * @returns The rendered version section body (never `""`).
3638
+ * Stop: emit one reminder covering the stopping session's pending projects.
3639
+ * @param payload - Raw hook payload (`session_id` selects the scoped path).
3640
+ * @param now - Clock.
3641
+ * @returns Native Stop stdout, or "" when nothing is pending.
3124
3642
  */
3125
- function collectVersion(root, moduleUrl) {
3126
- const running = runningVersion(moduleUrl).version;
3127
- const lines = [`- harness running: v${running}`];
3128
- const project = pkgVersion(root);
3129
- if (project && project !== running) lines.push(`- project package.json: v${project} (DRIFT — running harness differs)`);
3130
- else if (project) lines.push(`- project package.json: v${project} (in sync)`);
3131
- return lines.join("\n");
3643
+ function remindWrite(payload, now) {
3644
+ const window = throttleMs();
3645
+ const sid = sessionOf(payload);
3646
+ const pending = sid ? collectSessionPending(sid, now, window) : collectLegacyPending(now, window);
3647
+ if (pending.length === 0) return "";
3648
+ return contextResponse("Stop", reminderText(pending));
3132
3649
  }
3133
- //#endregion
3134
- //#region src/runtime/lifecycle/snapshot/board.ts
3135
- /** Max board characters injected — a persistent board should stay small; over-long boards are truncated. */
3136
- const MAX_BOARD = 4e3;
3137
3650
  /**
3138
- * Collect the persistent task board: the contents of `<root>/.claude/BOARD.md`
3139
- * (truncated to {@link MAX_BOARD}) plus an instruction to keep it current. The
3140
- * board lives on disk so it survives context purges — rehydrated every session.
3141
- * Missing/empty/unreadable board `""` (section omitted).
3142
- * @param root - The project root.
3143
- * @returns The rendered board section body, or `""` when there is no board.
3651
+ * PostToolUse: record the edit against the throttle. A code file arms the
3652
+ * reminder; writing `MEMORY/LESSON.md` silences it (the lesson was just saved).
3653
+ * Session-scoped when `session_id` is present, else the legacy global path.
3654
+ * @param payload - Raw hook payload (`tool_input.file_path`, `session_id`).
3655
+ * @param now - Clock.
3144
3656
  */
3145
- function collectBoard(root) {
3146
- const path = join(root, ".claude", "BOARD.md");
3147
- try {
3148
- if (!existsSync(path)) return "";
3149
- let body = readFileSync(path, "utf8").trim();
3150
- if (!body) return "";
3151
- if (body.length > MAX_BOARD) body = `${body.slice(0, MAX_BOARD)}\n… (truncated)`;
3152
- return `- .claude/BOARD.md (keep current — Write to it as tasks start/finish):\n\n${body}`;
3153
- } catch {
3154
- return "";
3657
+ function markWrite(payload, now) {
3658
+ const input = payload.tool_input;
3659
+ if (!input?.file_path) return;
3660
+ const abs = resolve(input.file_path);
3661
+ const root = projectRootOrNull(dirname(abs));
3662
+ if (!root) return;
3663
+ const isLesson = abs === resolve(root, "MEMORY", "LESSON.md");
3664
+ if (!isLesson && !isCodeFile(abs)) return;
3665
+ const sid = sessionOf(payload);
3666
+ if (sid) markSessionRoot(sid, root, isLesson ? "remindedAt" : "editedAt", now);
3667
+ else if (isLesson) setStateField(lessonsStateFileFor(root), "lastRemindedAt", now);
3668
+ else {
3669
+ setStateField(lessonsStateFileFor(root), "lastCodeEditAt", now);
3670
+ addRoot(root);
3155
3671
  }
3156
3672
  }
3157
3673
  //#endregion
3158
- //#region src/runtime/lifecycle/snapshot/format.ts
3674
+ //#region src/runtime/lifecycle/lessons/dispatch.ts
3159
3675
  /**
3160
- * Render the non-empty `sections` under one reconciliation heading. Empty
3161
- * sections are dropped; when every section is empty the whole snapshot is `""`.
3162
- * @param sections - The collected sections in display order.
3163
- * @returns The assembled markdown block, or `""` when nothing to report.
3676
+ * fuse-lessons scope dispatch (TS port of the 4 handler scripts). Routes by
3677
+ * event: SessionStart/SubagentStart inject `MEMORY/LESSON.md`; Stop reminds the
3678
+ * stopping session about ITS OWN projects with unsaved code edits; PostToolUse
3679
+ * marks the write to arm/silence the throttle. The reminder + mark logic (incl.
3680
+ * the per-`session_id` scoping that fixes the multi-session misdirection) lives
3681
+ * in {@link module:runtime/lifecycle/lessons/reminder}; this module keeps the
3682
+ * event router + lesson-file injection. Non-fatal by design.
3164
3683
  */
3165
- function renderSections(sections) {
3166
- const parts = sections.filter((s) => s.body.trim()).map((s) => `### ${s.title}\n${s.body.trim()}`);
3167
- if (!parts.length) return "";
3168
- return `# Reconciliation snapshot
3169
- Real state of the world at session start — reconcile against this instead of re-discovering it.\n\n${parts.join("\n\n")}`;
3170
- }
3171
3684
  /**
3172
- * Concatenate `snapshot` onto an existing SessionStart stdout's
3173
- * `additionalContext` it never replaces prior injected context (CLAUDE.md,
3174
- * dev-context). When `stdout` is empty a fresh {@link contextResponse} is made;
3175
- * a non-empty but unparseable `stdout` is returned UNCHANGED (the snapshot is
3176
- * dropped) fabricating a fresh response there would discard the very CLAUDE.md
3177
- * injection the invariant protects, so preserving prior context always wins.
3178
- * @param stdout - The core SessionStart JSON stdout (may be `""`).
3179
- * @param snapshot - The snapshot markdown to append (no-op when `""`).
3180
- * @returns The merged hook stdout JSON.
3685
+ * Persist a curation ATOMICALLY and ARCHIVE-FIRST for zero-loss: prepend the
3686
+ * moved bullets to LESSON-archive.md, THEN rewrite LESSON.md. On ANY write error
3687
+ * the original file is left untouched (returns `original`) so a bullet is never
3688
+ * lost a rare archive-then-trim-fail leaves a duplicate (never a loss), which
3689
+ * the next dedup pass reconciles.
3181
3690
  */
3182
- function attachSnapshot(stdout, snapshot) {
3183
- if (!snapshot) return stdout;
3184
- if (!stdout) return contextResponse("SessionStart", snapshot);
3691
+ function persistCuration(file, root, curated, archive, original) {
3185
3692
  try {
3186
- const parsed = JSON.parse(stdout);
3187
- const prev = parsed.hookSpecificOutput?.additionalContext ?? "";
3188
- const merged = prev ? `${prev}\n\n${snapshot}` : snapshot;
3189
- return JSON.stringify({
3190
- ...parsed,
3191
- hookSpecificOutput: {
3192
- ...parsed.hookSpecificOutput,
3193
- hookEventName: "SessionStart",
3194
- additionalContext: merged
3195
- }
3196
- });
3693
+ if (archive) {
3694
+ const af = lessonsArchiveFileFor(root);
3695
+ const prev = existsSync(af) ? readFileSync(af, "utf-8") : "";
3696
+ atomicWrite(af, prev ? `${archive}\n${prev}` : archive);
3697
+ }
3698
+ atomicWrite(file, curated);
3699
+ return curated;
3197
3700
  } catch {
3198
- return stdout;
3701
+ return original;
3199
3702
  }
3200
3703
  }
3201
- //#endregion
3202
- //#region src/runtime/lifecycle/snapshot/index.ts
3203
- /** Run `fn`, swallowing any throw into `""` so no single collector can break the hook. */
3204
- function safe(fn) {
3704
+ /**
3705
+ * Inject `MEMORY/LESSON.md` for `event`. Mechanical curation (dedup + cap→archive)
3706
+ * rewrites the FILE; the injected BLOCK is then COMPRESSED (newest bullets whole,
3707
+ * older ones distilled to their rule) so a growing file never inflates the
3708
+ * SessionStart/SubagentStart context. A hard {@link capFragment} budget is the
3709
+ * last-resort backstop on top of compression — the regression that motivated it
3710
+ * was this exact block silently reaching ~44k tokens. Any curation report
3711
+ * surfaces via systemMessage.
3712
+ */
3713
+ function injectMemory(cwd, event, now) {
3714
+ const root = projectRoot(cwd);
3715
+ const file = lessonsFileFor(root);
3716
+ if (!existsSync(file)) return "";
3717
+ let content = "";
3205
3718
  try {
3206
- return fn();
3719
+ content = readFileSync(file, "utf-8").trim();
3207
3720
  } catch {
3208
3721
  return "";
3209
3722
  }
3723
+ if (!content) return "";
3724
+ const { content: curated, archive, report } = curateLessons(content, now, root);
3725
+ if (curated !== content) content = persistCuration(file, root, curated, archive, content);
3726
+ const ctx = `Project lessons — never reproduce these:\n${capFragment("lessons", compressInjection(content))}\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.`;
3727
+ return report ? attachSystemMessage(contextResponse(event, ctx), `LESSON.md curation:\n${report}`) : contextResponse(event, ctx);
3210
3728
  }
3211
3729
  /**
3212
- * Build the reconciliation snapshot markdown for `cwd`: git state, running
3213
- * harness version + drift, the persistent board, and one-shot gate status. Each
3214
- * collector is isolated by {@link safe}; an all-empty result yields `""`.
3215
- * @param cwd - The session working directory.
3216
- * @param moduleUrl - `import.meta.url` of the caller (locates the running package).
3217
- * @returns The snapshot markdown, or `""` when nothing to report.
3218
- */
3219
- function renderSnapshot(cwd, moduleUrl) {
3220
- const root = projectRootOrNull(cwd) ?? cwd;
3221
- return renderSections([
3222
- {
3223
- title: "Git",
3224
- body: safe(() => collectGit(root))
3225
- },
3226
- {
3227
- title: "Version",
3228
- body: safe(() => collectVersion(root, moduleUrl))
3229
- },
3230
- {
3231
- title: "Board",
3232
- body: safe(() => collectBoard(root))
3233
- },
3234
- {
3235
- title: "One-shot gates",
3236
- body: safe(() => oneShotSummary(cwd))
3237
- }
3238
- ]);
3239
- }
3240
- /**
3241
- * Concatenate the reconciliation snapshot onto a core SessionStart stdout. Fully
3242
- * fail-safe: any error returns `stdout` unchanged so the hook never breaks.
3243
- * @param stdout - The core SessionStart JSON stdout (may be `""`).
3244
- * @param cwd - The session working directory.
3245
- * @param moduleUrl - `import.meta.url` of the caller.
3246
- * @returns The merged hook stdout.
3730
+ * Route a fuse-lessons event to its handler. Returns the native stdout for
3731
+ * context-injecting events (SessionStart/SubagentStart/Stop) or "" for the
3732
+ * side-effect-only PostToolUse mark.
3733
+ * @param event - The raw hook event name.
3734
+ * @param payload - The raw hook payload.
3735
+ * @param cwd - Project root for memory injection.
3736
+ * @param now - Clock.
3737
+ * @returns The native stdout (possibly empty).
3247
3738
  */
3248
- function withSnapshot(stdout, cwd, moduleUrl) {
3249
- try {
3250
- return attachSnapshot(stdout, renderSnapshot(cwd, moduleUrl));
3251
- } catch {
3252
- return stdout;
3739
+ function dispatchLessons(event, payload, cwd, now) {
3740
+ switch (event) {
3741
+ case "SessionStart":
3742
+ case "SubagentStart": return injectMemory(cwd, event, now);
3743
+ case "Stop": return remindWrite(payload, now);
3744
+ case "PostToolUse":
3745
+ markWrite(payload, now);
3746
+ return "";
3747
+ default: return "";
3253
3748
  }
3254
3749
  }
3255
3750
  //#endregion
@@ -3292,7 +3787,7 @@ async function injectApexSubagentContext(cwd, home = homedir()) {
3292
3787
  const agentsPath = join(apexDir, "AGENTS.md");
3293
3788
  const agents = existsSync(agentsPath) ? readText(agentsPath).slice(0, 4e3) : "";
3294
3789
  const taskData = await readJsonFile(join(apexDir, "task.json"));
3295
- return contextResponse("SubagentStart", `## APEX Sub-Agent Instructions
3790
+ return contextResponse("SubagentStart", capFragment("apex-subagent", `## APEX Sub-Agent Instructions
3296
3791
 
3297
3792
  You are a sub-agent in APEX workflow. Follow these rules:
3298
3793
 
@@ -3313,7 +3808,7 @@ ${agents}
3313
3808
  - Use Context7/Exa for docs | Write notes to .claude/apex/docs/
3314
3809
 
3315
3810
  ### 6. When Done
3316
- - TaskUpdate(taskId, status: completed) triggers auto-commit${cartographerContext()}`);
3811
+ - TaskUpdate(taskId, status: completed) triggers auto-commit${cartographerContext()}`));
3317
3812
  }
3318
3813
  /** 16-char hex SHA-256 of `text` (project hash / doc topic key). */
3319
3814
  function hashText16(text) {
@@ -4535,10 +5030,9 @@ function dispatchLifecycle(input) {
4535
5030
  if (input.scope === "aipilot") return "";
4536
5031
  harvestSubagentTrack(input.payload, input.cwd, input.now);
4537
5032
  return trackAgentMemory(input.payload, void 0, input.now);
4538
- case "TeammateIdle": return validateTeammateOutput(input.payload);
4539
- case "PostToolUseFailure":
4540
- logToolFailure(input.payload, void 0, input.now);
4541
- return "";
5033
+ case "TeammateIdle": return teammateIdleContext(input.payload, input.cwd, void 0, input.now);
5034
+ case "PostToolUseFailure": return failureLessonContext(input.payload, input.cwd, void 0, input.now);
5035
+ case "PostCompact": return input.scope === "core" ? postCompactContext(input.payload, input.cwd, import.meta.url, input.now) : "";
4542
5036
  case "PreCompact": return saveApexState(input.cwd, input.now);
4543
5037
  case "SessionEnd":
4544
5038
  if (input.scope !== "aipilot") cleanupSession(void 0, input.now);
@@ -5587,6 +6081,50 @@ function frameworkSkillGate(input, refsRead, existingCodeLines) {
5587
6081
  return skillTriggerGate(input.framework, content, refsRead, forced, input.cwd, input.filePath);
5588
6082
  }
5589
6083
  //#endregion
6084
+ //#region src/freshness/ref-evidence.ts
6085
+ /**
6086
+ * Platform-transcript reconciliation for `.md` reference reads — the durable
6087
+ * counterpart to {@link agentsRanFromTranscript} for agent freshness.
6088
+ *
6089
+ * WHY: the session track is persisted by a non-atomic load→mutate→save. Under
6090
+ * the multi-plugin hook fan-out (one hook process per installed plugin, ×N) plus
6091
+ * back-to-back tool events, concurrent writers clobber each other (lost update).
6092
+ * `agents`/`authorizations` self-heal — every explore/research/doc call rewrites
6093
+ * them, so a lost update lands again on the next of hundreds of writes — but a
6094
+ * `refsRead` entry is written ONCE (when the `.md` is Read), so a single lost
6095
+ * update erases it permanently, and the lead has no SubagentStop
6096
+ * {@link harvestAgentEvidence} pass to reconcile it (sub-agents do, which is why
6097
+ * only the LEAD's solidReadGate never credited). The Claude-authored transcript
6098
+ * is append-only and race-immune, so folding its `.md` Reads back into the track
6099
+ * restores the lost evidence; each gate still applies its own TTL/session policy.
6100
+ */
6101
+ /**
6102
+ * Fold every `.md` `Read` in the transcript into `track` as a timestamped ref
6103
+ * read (immutably). PURE reconciliation — the caller owns the track; each read
6104
+ * is stamped with its transcript timestamp (unstamped → `now`, lenient, matching
6105
+ * {@link agentsRanFromTranscript}), and an existing MORE-recent stamp is never
6106
+ * rolled back. Fail-open: an absent/unreadable transcript returns `track`
6107
+ * unchanged (same reference).
6108
+ * @param track - The current (possibly race-damaged) session track.
6109
+ * @param transcriptPath - Claude `transcript_path` for this session.
6110
+ * @param now - Fallback epoch-ms for transcript entries the platform left unstamped.
6111
+ * @returns The track with transcript `.md` reads merged into `refsRead`/`refsReadAt`.
6112
+ */
6113
+ function reconcileRefReadsFromTranscript(track, transcriptPath, now) {
6114
+ const uses = readAgentToolUses(transcriptPath);
6115
+ if (!uses) return track;
6116
+ let next = track;
6117
+ for (const u of uses) {
6118
+ if (u.name !== "Read") continue;
6119
+ const path = String(u.input?.file_path ?? u.input?.path ?? "");
6120
+ if (!path.endsWith(".md")) continue;
6121
+ const ts = u.ts ?? now;
6122
+ const prev = next.refsReadAt?.[path];
6123
+ if (prev === void 0 || prev < ts) next = recordRefRead(next, path, ts);
6124
+ }
6125
+ return next;
6126
+ }
6127
+ //#endregion
5590
6128
  //#region src/policy/shadcn-skill-gate.ts
5591
6129
  /** File extensions the shadcn gate polices (source: `\.(tsx|jsx|css|scss|json)$`). */
5592
6130
  const SHADCN_FILE_RE = /\.(tsx|jsx|css|scss|json)$/;
@@ -6048,8 +6586,7 @@ async function runGates(input) {
6048
6586
  if (modular) return modular;
6049
6587
  if (!input.filePath) return null;
6050
6588
  const filePath = input.filePath;
6051
- const window = input.windowMs ?? 12e4;
6052
- const track = await loadTrack(input.trackFile);
6589
+ const track = reconcileRefReadsFromTranscript(await loadTrack(input.trackFile), input.transcriptPath, input.now);
6053
6590
  const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingCodeLines);
6054
6591
  if (solidOrSkill) return solidOrSkill;
6055
6592
  if (isShadcnWrite(input.tool, filePath)) {
@@ -6070,7 +6607,7 @@ async function runGates(input) {
6070
6607
  });
6071
6608
  if (geminiBlock) return geminiBlock;
6072
6609
  if (isApexScoped(input.filePath)) {
6073
- const apex = await apexScopedGate(input, track, window);
6610
+ const apex = await apexScopedGate(input, track, input.windowMs ?? 12e4);
6074
6611
  if (apex) return apex;
6075
6612
  }
6076
6613
  return dryGate(input.tool, input.filePath, input.content, input.cwd);
@@ -6690,181 +7227,49 @@ function designGate(payload, event, cacheDir, cwd) {
6690
7227
  return null;
6691
7228
  }
6692
7229
  //#endregion
6693
- //#region src/policy/lessons/trigger-index.ts
6694
- /**
6695
- * Compile the triggered-lesson index from `MEMORY/LESSON.md`. A lesson is a
6696
- * bullet (`- [YYYY-MM-DD HH:MM] ...`); it opts into decision-time injection by
6697
- * ending with a `[TRIGGERS tool:.. path:.. error:.. keyword:..]` line. Lessons
6698
- * WITHOUT that tag are skipped here (they keep the SessionStart block behavior —
6699
- * zero regression). Parsed once per file version (mtime-memoized).
6700
- */
6701
- /** Matches a trailing `[TRIGGERS ...]` line (its body captured). */
6702
- const TRIGGER_RE = /^\[TRIGGERS\s+(.+?)\]$/;
6703
- /** Comma list for `key:` in a trigger body (values are space-delimited). */
6704
- function list(body, key) {
6705
- const val = body.match(new RegExp(`\\b${key}:([^\\s\\]]+)`))?.[1];
6706
- return val ? val.split(",").filter(Boolean) : [];
6707
- }
6708
- /** Parse a `[TRIGGERS ...]` body into predicates (error is a single regex). */
6709
- function parseTriggers(body) {
6710
- const err = body.match(/\berror:([^\s\]]+)/);
6711
- return {
6712
- tools: list(body, "tool"),
6713
- paths: list(body, "path"),
6714
- error: err?.[1],
6715
- keywords: list(body, "keyword")
6716
- };
6717
- }
6718
- /** Collapse to a single ≤3-line compact string (cap length). */
6719
- function compact(text) {
6720
- const one = text.replace(/\s+/g, " ").trim();
6721
- return one.length > 280 ? `${one.slice(0, 277)}…` : one;
6722
- }
6723
- /**
6724
- * Parse LESSON.md content into triggered entries. A bullet's text spans its
6725
- * `- ` line plus any following non-blank continuation lines up to the next
6726
- * bullet; a `[TRIGGERS ...]` continuation line arms it.
6727
- * @param content - Raw LESSON.md text.
6728
- * @returns Entries that declared triggers (others skipped).
6729
- */
6730
- function parseLessons(content) {
6731
- const lines = content.split("\n");
6732
- const out = [];
6733
- for (let i = 0; i < lines.length; i++) {
6734
- const line = lines[i];
6735
- if (line === void 0 || !line.startsWith("- ")) continue;
6736
- let text = line.slice(2);
6737
- let triggers = null;
6738
- for (let j = i + 1; j < lines.length; j++) {
6739
- const cont = lines[j];
6740
- if (cont === void 0 || cont.trim() === "" || cont.startsWith("- ")) break;
6741
- const body = cont.trim().match(TRIGGER_RE)?.[1];
6742
- if (body !== void 0) triggers = parseTriggers(body);
6743
- else text += ` ${cont.trim()}`;
6744
- }
6745
- if (triggers) out.push({
6746
- text: compact(text),
6747
- triggers
6748
- });
6749
- }
6750
- return out;
6751
- }
6752
- let memo = null;
6753
- /**
6754
- * Compile (once per file version) the triggered-lesson index from `file`.
6755
- * Memoized by path+mtime: re-parses only when LESSON.md changes.
6756
- * @param file - Absolute path to MEMORY/LESSON.md.
6757
- * @returns The compiled entries (missing/unreadable file → empty).
6758
- */
6759
- function lessonIndex(file) {
6760
- let key;
6761
- try {
6762
- key = `${file}:${statSync(file).mtimeMs}`;
6763
- } catch {
6764
- return [];
6765
- }
6766
- if (memo?.key === key) return memo.entries;
6767
- let entries = [];
6768
- try {
6769
- entries = parseLessons(readFileSync(file, "utf-8"));
6770
- } catch {
6771
- entries = [];
6772
- }
6773
- memo = {
6774
- key,
6775
- entries
6776
- };
6777
- return entries;
6778
- }
6779
- /** Glob (`*`/`**`) → RegExp, matching a path segment/tail. */
6780
- function globToRe(glob) {
6781
- const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(//g, ".*");
6782
- return new RegExp(`(^|/)${esc}$`);
6783
- }
6784
- /** Safe case-insensitive regex test (absent source or invalid → false). */
6785
- function safeTest(src, s) {
6786
- if (!src) return false;
6787
- try {
6788
- return new RegExp(src, "i").test(s);
6789
- } catch {
6790
- return false;
6791
- }
6792
- }
6793
- /** Score one entry against the call; null = no predicate matched. */
6794
- function scoreEntry(e, tool, filePath, inputJson, prevError) {
6795
- const tr = e.triggers;
6796
- if (tr.tools.includes(tool)) return {
6797
- entry: e,
6798
- rank: 3
6799
- };
6800
- if (filePath && tr.paths.some((g) => globToRe(g).test(filePath))) return {
6801
- entry: e,
6802
- rank: 2
6803
- };
6804
- if (prevError && safeTest(tr.error, prevError)) return {
6805
- entry: e,
6806
- rank: 1
6807
- };
6808
- if (tr.keywords.some((k) => inputJson.includes(k))) return {
6809
- entry: e,
6810
- rank: 0
6811
- };
6812
- return null;
6813
- }
6814
- /** Stable, filesystem-safe cooldown key from a lesson's compact text (djb2). */
6815
- function cooldownKey(text) {
6816
- let h = 5381;
6817
- for (let i = 0; i < text.length; i++) h = (h << 5) + h + text.charCodeAt(i) | 0;
6818
- return `lesson:${(h >>> 0).toString(36)}`;
6819
- }
6820
- /**
6821
- * The single most-specific lesson for this PreToolUse call, or null. Matching
6822
- * priority: exact tool > path glob > error regex > input-JSON keyword. Cooldown
6823
- * suppresses a lesson already injected within the window.
6824
- * @param tool - The tool being called (e.g. `Write`).
6825
- * @param toolInput - The raw `tool_input`.
6826
- * @param opts - Index file, cooldown gate, and optional prior error.
6827
- * @returns An `inform` prompt, or null when nothing matches / in cooldown.
6828
- */
6829
- function lessonFor(tool, toolInput, opts) {
6830
- const entries = lessonIndex(opts.file);
6831
- if (entries.length === 0) return null;
6832
- const filePath = typeof toolInput?.file_path === "string" ? toolInput.file_path : "";
6833
- const inputJson = JSON.stringify(toolInput ?? {});
6834
- let best = null;
6835
- for (const e of entries) {
6836
- const m = scoreEntry(e, tool, filePath, inputJson, opts.prevError);
6837
- if (m && (!best || m.rank > best.rank)) best = m;
6838
- }
6839
- if (!best) return null;
6840
- if (!opts.once(cooldownKey(best.entry.text), opts.cooldownMs ?? 18e5)) return null;
6841
- return {
6842
- kind: "inform",
6843
- title: `Project lesson${filePath ? ` (${basename(filePath)})` : ""}`,
6844
- reason: best.entry.text
6845
- };
6846
- }
6847
- //#endregion
6848
7230
  //#region src/runtime/pre-allow.ts
6849
7231
  /**
6850
7232
  * PreToolUse ALLOW-path response assembly. Reached only after every gate
6851
7233
  * allowed (a deny/ask already returned upstream), so nothing here can block nor
6852
7234
  * override a decision. Combines the Python-parity pass notice (systemMessage)
6853
- * with the single most-specific decision-time lesson (additionalContext).
6854
- */
7235
+ * with the single most-specific decision-time lesson (additionalContext), plus
7236
+ * a compact "evidence fresh" compliance notice on the first APEX-scoped code
7237
+ * Write/Edit that clears the freshness gate.
7238
+ */
7239
+ /**
7240
+ * `✓ evidence fresh (explore+research)` for the first APEX-scoped Write/Edit
7241
+ * that finds explore-codebase + research-expert evidence still within the
7242
+ * freshness window — the user-visible confirmation for a gate that, until now,
7243
+ * only ever spoke up when it BLOCKED. Deduped once per freshness window per
7244
+ * session (same window the gate itself re-validates on), so it reads as "just
7245
+ * confirmed", not a notice on every edit. Returns null for anything but a
7246
+ * Write/Edit on an APEX-scoped path, or when evidence isn't fresh.
7247
+ */
7248
+ async function freshEvidenceNotice(event, evidence, cwd) {
7249
+ if (event.tool !== "Write" && event.tool !== "Edit" || !isApexScoped(event.filePath)) return null;
7250
+ const windowMs = evidence.windowMs ?? 12e4;
7251
+ if (!agentsFreshInTrack(await loadTrack(evidence.trackFile), REQUIRED_AGENTS, windowMs, evidence.now)) return null;
7252
+ if (!oncePerWindow(`evidence-fresh:${event.sessionId}`, windowMs, {
7253
+ now: evidence.now,
7254
+ dir: defaultStateDir(cwd)
7255
+ })) return null;
7256
+ return evidenceFreshNotice();
7257
+ }
6855
7258
  /**
6856
7259
  * Build the native outcome for a PreToolUse call that passed every gate: emit a
6857
- * user-visible pass notice (once per allowed call) and, when its TRIGGERS match
6858
- * this call, the one cooldown-guarded decision-time lesson. Both channels ride a
6859
- * single response (lesson additionalContext, notice systemMessage).
7260
+ * user-visible pass notice (once per allowed call), the "evidence fresh"
7261
+ * compliance notice when applicable, and, when its TRIGGERS match this call,
7262
+ * the one cooldown-guarded decision-time lesson. All 3 channels ride a single
7263
+ * response (lesson → additionalContext, notices → systemMessage).
6860
7264
  * @param id - Harness id for {@link respond}.
6861
7265
  * @param event - The normalized PreToolUse event.
6862
7266
  * @param payload - The raw hook payload (for `agent_id`).
6863
7267
  * @param mcpDir - MCP state dir backing the pass-notice throttle.
6864
7268
  * @param cwd - Project root (lesson file + notice scope).
7269
+ * @param evidence - Session track + clock for the "evidence fresh" notice (omit to disable it).
6865
7270
  * @returns The native hook outcome (empty stdout when nothing to emit).
6866
7271
  */
6867
- function allowOutcome(id, event, payload, mcpDir, cwd) {
7272
+ async function allowOutcome(id, event, payload, mcpDir, cwd, evidence) {
6868
7273
  const notice = designPassNotice({
6869
7274
  agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
6870
7275
  tool: event.tool,
@@ -6877,13 +7282,32 @@ function allowOutcome(id, event, payload, mcpDir, cwd) {
6877
7282
  file: lessonsFileFor(projectRoot(cwd)),
6878
7283
  once: oncePerWindow
6879
7284
  });
6880
- if (lesson) return {
6881
- stdout: respond(id, notice?.userMessage ? {
6882
- ...lesson,
6883
- userMessage: notice.userMessage
6884
- } : lesson),
6885
- exit: 0
6886
- };
7285
+ const evidenceNotice = evidence ? await freshEvidenceNotice(event, evidence, cwd) : null;
7286
+ if (lesson) {
7287
+ const userMessage = [notice?.userMessage, evidenceNotice].filter(Boolean).join("\n") || void 0;
7288
+ return {
7289
+ stdout: respond(id, userMessage ? {
7290
+ ...lesson,
7291
+ userMessage
7292
+ } : lesson),
7293
+ exit: 0
7294
+ };
7295
+ }
7296
+ if (evidenceNotice) {
7297
+ const userMessage = [notice?.userMessage, evidenceNotice].filter(Boolean).join("\n");
7298
+ return {
7299
+ stdout: respond(id, notice ? {
7300
+ ...notice,
7301
+ userMessage
7302
+ } : {
7303
+ kind: "inform",
7304
+ title: "APEX freshness",
7305
+ reason: "",
7306
+ userMessage
7307
+ }),
7308
+ exit: 0
7309
+ };
7310
+ }
6887
7311
  return {
6888
7312
  stdout: notice ? respond(id, notice) : "",
6889
7313
  exit: 0
@@ -6954,7 +7378,11 @@ async function handlePre(ctx) {
6954
7378
  stdout: respond(id, prompt),
6955
7379
  exit: 0
6956
7380
  };
6957
- return allowOutcome(id, event, payload, mcpDir, opts.cwd);
7381
+ return allowOutcome(id, event, payload, mcpDir, opts.cwd, {
7382
+ trackFile: file,
7383
+ windowMs: opts.windowMs,
7384
+ now: opts.now
7385
+ });
6958
7386
  }
6959
7387
  //#endregion
6960
7388
  //#region src/freshness/query-framework.ts
@@ -7185,27 +7613,38 @@ async function handlePost(ctx) {
7185
7613
  url: "",
7186
7614
  phase: "post"
7187
7615
  }, mcpDir);
7616
+ const refNotice = refCreditNoticeFor(activities, event.sessionId, opts.now, defaultStateDir(opts.cwd));
7617
+ const userMessage = [notice?.userMessage, refNotice].filter(Boolean).join("\n") || void 0;
7188
7618
  if (designWarn) return {
7189
- stdout: respond(id, notice?.userMessage ? {
7619
+ stdout: respond(id, userMessage ? {
7190
7620
  ...designWarn,
7191
- userMessage: notice.userMessage
7621
+ userMessage
7192
7622
  } : designWarn),
7193
7623
  exit: 0
7194
7624
  };
7195
- if (!notice?.userMessage) return {
7625
+ if (!userMessage) return {
7196
7626
  stdout: extra,
7197
7627
  exit: 0
7198
7628
  };
7629
+ const withUserMessage = notice ? {
7630
+ ...notice,
7631
+ userMessage
7632
+ } : {
7633
+ kind: "inform",
7634
+ title: "Compliance",
7635
+ reason: "",
7636
+ userMessage
7637
+ };
7199
7638
  if (!extra) return {
7200
- stdout: respond(id, notice),
7639
+ stdout: respond(id, withUserMessage),
7201
7640
  exit: 0
7202
7641
  };
7203
7642
  if (id === "claude-code" || id === "codex") return {
7204
- stdout: attachSystemMessage(extra, notice.userMessage),
7643
+ stdout: attachSystemMessage(extra, userMessage),
7205
7644
  exit: 0
7206
7645
  };
7207
7646
  return {
7208
- stdout: respond(id, notice) || extra,
7647
+ stdout: respond(id, withUserMessage) || extra,
7209
7648
  exit: 0
7210
7649
  };
7211
7650
  }
@@ -7290,4 +7729,4 @@ async function handleHook(id, payload, opts) {
7290
7729
  });
7291
7730
  }
7292
7731
  //#endregion
7293
- 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 };
7732
+ export { postEditTypescript as $, trackSkillRead as A, trackFile as At, isProject as B, seoPostToolUse as C, gitContext as Ct, postTrackingSideEffects as D, taskContext as Dt, securityAdvisory as E, promptSubmitContext as Et, dispatchLessons as F, securityStateDir as Ft, getFileDesc as G, loadEnriched as H, cartoSessionStart as I, securityStatePath as It, runningVersion as J, listChildren as K, generateEcosystemMap 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, lessonsStateFileFor as Q, writePluginMap as R, postEditContext as S, devContext as St, dispatchMemory as T, claudeMdKey as Tt, mergeLines as U, writeTree as V, countFiles as W, lessonsArchiveFileFor as X, versionBanner as Y, lessonsFileFor 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, runDoctor 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, generateProjectMap as z };