@fusengine/harness 0.1.56 → 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,1567 +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()));
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 };
2323
+ }
2198
2324
  }
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)
2325
+ /**
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.
2335
+ */
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
2212
2342
  });
2213
- else if (l.trim() && last) last.raw.push(l);
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 {}
2349
+ }
2350
+ /**
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 "".
2357
+ */
2358
+ function oneShotSummary(cwd) {
2359
+ try {
2360
+ return formatSummary(pruneState(loadState(join(defaultStateDir(cwd), SIDECAR$1)), Date.now(), WINDOW_MS));
2361
+ } catch {
2362
+ return "";
2214
2363
  }
2215
- return {
2216
- preamble,
2217
- blocks
2218
- };
2219
2364
  }
2220
2365
  //#endregion
2221
- //#region src/runtime/lifecycle/aipilot/lesson-archive.ts
2366
+ //#region src/tracking/one-shot-failure.ts
2222
2367
  /**
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.
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
2229
2375
  */
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);
2237
- }
2238
2376
  /**
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`.
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.
2247
2383
  */
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--;
2262
- }
2384
+ function applyFailure(s, tool, now) {
2263
2385
  return {
2264
- keep: blocks.filter((b) => !toArchive.has(b)),
2265
- archive: blocks.filter((b) => toArchive.has(b))
2386
+ ...s,
2387
+ failures: {
2388
+ ...s.failures ?? {},
2389
+ [tool]: (s.failures?.[tool] ?? 0) + 1
2390
+ },
2391
+ updatedAt: now
2266
2392
  };
2267
2393
  }
2268
2394
  /**
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.
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).
2275
2400
  */
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`;
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 {}
2279
2408
  }
2280
2409
  //#endregion
2281
- //#region src/runtime/lifecycle/aipilot/curate-lessons.ts
2410
+ //#region src/runtime/lifecycle/failure-lesson.ts
2282
2411
  /**
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.
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
2288
2421
  */
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(", ")}`];
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) : "";
2427
+ }
2428
+ /**
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.
2437
+ */
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
2299
2455
  });
2456
+ return lesson?.reason ? contextResponse("PostToolUseFailure", lesson.reason) : "";
2300
2457
  }
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);
2458
+ //#endregion
2459
+ //#region src/runtime/lifecycle/snapshot/git.ts
2460
+ /**
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.
2468
+ */
2469
+ function git(root, args) {
2470
+ try {
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();
2481
+ } catch {
2482
+ return "";
2483
+ }
2484
+ }
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
2491
+ };
2492
+ for (const line of porcelain.split("\n")) {
2493
+ if (!line || line.startsWith("#")) continue;
2494
+ if (line.startsWith("??")) {
2495
+ w.untracked++;
2309
2496
  continue;
2310
2497
  }
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)}`);
2498
+ const x = line[0], y = line[1];
2499
+ if (x && x !== " " && x !== "?") w.staged++;
2500
+ if (y === "M" || y === "D") w.unstaged++;
2318
2501
  }
2319
- return {
2320
- kept,
2321
- fused
2322
- };
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] ?? "";
2323
2511
  }
2324
2512
  /**
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.
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.
2333
2518
  */
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");
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");
2529
+ }
2530
+ //#endregion
2531
+ //#region src/cli/doctor.ts
2532
+ /**
2533
+ * `harness doctor` — diagnose which `@fusengine/harness` is actually running.
2534
+ *
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.
2543
+ */
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;
2559
+ }
2560
+ return null;
2561
+ }
2562
+ /** Resolve the running version + package path (no network), from a module URL. */
2563
+ function runningVersion(moduleUrl) {
2564
+ const found = findPackage(dirname(fileURLToPath(moduleUrl)));
2345
2565
  return {
2346
- content: rebuilt,
2347
- archive: formatArchive(archive, now),
2348
- report
2566
+ version: found?.version ?? "unknown",
2567
+ path: found?.path ?? "unknown"
2349
2568
  };
2350
2569
  }
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] ?? "";
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}`;
2354
2573
  }
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();
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;
2582
+ }
2358
2583
  }
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();
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
+ };
2362
2595
  }
2363
- /**
2364
- * The bullet's `narrative → rule` delimiter: a SPACED arrow only. A GLUED arrow
2365
- * between tokens (e.g. `120s→300s`, `s→3`) is prose the author wrote, never a
2366
- * delimiter — matching on `→` alone chopped rules mid-token (bug: `300s) pensant
2367
- * corriger…`). Also used to split rule-internal clauses.
2368
- */
2369
- const RULE_ARROW = /\s+→\s+/;
2370
- /**
2371
- * Distil the actionable rule from a bullet body. With no spaced arrow the whole
2372
- * bullet is the rule its first sentence. Otherwise the rule is everything after
2373
- * the FIRST spaced arrow; its spaced-arrow-delimited segments are kept whole
2374
- * except for TRAILING short asides (< {@link MIN_RULE} chars, e.g. `→ (cf.
2375
- * lecture).`) which are dropped — so an arrow used as PROSE inside a rule (`maps
2376
- * X → Y doit…`) is preserved intact rather than chopped at the arrow. When the
2377
- * kept rule is still under {@link MIN_RULE} chars, fall back to the first sentence
2378
- * of the WHOLE rule part (never the narrative), avoiding an illegible stub.
2379
- */
2380
- function distillRule(text) {
2381
- const sep = text.search(RULE_ARROW);
2382
- if (sep < 0) return firstSentence(text);
2383
- const rulePart = text.slice(sep).replace(RULE_ARROW, "").trim();
2384
- const segments = rulePart.split(RULE_ARROW).map((s) => s.trim()).filter(Boolean);
2385
- while (segments.length > 1 && (segments[segments.length - 1]?.length ?? 0) < 40) segments.pop();
2386
- const rule = firstSentence(segments.join(" → "));
2387
- return rule.length >= 40 ? rule : firstSentence(rulePart);
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");
2388
2608
  }
2389
- /** Collapse one older bullet to `- [date] <rule>`: {@link distillRule}, capped. */
2390
- function compressBullet(block) {
2391
- let rule = distillRule(bodyText(block));
2392
- if (rule.length > 200) rule = `${rule.slice(0, 199).trimEnd()}…`;
2393
- const date = stamp(block);
2394
- return `- ${date ? `[${date}] ` : ""}${rule}`;
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
+ }
2614
+ //#endregion
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
+ }
2395
2623
  }
2396
2624
  /**
2397
- * Build the compressed injection body for `content`. The preamble comments are
2398
- * dropped (format docs, noise for the reader); the `recentFull` newest bullets
2399
- * stay whole, every older bullet becomes one distilled rule-line.
2400
- * @param content - Raw LESSON.md text.
2401
- * @param recentFull - Count of newest bullets to keep verbatim.
2402
- * @returns The compressed block (bullets only), or the trimmed content when there are no bullets.
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 `""`).
2403
2632
  */
2404
- function compressInjection(content, recentFull = 10) {
2405
- const { blocks } = parse(content);
2406
- if (blocks.length === 0) return content.trim();
2407
- const full = blocks.slice(0, recentFull).map((b) => b.raw.join("\n"));
2408
- const rest = blocks.slice(recentFull).map(compressBullet);
2409
- return [...full, ...rest].join("\n");
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");
2410
2640
  }
2411
2641
  //#endregion
2412
- //#region src/runtime/lifecycle/lessons/state.ts
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;
2413
2645
  /**
2414
- * Per-project lessons paths. The `fuse-lessons` plugin stores its lessons under
2415
- * `<root>/MEMORY/` (NOT the harness `.harness/memory/`), so these two path
2416
- * helpers override the layout while ALL state/gitignore/throttle logic is
2417
- * reused from `src/memory` (`setStateField`, `ensureMemoryGitignore`,
2418
- * `readState`, `nowStamp`, `throttleMs`).
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.
2419
2652
  */
2420
- /** Absolute `<root>/MEMORY/LESSON.md` — the curated, committable lessons file. */
2421
- function lessonsFileFor(root) {
2422
- return join(root, "MEMORY", "LESSON.md");
2423
- }
2424
- /** Absolute `<root>/MEMORY/LESSON-archive.md` cold storage for capped-out bullets. */
2425
- function lessonsArchiveFileFor(root) {
2426
- return join(root, "MEMORY", "LESSON-archive.md");
2427
- }
2428
- /** Absolute `<root>/MEMORY/state.json` — machine-local throttle counter. */
2429
- function lessonsStateFileFor(root) {
2430
- return join(root, "MEMORY", "state.json");
2653
+ function collectBoard(root) {
2654
+ const path = join(root, ".claude", "BOARD.md");
2655
+ try {
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}`;
2661
+ } catch {
2662
+ return "";
2663
+ }
2431
2664
  }
2432
2665
  //#endregion
2433
- //#region src/memory/session-roots.ts
2666
+ //#region src/runtime/lifecycle/snapshot/format.ts
2434
2667
  /**
2435
- * Session-scoped lessons roots registry. The flat {@link module:memory/registry}
2436
- * keeps ONE global list of pending roots correct mono-session, but wrong with
2437
- * several concurrent Claude Code sessions: at Stop, one session lists (and, by
2438
- * bumping the throttle, STEALS) another session's pending lesson on a project it
2439
- * never touched. This registry keys "which project got code edits, and was its
2440
- * Stop reminder already fired" by `session_id`, so each Stop sees and consumes
2441
- * ONLY its own roots. Stored at `$HOME/.fuse-harness/cache/lessons/session-roots.json`;
2442
- * non-fatal on any I/O failure (a missed reminder never blocks a session).
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.
2443
2675
  */
2444
- /** Registry path (rel. home) + stale-bucket purge horizon (bounds growth). */
2445
- const SUBPATH = ".fuse-harness/cache/lessons/session-roots.json";
2446
- const PURGE_MS = 10080 * 60 * 1e3;
2447
- /** Absolute registry path, or null when home is unusable. */
2448
- function file(home) {
2449
- const h = home?.trim();
2450
- return h && h.startsWith("/") ? `${h}/${SUBPATH}` : null;
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}_`;
2451
2689
  }
2452
- /** Read the registry; missing/corrupt/legacy (array) shapes collapse to `{}`. */
2453
- function read(home) {
2454
- const f = file(home);
2455
- if (!f) return {};
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);
2456
2704
  try {
2457
- const parsed = JSON.parse(readFileSync(f, "utf8"));
2458
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
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
+ });
2459
2716
  } catch {
2460
- return {};
2717
+ return stdout;
2461
2718
  }
2462
2719
  }
2463
- /** Purge stale buckets, then atomically persist (unique tmp + rename). Non-throwing. */
2464
- function write(home, reg, now) {
2465
- const f = file(home);
2466
- if (!f) return;
2467
- for (const [sid, entry] of Object.entries(reg)) if (!entry || now - (entry.updatedAt ?? 0) > PURGE_MS) delete reg[sid];
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) {
2468
2724
  try {
2469
- mkdirSync(dirname(f), { recursive: true });
2470
- atomicWrite(f, JSON.stringify(reg));
2471
- } catch {}
2725
+ return fn();
2726
+ } catch {
2727
+ return "";
2728
+ }
2472
2729
  }
2473
- /** Record `field` for `(sid, root)`, refreshing the purge cursor. `home` defaults to `$HOME`. */
2474
- function markSessionRoot(sid, root, field, value, home = process.env.HOME) {
2475
- const reg = read(home);
2476
- const prev = reg[sid];
2477
- const entry = prev && typeof prev.roots === "object" && prev.roots !== null ? prev : {
2478
- updatedAt: value,
2479
- roots: {}
2480
- };
2481
- const mark = entry.roots[root] ?? {
2482
- editedAt: 0,
2483
- remindedAt: 0
2484
- };
2485
- entry.roots[root] = {
2486
- ...mark,
2487
- [field]: value
2488
- };
2489
- entry.updatedAt = value;
2490
- reg[sid] = entry;
2491
- write(home, reg, value);
2730
+ /**
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.
2737
+ */
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
+ ]);
2492
2758
  }
2493
2759
  /**
2494
- * Roots of `sid` with an unsaved code edit past the `window`; each returned
2495
- * root's `remindedAt` is bumped to `now` so the reminder fires at most once per
2496
- * window and is consumed ONLY by this session. `home` defaults to `$HOME`.
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.
2497
2766
  */
2498
- function collectSessionPending(sid, now, window, home = process.env.HOME) {
2499
- const reg = read(home);
2500
- const entry = reg[sid];
2501
- if (!entry || typeof entry.roots !== "object" || entry.roots === null) return [];
2502
- const pending = [];
2503
- for (const [root, mark] of Object.entries(entry.roots)) {
2504
- if (mark.editedAt <= mark.remindedAt) continue;
2505
- if (now - mark.remindedAt < window) continue;
2506
- pending.push(root);
2507
- entry.roots[root] = {
2508
- ...mark,
2509
- remindedAt: now
2510
- };
2767
+ function withSnapshot(stdout, cwd, moduleUrl) {
2768
+ try {
2769
+ return attachSnapshot(stdout, renderSnapshot(cwd, moduleUrl));
2770
+ } catch {
2771
+ return stdout;
2511
2772
  }
2512
- if (pending.length > 0) write(home, reg, now);
2513
- return pending;
2514
2773
  }
2515
2774
  //#endregion
2516
- //#region src/runtime/lifecycle/lessons/reminder.ts
2775
+ //#region src/runtime/lifecycle/post-compact.ts
2517
2776
  /**
2518
- * fuse-lessons write-mark + Stop-reminder, scoped by `session_id` when present.
2519
- *
2520
- * WITH a session id (normal Claude Code): each `(session, root)` pair carries
2521
- * its own edit/reminder throttle in {@link module:memory/session-roots}, so a
2522
- * Stop lists and silences ONLY the roots THAT session edited — concurrent
2523
- * sessions on different projects never cross-remind nor steal each other's
2524
- * throttle. WITHOUT a usable session id (a harness that omits it, or the legacy
2525
- * on-disk state) it falls back to the original mono-session behavior: the global
2526
- * flat root registry + the per-project `MEMORY/state.json` throttle.
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 "".
2782
+ * @packageDocumentation
2527
2783
  */
2528
- /** Sanitized session id from a raw hook payload, or null (→ legacy fallback). */
2529
- function sessionOf(payload) {
2530
- return sanitizeSessionId(payload.session_id);
2531
- }
2532
- /** Legacy (no session id): pending roots across the global flat registry. */
2533
- function collectLegacyPending(now, window) {
2534
- const pending = [];
2535
- for (const root of readRoots()) {
2536
- const stateFile = lessonsStateFileFor(root);
2537
- const { lastRemindedAt, lastCodeEditAt } = readState(stateFile);
2538
- if (lastCodeEditAt <= lastRemindedAt) continue;
2539
- if (now - lastRemindedAt < window) continue;
2540
- pending.push(root);
2541
- setStateField(stateFile, "lastRemindedAt", now);
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).";
2788
+ /**
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 "".
2796
+ */
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 "";
2542
2807
  }
2543
- return pending;
2544
2808
  }
2545
- /** Stop reminder body listing each pending project's lessons file. */
2546
- function reminderText(pending) {
2547
- 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")}`;
2809
+ //#endregion
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)));
2548
2837
  }
2549
2838
  /**
2550
- * Stop: emit one reminder covering the stopping session's pending projects.
2551
- * @param payload - Raw hook payload (`session_id` selects the scoped path).
2552
- * @param now - Clock.
2553
- * @returns Native Stop stdout, or "" when nothing is pending.
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.
2554
2846
  */
2555
- function remindWrite(payload, now) {
2556
- const window = throttleMs();
2557
- const sid = sessionOf(payload);
2558
- const pending = sid ? collectSessionPending(sid, now, window) : collectLegacyPending(now, window);
2559
- if (pending.length === 0) return "";
2560
- return contextResponse("Stop", reminderText(pending));
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
+ });
2561
2855
  }
2562
2856
  /**
2563
- * PostToolUse: record the edit against the throttle. A code file arms the
2564
- * reminder; writing `MEMORY/LESSON.md` silences it (the lesson was just saved).
2565
- * Session-scoped when `session_id` is present, else the legacy global path.
2566
- * @param payload - Raw hook payload (`tool_input.file_path`, `session_id`).
2567
- * @param now - Clock.
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).
2568
2862
  */
2569
- function markWrite(payload, now) {
2570
- const input = payload.tool_input;
2571
- if (!input?.file_path) return;
2572
- const abs = resolve(input.file_path);
2573
- const root = projectRootOrNull(dirname(abs));
2574
- if (!root) return;
2575
- const isLesson = abs === resolve(root, "MEMORY", "LESSON.md");
2576
- if (!isLesson && !isCodeFile(abs)) return;
2577
- const sid = sessionOf(payload);
2578
- if (sid) markSessionRoot(sid, root, isLesson ? "remindedAt" : "editedAt", now);
2579
- else if (isLesson) setStateField(lessonsStateFileFor(root), "lastRemindedAt", now);
2580
- else {
2581
- setStateField(lessonsStateFileFor(root), "lastCodeEditAt", now);
2582
- addRoot(root);
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 {}
2583
2871
  }
2872
+ return violations;
2584
2873
  }
2585
- //#endregion
2586
- //#region src/runtime/lifecycle/lessons/dispatch.ts
2587
2874
  /**
2588
- * fuse-lessons scope dispatch (TS port of the 4 handler scripts). Routes by
2589
- * event: SessionStart/SubagentStart inject `MEMORY/LESSON.md`; Stop reminds the
2590
- * stopping session about ITS OWN projects with unsaved code edits; PostToolUse
2591
- * marks the write to arm/silence the throttle. The reminder + mark logic (incl.
2592
- * the per-`session_id` scoping that fixes the multi-session misdirection) lives
2593
- * in {@link module:runtime/lifecycle/lessons/reminder}; this module keeps the
2594
- * event router + lesson-file injection. Non-fatal by design.
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.
2595
2884
  */
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("; "));
2895
+ }
2896
+ //#endregion
2897
+ //#region src/runtime/lifecycle/cartographer/fs-util.ts
2596
2898
  /**
2597
- * Persist a curation ATOMICALLY and ARCHIVE-FIRST for zero-loss: prepend the
2598
- * moved bullets to LESSON-archive.md, THEN rewrite LESSON.md. On ANY write error
2599
- * the original file is left untouched (returns `original`) so a bullet is never
2600
- * lost — a rare archive-then-trim-fail leaves a duplicate (never a loss), which
2601
- * the next dedup pass reconciles.
2899
+ * Filesystem helpers for the cartographer tree walk. Ports the fs parts of
2900
+ * `describe.py` (file desc) and `write_recursive.py` (children + counts).
2602
2901
  */
2603
- function persistCuration(file, root, curated, archive, original) {
2604
- try {
2605
- if (archive) {
2606
- const af = lessonsArchiveFileFor(root);
2607
- const prev = existsSync(af) ? readFileSync(af, "utf-8") : "";
2608
- atomicWrite(af, prev ? `${archive}\n${prev}` : archive);
2609
- }
2610
- atomicWrite(file, curated);
2611
- return curated;
2612
- } catch {
2613
- return original;
2614
- }
2615
- }
2616
2902
  /**
2617
- * Inject `MEMORY/LESSON.md` for `event`. Mechanical curation (dedup + cap→archive)
2618
- * rewrites the FILE; the injected BLOCK is then COMPRESSED (newest bullets whole,
2619
- * older ones distilled to their rule) so a growing file never inflates the
2620
- * SessionStart/SubagentStart context. Any curation report surfaces via systemMessage.
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 "".
2621
2907
  */
2622
- function injectMemory(cwd, event, now) {
2623
- const root = projectRoot(cwd);
2624
- const file = lessonsFileFor(root);
2625
- if (!existsSync(file)) return "";
2626
- let content = "";
2908
+ function getFileDesc(filePath) {
2909
+ let text = "";
2627
2910
  try {
2628
- content = readFileSync(file, "utf-8").trim();
2911
+ text = readFileSync(filePath, "utf-8");
2629
2912
  } catch {
2630
2913
  return "";
2631
2914
  }
2632
- if (!content) return "";
2633
- const { content: curated, archive, report } = curateLessons(content, now, root);
2634
- if (curated !== content) content = persistCuration(file, root, curated, archive, content);
2635
- 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.`;
2636
- return report ? attachSystemMessage(contextResponse(event, ctx), `LESSON.md curation:\n${report}`) : contextResponse(event, ctx);
2915
+ const suffix = extname(filePath);
2916
+ const mdField = suffix === ".md" ? parseField(text, "description") : "";
2917
+ return descFromText(suffix, text, mdField);
2637
2918
  }
2638
2919
  /**
2639
- * Route a fuse-lessons event to its handler. Returns the native stdout for
2640
- * context-injecting events (SessionStart/SubagentStart/Stop) or "" for the
2641
- * side-effect-only PostToolUse mark.
2642
- * @param event - The raw hook event name.
2643
- * @param payload - The raw hook payload.
2644
- * @param cwd - Project root for memory injection.
2645
- * @param now - Clock.
2646
- * @returns The native stdout (possibly empty).
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.
2647
2925
  */
2648
- function dispatchLessons(event, payload, cwd, now) {
2649
- switch (event) {
2650
- case "SessionStart":
2651
- case "SubagentStart": return injectMemory(cwd, event, now);
2652
- case "Stop": return remindWrite(payload, now);
2653
- case "PostToolUse":
2654
- markWrite(payload, now);
2655
- return "";
2656
- default: return "";
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);
2657
2955
  }
2956
+ return {
2957
+ dirs: dirs.sort(),
2958
+ files: files.sort()
2959
+ };
2658
2960
  }
2659
2961
  //#endregion
2660
- //#region src/policy/deny-loop.ts
2962
+ //#region src/runtime/lifecycle/cartographer/merge.ts
2661
2963
  /**
2662
- * @module deny-loop
2663
- * Pure anti-loop logic: hash a tool-call, decide if it repeats a prior deny, and
2664
- * enrich the repeated block's message.
2665
- *
2666
- * The proprietary rule "NEVER propose the same fix twice" is prose a model under
2667
- * pressure ignores. This makes it machine-enforced: when a call whose
2668
- * `(tool + normalized input)` hash was ALREADY denied in-window is retried, the
2669
- * harness keeps the deny but rewrites the message — `[REPEAT]` title, STOP
2670
- * prefix, forced `research-expert` action. State + wiring live in the sidecar
2671
- * store ({@link module:deny-loop-store}); this file is IO-free and pure.
2672
- * @packageDocumentation
2964
+ * Index merge — preserves enriched descriptions across regenerations. Ports
2965
+ * `merge_index.py` (merge_lines + .enriched.json sidecar).
2673
2966
  */
2674
- /** Stable JSON: keys sorted at every depth so `{a,b}` and `{b,a}` hash identically. */
2675
- function stableStringify(v) {
2676
- if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
2677
- if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
2678
- const o = v;
2679
- return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(",")}}`;
2680
- }
2681
2967
  /**
2682
- * Stable identity hash of a tool-call = tool name + normalized (key-sorted) input,
2683
- * so re-ordered keys never mask a repeat.
2684
- * @param tool - Tool name (e.g. "Write", "Bash").
2685
- * @param input - Identifying tool input (filePath/content/command...).
2686
- * @returns 8-char hex hash.
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).
2687
2971
  */
2688
- function denyHash(tool, input) {
2689
- return hashText(`${tool}\n${stableStringify(input)}`);
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
+ }
2690
2980
  }
2691
2981
  /**
2692
- * Pure loop check: given the already-pruned in-window map, compute the running
2693
- * count for `hash` and whether it repeats (count > 1). No IO — the caller persists.
2694
- *
2695
- * When `dedupMs` is set (>0) and an identical prior deny landed within that
2696
- * window, the current call is a sibling hook echoing the SAME event (see
2697
- * {@link module:burst-window}): it returns the prior verdict VERBATIM with
2698
- * `deduped:true` and does NOT bump the count, so all N fan-out processes agree
2699
- * on one number instead of counting to N. Absent `dedupMs` (mono-process
2700
- * callers / unit tests) the historical increment-every-time behaviour holds.
2701
- * @param hash - {@link denyHash}-derived map key of the current call.
2702
- * @param priorDenies - The `{ hash -> DenyEntry }` map, pruned to `now`/`windowMs`.
2703
- * @param opts - Clock + window, plus an optional burst-dedup window.
2704
- * @returns `{ isRepeat, count, hash, deduped? }`.
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.
2705
2987
  */
2706
- function denyLoopCheck(hash, priorDenies, opts) {
2707
- const prev = priorDenies[hash];
2708
- if (!(prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs)) return {
2709
- isRepeat: false,
2710
- count: 1,
2711
- hash
2712
- };
2713
- if ((opts.dedupMs ?? 0) > 0 && opts.now - prev.lastTs < (opts.dedupMs ?? 0)) return {
2714
- isRepeat: prev.count > 1,
2715
- count: prev.count,
2716
- hash,
2717
- deduped: true
2718
- };
2719
- const count = prev.count + 1;
2720
- return {
2721
- isRepeat: count > 1,
2722
- count,
2723
- hash
2724
- };
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;
3004
+ });
2725
3005
  }
3006
+ //#endregion
3007
+ //#region src/runtime/lifecycle/cartographer/write-tree.ts
2726
3008
  /**
2727
- * Enrich a REPEATED block prompt — a NEW object, never a mutation (the input may
2728
- * be a shared const like FAIL_CLOSED). The decision stays `block`; only the
2729
- * message changes, so every harness renders it through the same adapter.
2730
- * @param prompt - The original block prompt.
2731
- * @param count - The running identical-deny count (n).
2732
- * @returns A block prompt with `[REPEAT]` title, STOP-prefixed reason, forced research action.
3009
+ * Recursive index.md tree writer. Ports `write_recursive.py`.
2733
3010
  */
2734
- function enrichRepeatDeny(prompt, count) {
2735
- const stop = `Identical attempt #${count} already denied for the same reason. STOP: do not retry this same call. `;
2736
- const action = "Launch fuse-ai-pilot:research-expert to find a DIFFERENT approach";
2737
- return {
2738
- ...prompt,
2739
- title: prompt.title.startsWith("[REPEAT]") ? prompt.title : `[REPEAT] ${prompt.title}`,
2740
- reason: stop + prompt.reason,
2741
- actions: [action, ...prompt.actions ?? []]
2742
- };
2743
- }
2744
- //#endregion
2745
- //#region src/tracking/one-shot-store.ts
2746
- /** A fresh, empty state — always spread (`{ ...EMPTY }`) so the const is never shared. */
2747
- const EMPTY = {
2748
- gates: {},
2749
- firstTry: 0,
2750
- corrected: 0,
2751
- pending: {},
2752
- updatedAt: 0
2753
- };
2754
3011
  /**
2755
- * Drop stale data: whole-state idle reset past the window, else per-entry prune of
2756
- * gates/pending older than `windowMs`. Keeps the "7d" window honest, bounds size.
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.
2757
3019
  */
2758
- function pruneState(s, now, windowMs) {
2759
- if (now - s.updatedAt >= windowMs) return { ...EMPTY };
2760
- const gates = {};
2761
- for (const [k, g] of Object.entries(s.gates)) if (now - g.lastTs < windowMs) gates[k] = g;
2762
- const pending = {};
2763
- for (const [k, p] of Object.entries(s.pending)) if (now - p.ts < windowMs) pending[k] = p;
2764
- return {
2765
- ...s,
2766
- gates,
2767
- pending
2768
- };
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");
2769
3045
  }
3046
+ //#endregion
3047
+ //#region src/runtime/lifecycle/cartographer/project-map.ts
2770
3048
  /**
2771
- * Record a deny for gate `title` on operation `op` (content-free tool identity):
2772
- * bump the gate's deny count and mark `op` pending for a later fix.
3049
+ * Project map generation. Ports `generate_project_map.py` (project map only).
2773
3050
  */
2774
- function applyDeny(s, title, op, now) {
2775
- const g = s.gates[title] ?? {
2776
- denies: 0,
2777
- corrected: 0,
2778
- lastTs: 0
2779
- };
2780
- return {
2781
- ...s,
2782
- gates: {
2783
- ...s.gates,
2784
- [title]: {
2785
- denies: g.denies + 1,
2786
- corrected: g.corrected,
2787
- lastTs: now
2788
- }
2789
- },
2790
- pending: {
2791
- ...s.pending,
2792
- [op]: {
2793
- title,
2794
- ts: now
2795
- }
2796
- },
2797
- updatedAt: now
2798
- };
3051
+ /** True when `dir` is a real directory. */
3052
+ function isDirectory(dir) {
3053
+ try {
3054
+ return statSync(dir).isDirectory();
3055
+ } catch {
3056
+ return false;
3057
+ }
2799
3058
  }
2800
3059
  /**
2801
- * Record an allow for a gateable `op`. A non-gateable allow (Read/Task/MCP) leaves
2802
- * state untouched it never counts and never clears a pending deny. Otherwise: a
2803
- * pending deny `corrected` (a fix, credited to the blocking gate); no pending →
2804
- * `firstTry` (one-shot).
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.
2805
3064
  */
2806
- function applyAllow(s, op, now, gateable) {
2807
- if (!gateable) return s;
2808
- const pend = s.pending[op];
2809
- if (pend) {
2810
- const g = s.gates[pend.title] ?? {
2811
- denies: 0,
2812
- corrected: 0,
2813
- lastTs: 0
2814
- };
2815
- const { [op]: _drop, ...pending } = s.pending;
2816
- return {
2817
- ...s,
2818
- gates: {
2819
- ...s.gates,
2820
- [pend.title]: {
2821
- ...g,
2822
- corrected: g.corrected + 1,
2823
- lastTs: now
2824
- }
2825
- },
2826
- corrected: s.corrected + 1,
2827
- pending,
2828
- updatedAt: now
2829
- };
2830
- }
2831
- return gateable ? {
2832
- ...s,
2833
- firstTry: s.firstTry + 1,
2834
- updatedAt: now
2835
- } : s;
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;
2836
3070
  }
2837
3071
  /**
2838
- * Compact injectable summary (one line); "" when there is nothing to report.
2839
- * @returns e.g. `gates 7d: 88% one-shot (44/50 clean); SOLID file-size limit 4den/3fix`.
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).
2840
3077
  */
2841
- function formatSummary(s) {
2842
- const keys = Object.keys(s.gates);
2843
- const total = s.firstTry + s.corrected;
2844
- if (keys.length === 0 && total === 0) return "";
2845
- const head = total > 0 ? `${Math.round(s.firstTry / total * 100)}% one-shot (${s.firstTry}/${total} clean)` : "no clean pass yet";
2846
- const parts = keys.map((k) => ({
2847
- k,
2848
- g: s.gates[k]
2849
- })).sort((a, b) => b.g.denies - a.g.denies).map(({ k, g }) => `${k} ${g.denies}den/${g.corrected}fix`);
2850
- return `gates 7d: ${head}${parts.length ? `; ${parts.join("; ")}` : ""}`;
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 "";
2851
3085
  }
2852
3086
  //#endregion
2853
- //#region src/tracking/one-shot-dedup.ts
3087
+ //#region src/policy/cartographer/build-tree.ts
3088
+ const SECTION_ORDER = [
3089
+ "agent",
3090
+ "skill",
3091
+ "command"
3092
+ ];
2854
3093
  /**
2855
- * @module one-shot-dedup
2856
- * Burst-dedup guard for the one-shot metric ({@link module:one-shot}).
2857
- *
2858
- * ONE Claude tool event fans out to ~11 sibling plugin-hook processes, each
2859
- * calling {@link recordOneShot}; without this the metric would count a single
2860
- * deny/allow ~11×. Reuses the proven {@link oncePerWindow} cooldown sidecar:
2861
- * the FIRST process in the {@link module:burst-window} window mutates the
2862
- * metric, the rest skip. The dedup key includes the outcome KIND (deny-title vs
2863
- * allow) so a deny and its later fix — different kinds — are never folded into
2864
- * each other. No `sessionId` → always the first (mono-process + unit-test
2865
- * parity; a burst can only exist when a real session drives the fan-out).
2866
- * @packageDocumentation
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.
2867
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
+ }
2868
3114
  /**
2869
- * True when this `(op, kind)` is the FIRST of its burst for the session — the
2870
- * process that should actually mutate the metric. Sibling processes firing the
2871
- * SAME event within {@link BURST_DEDUP_MS} return false and skip the write.
2872
- * @param op - Content-free operation key ({@link denyHash}("op", …)).
2873
- * @param kind - Outcome discriminator (`deny:<title>` or `allow`).
2874
- * @param opts - Clock + state dir + optional session id.
2875
- * @returns `true` to apply the record, `false` to skip (already counted).
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.
2876
3120
  */
2877
- function burstFirst(op, kind, opts) {
2878
- const sid = opts.sessionId?.trim();
2879
- if (!sid) return true;
2880
- return oncePerWindow(`oneshot:${sid}:${op}:${kind}`, BURST_DEDUP_MS, {
2881
- now: opts.now,
2882
- dir: opts.dir
2883
- });
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");
2884
3146
  }
2885
3147
  //#endregion
2886
- //#region src/tracking/one-shot.ts
3148
+ //#region src/runtime/lifecycle/cartographer/write-plugin-map.ts
2887
3149
  /**
2888
- * @module one-shot
2889
- * Sidecar store + gate wiring for the per-gate one-shot metric.
2890
- *
2891
- * STATE — a standalone sidecar (`one-shot.json`) in the same per-project state dir
2892
- * as the session track, mirroring {@link module:deny-loop-store} (atomicWrite,
2893
- * prune-by-window, fail-safe). A write error NEVER changes a gate decision nor its
2894
- * prompt — metrics are pure observation.
2895
- *
2896
- * KEY — the operation identity is content-FREE (`tool + filePath/command`): a fix
2897
- * changes the content, so a content hash would make every retry a new op and hide
2898
- * the deny→allow transition this metric exists to see. The pure model lives in
2899
- * {@link module:one-shot-store}; this file is the only IO surface.
2900
- * @packageDocumentation
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`.
2901
3153
  */
2902
- /** Sidecar basename under the per-project state dir. */
2903
- const SIDECAR$1 = "one-shot.json";
2904
- /** Retention window: 7 days. Aggregates and pending denies older than this are pruned. */
2905
- const WINDOW_MS = 10080 * 60 * 1e3;
2906
- /** Load the state, or a fresh copy when missing/corrupt. */
2907
- function loadState(path) {
3154
+ /** True when `dir` is a real directory. */
3155
+ function isDir(dir) {
2908
3156
  try {
2909
- if (!existsSync(path)) return { ...EMPTY };
2910
- const d = JSON.parse(readFileSync(path, "utf8"));
2911
- return d && typeof d === "object" && !Array.isArray(d) ? {
2912
- ...EMPTY,
2913
- ...d
2914
- } : { ...EMPTY };
3157
+ return statSync(dir).isDirectory();
2915
3158
  } catch {
2916
- return { ...EMPTY };
3159
+ return false;
2917
3160
  }
2918
3161
  }
2919
3162
  /**
2920
- * Record a gate outcome: a `block` is a deny for its gate title; a `null` allow is
2921
- * a fix (if the op was pending) or a one-shot (if gateable). `ask`/`inform` are
2922
- * neither and are skipped. Fails silently — a metric write NEVER affects a decision.
2923
- *
2924
- * The op key is tool-INDEPENDENT (`filePath`/`command` only, constant `"op"` tool):
2925
- * a deny (a `Write`) and its fix (an `Edit`) on the same file must link.
2926
- * @param prompt - The gate's outcome (block, allow=null, or ask/inform).
2927
- * @param input - Identifying tool input (content decides gateability only).
2928
- * @param opts - Clock + state dir.
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).
2929
3170
  */
2930
- function recordOneShot(prompt, input, opts) {
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 = [];
2931
3196
  try {
2932
- if (prompt && prompt.kind !== "block") return;
2933
- const op = denyHash("op", {
2934
- filePath: input.filePath,
2935
- command: input.command
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)
2936
3321
  });
2937
- if (!burstFirst(op, prompt ? `deny:${prompt.title}` : "allow", opts)) return;
2938
- const path = join(opts.dir, SIDECAR$1);
2939
- let s = pruneState(loadState(path), opts.now, WINDOW_MS);
2940
- s = prompt ? applyDeny(s, prompt.title, op, opts.now) : applyAllow(s, op, opts.now, input.content != null || input.command != null);
2941
- atomicWrite(path, JSON.stringify(s));
2942
- } 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
+ };
2943
3376
  }
2944
3377
  /**
2945
- * Compact, injection-ready one-shot summary for the project rooted at `cwd`. The
2946
- * state dir is derived EXACTLY like the runtime writer ({@link defaultStateDir},
2947
- * mirroring `handle.ts` `trackFile(sid, defaultStateDir(cwd))`), so the file read
2948
- * here is the same one {@link recordOneShot} wrote. "" when no data or read error.
2949
- * @param cwd - The project working directory (Claude `cwd`), NOT the state dir.
2950
- * @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.
2951
3384
  */
2952
- function oneShotSummary(cwd) {
2953
- try {
2954
- return formatSummary(pruneState(loadState(join(defaultStateDir(cwd), SIDECAR$1)), Date.now(), WINDOW_MS));
2955
- } catch {
2956
- return "";
2957
- }
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`;
2958
3388
  }
2959
3389
  //#endregion
2960
- //#region src/runtime/lifecycle/snapshot/git.ts
3390
+ //#region src/runtime/lifecycle/aipilot/curate-lessons.ts
2961
3391
  /**
2962
- * Run a git subcommand at `root` with a short timeout, returning trimmed stdout.
2963
- * Uses `node:child_process` (the Bun shell can hang on some git plumbing) and
2964
- * swallows every failure a non-repo, missing git, or timeout yields `""` so
2965
- * the caller omits the section instead of throwing inside the hook.
2966
- * @param root - Directory to run git in.
2967
- * @param args - The git args (e.g. `"log --oneline -3"`).
2968
- * @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.
2969
3397
  */
2970
- function git(root, args) {
2971
- try {
2972
- return execSync(`git ${args}`, {
2973
- cwd: root,
2974
- encoding: "utf8",
2975
- timeout: 150,
2976
- stdio: [
2977
- "ignore",
2978
- "pipe",
2979
- "ignore"
2980
- ]
2981
- }).trim();
2982
- } catch {
2983
- return "";
2984
- }
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
+ });
2985
3409
  }
2986
- /** Count staged/unstaged/untracked files from porcelain v1 output (skips the `##` branch line). */
2987
- function countWip(porcelain) {
2988
- const w = {
2989
- staged: 0,
2990
- unstaged: 0,
2991
- untracked: 0
2992
- };
2993
- for (const line of porcelain.split("\n")) {
2994
- if (!line || line.startsWith("#")) continue;
2995
- if (line.startsWith("??")) {
2996
- 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);
2997
3418
  continue;
2998
3419
  }
2999
- const x = line[0], y = line[1];
3000
- if (x && x !== " " && x !== "?") w.staged++;
3001
- 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)}`);
3002
3427
  }
3003
- return w;
3004
- }
3005
- /** Parse the current branch from the leading `## branch...upstream` porcelain line. */
3006
- function parseBranch(porcelain) {
3007
- const head = porcelain.split("\n")[0] ?? "";
3008
- if (!head.startsWith("## ")) return "";
3009
- const rest = head.slice(3);
3010
- const dots = rest.indexOf("...");
3011
- return (dots >= 0 ? rest.slice(0, dots) : rest).split(" ")[0] ?? "";
3428
+ return {
3429
+ kept,
3430
+ fused
3431
+ };
3012
3432
  }
3013
3433
  /**
3014
- * Collect a compact git reconciliation section for `root`: current branch, the
3015
- * last three commits (oneline), and staged/unstaged/untracked WIP counts. When
3016
- * `root` is not a git repo (status fails) the whole section is omitted (`""`).
3017
- * @param root - The project/repo root.
3018
- * @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.
3019
3442
  */
3020
- function collectGit(root) {
3021
- const status = git(root, "status --porcelain=v1 --branch");
3022
- if (!status) return "";
3023
- const branch = parseBranch(status) || "(unknown)";
3024
- const w = countWip(status);
3025
- const log = git(root, "log --oneline -3");
3026
- const lines = [`- branch: ${branch}`];
3027
- if (log) lines.push("- recent:", ...log.split("\n").map((l) => ` ${l}`));
3028
- lines.push(`- WIP: ${w.staged} staged, ${w.unstaged} unstaged, ${w.untracked} untracked`);
3029
- 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();
3030
3471
  }
3031
- //#endregion
3032
- //#region src/cli/doctor.ts
3033
3472
  /**
3034
- * `harness doctor` diagnose which `@fusengine/harness` is actually running.
3035
- *
3036
- * A confirmed, still-open bun bug (oven-sh/bun #5791; scoped-pkg behaviour
3037
- * reinforced by #32019/#32150) makes `bunx <pkg>` (unpinned) prefer a stale
3038
- * GLOBAL install over npm-latest, so a consumer can silently run an old harness
3039
- * after a publish. This command surfaces the truth: the resolved version +
3040
- * package path of the code executing right now, the runtime binary, and the
3041
- * latest version published on npm. It queries the registry over HTTP (not
3042
- * `npm view`, whose exit code is 0 even on an empty result — npm/cli#6408) and
3043
- * 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.
3044
3477
  */
3045
- const PKG = "@fusengine/harness";
3046
- /** Walk up from `startDir` for the `@fusengine/harness` `package.json`. */
3047
- function findPackage(startDir) {
3048
- let dir = startDir;
3049
- for (let depth = 0; depth < 6; depth++) {
3050
- try {
3051
- const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
3052
- if (pkg.name === PKG) return {
3053
- version: pkg.version ?? "unknown",
3054
- path: dir
3055
- };
3056
- } catch {}
3057
- const parent = dirname(dir);
3058
- if (parent === dir) break;
3059
- dir = parent;
3060
- }
3061
- 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);
3062
3497
  }
3063
- /** Resolve the running version + package path (no network), from a module URL. */
3064
- function runningVersion(moduleUrl) {
3065
- const found = findPackage(dirname(fileURLToPath(moduleUrl)));
3066
- return {
3067
- version: found?.version ?? "unknown",
3068
- path: found?.path ?? "unknown"
3069
- };
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}`;
3070
3504
  }
3071
- /** One-line `pkg vX.Y.Z` banner (stderr, only on explicit `--version`/`doctor` commands — never on `hook`, to avoid spamming automated invocations). */
3072
- function versionBanner(moduleUrl) {
3073
- 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;
3074
3539
  }
3075
- /** Latest published version via the npm registry HTTP API. `null` on any failure. */
3076
- 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 {};
3077
3544
  try {
3078
- const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(8e3) });
3079
- if (!res.ok) return null;
3080
- return (await res.json()).version ?? null;
3545
+ const parsed = JSON.parse(readFileSync(f, "utf8"));
3546
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3081
3547
  } catch {
3082
- return null;
3548
+ return {};
3083
3549
  }
3084
3550
  }
3085
- /** Build the full diagnostic report for the module at `moduleUrl`. */
3086
- async function buildDoctorReport(moduleUrl) {
3087
- const { version, path } = runningVersion(moduleUrl);
3088
- const latest = await npmLatest();
3089
- return {
3090
- running: version,
3091
- packagePath: path,
3092
- runtime: process.execPath,
3093
- latest,
3094
- stale: latest !== null && latest !== version
3095
- };
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 {}
3096
3560
  }
3097
- /** Render a {@link DoctorReport} as human-readable stdout text. */
3098
- function formatDoctor(r) {
3099
- const lines = [
3100
- `${PKG} doctor`,
3101
- ` running: ${r.running}`,
3102
- ` package: ${r.packagePath}`,
3103
- ` runtime: ${r.runtime}`,
3104
- ` npm latest: ${r.latest ?? "(unavailable — offline or unreachable)"}`
3105
- ];
3106
- if (r.stale) lines.push(` ! stale — npm serves ${r.latest}. Pin "@fusengine/harness@${r.latest}" in hooks.json (see README).`);
3107
- else if (r.latest !== null) lines.push(` ok — running the latest published version.`);
3108
- 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);
3109
3580
  }
3110
- /** Run `harness doctor`: print the diagnostic to stdout. Always resolves 0 (pure info). */
3111
- async function runDoctor(moduleUrl) {
3112
- process.stdout.write(formatDoctor(await buildDoctorReport(moduleUrl)) + "\n");
3113
- 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;
3114
3602
  }
3115
3603
  //#endregion
3116
- //#region src/runtime/lifecycle/snapshot/version.ts
3117
- /** Read the `version` field of `<root>/package.json`, or `""` if absent/unreadable. */
3118
- function pkgVersion(root) {
3119
- try {
3120
- return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version ?? "";
3121
- } catch {
3122
- 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);
3123
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")}`;
3124
3636
  }
3125
3637
  /**
3126
- * Collect the version reconciliation section: the harness version actually
3127
- * running (resolved from {@link runningVersion}, no network) and, when `root`
3128
- * carries its own `package.json`, whether that project's version has drifted
3129
- * from the running harness.
3130
- * @param root - The project root (cwd repo).
3131
- * @param moduleUrl - `import.meta.url` of the calling module (locates the running package.json).
3132
- * @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.
3133
3642
  */
3134
- function collectVersion(root, moduleUrl) {
3135
- const running = runningVersion(moduleUrl).version;
3136
- const lines = [`- harness running: v${running}`];
3137
- const project = pkgVersion(root);
3138
- if (project && project !== running) lines.push(`- project package.json: v${project} (DRIFT — running harness differs)`);
3139
- else if (project) lines.push(`- project package.json: v${project} (in sync)`);
3140
- 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));
3141
3649
  }
3142
- //#endregion
3143
- //#region src/runtime/lifecycle/snapshot/board.ts
3144
- /** Max board characters injected — a persistent board should stay small; over-long boards are truncated. */
3145
- const MAX_BOARD = 4e3;
3146
3650
  /**
3147
- * Collect the persistent task board: the contents of `<root>/.claude/BOARD.md`
3148
- * (truncated to {@link MAX_BOARD}) plus an instruction to keep it current. The
3149
- * board lives on disk so it survives context purges — rehydrated every session.
3150
- * Missing/empty/unreadable board `""` (section omitted).
3151
- * @param root - The project root.
3152
- * @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.
3153
3656
  */
3154
- function collectBoard(root) {
3155
- const path = join(root, ".claude", "BOARD.md");
3156
- try {
3157
- if (!existsSync(path)) return "";
3158
- let body = readFileSync(path, "utf8").trim();
3159
- if (!body) return "";
3160
- if (body.length > MAX_BOARD) body = `${body.slice(0, MAX_BOARD)}\n… (truncated)`;
3161
- return `- .claude/BOARD.md (keep current — Write to it as tasks start/finish):\n\n${body}`;
3162
- } catch {
3163
- 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);
3164
3671
  }
3165
3672
  }
3166
3673
  //#endregion
3167
- //#region src/runtime/lifecycle/snapshot/format.ts
3674
+ //#region src/runtime/lifecycle/lessons/dispatch.ts
3168
3675
  /**
3169
- * Render the non-empty `sections` under one reconciliation heading. Empty
3170
- * sections are dropped; when every section is empty the whole snapshot is `""`.
3171
- * @param sections - The collected sections in display order.
3172
- * @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.
3173
3683
  */
3174
- function renderSections(sections) {
3175
- const parts = sections.filter((s) => s.body.trim()).map((s) => `### ${s.title}\n${s.body.trim()}`);
3176
- if (!parts.length) return "";
3177
- return `# Reconciliation snapshot
3178
- Real state of the world at session start — reconcile against this instead of re-discovering it.\n\n${parts.join("\n\n")}`;
3179
- }
3180
3684
  /**
3181
- * Concatenate `snapshot` onto an existing SessionStart stdout's
3182
- * `additionalContext` it never replaces prior injected context (CLAUDE.md,
3183
- * dev-context). When `stdout` is empty a fresh {@link contextResponse} is made;
3184
- * a non-empty but unparseable `stdout` is returned UNCHANGED (the snapshot is
3185
- * dropped) fabricating a fresh response there would discard the very CLAUDE.md
3186
- * injection the invariant protects, so preserving prior context always wins.
3187
- * @param stdout - The core SessionStart JSON stdout (may be `""`).
3188
- * @param snapshot - The snapshot markdown to append (no-op when `""`).
3189
- * @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.
3190
3690
  */
3191
- function attachSnapshot(stdout, snapshot) {
3192
- if (!snapshot) return stdout;
3193
- if (!stdout) return contextResponse("SessionStart", snapshot);
3691
+ function persistCuration(file, root, curated, archive, original) {
3194
3692
  try {
3195
- const parsed = JSON.parse(stdout);
3196
- const prev = parsed.hookSpecificOutput?.additionalContext ?? "";
3197
- const merged = prev ? `${prev}\n\n${snapshot}` : snapshot;
3198
- return JSON.stringify({
3199
- ...parsed,
3200
- hookSpecificOutput: {
3201
- ...parsed.hookSpecificOutput,
3202
- hookEventName: "SessionStart",
3203
- additionalContext: merged
3204
- }
3205
- });
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;
3206
3700
  } catch {
3207
- return stdout;
3701
+ return original;
3208
3702
  }
3209
3703
  }
3210
- //#endregion
3211
- //#region src/runtime/lifecycle/snapshot/index.ts
3212
- /** Run `fn`, swallowing any throw into `""` so no single collector can break the hook. */
3213
- 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 = "";
3214
3718
  try {
3215
- return fn();
3719
+ content = readFileSync(file, "utf-8").trim();
3216
3720
  } catch {
3217
3721
  return "";
3218
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);
3219
3728
  }
3220
3729
  /**
3221
- * Build the reconciliation snapshot markdown for `cwd`: git state, running
3222
- * harness version + drift, the persistent board, and one-shot gate status. Each
3223
- * collector is isolated by {@link safe}; an all-empty result yields `""`.
3224
- * @param cwd - The session working directory.
3225
- * @param moduleUrl - `import.meta.url` of the caller (locates the running package).
3226
- * @returns The snapshot markdown, or `""` when nothing to report.
3227
- */
3228
- function renderSnapshot(cwd, moduleUrl) {
3229
- const root = projectRootOrNull(cwd) ?? cwd;
3230
- return renderSections([
3231
- {
3232
- title: "Git",
3233
- body: safe(() => collectGit(root))
3234
- },
3235
- {
3236
- title: "Version",
3237
- body: safe(() => collectVersion(root, moduleUrl))
3238
- },
3239
- {
3240
- title: "Board",
3241
- body: safe(() => collectBoard(root))
3242
- },
3243
- {
3244
- title: "One-shot gates",
3245
- body: safe(() => oneShotSummary(cwd))
3246
- }
3247
- ]);
3248
- }
3249
- /**
3250
- * Concatenate the reconciliation snapshot onto a core SessionStart stdout. Fully
3251
- * fail-safe: any error returns `stdout` unchanged so the hook never breaks.
3252
- * @param stdout - The core SessionStart JSON stdout (may be `""`).
3253
- * @param cwd - The session working directory.
3254
- * @param moduleUrl - `import.meta.url` of the caller.
3255
- * @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).
3256
3738
  */
3257
- function withSnapshot(stdout, cwd, moduleUrl) {
3258
- try {
3259
- return attachSnapshot(stdout, renderSnapshot(cwd, moduleUrl));
3260
- } catch {
3261
- 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 "";
3262
3748
  }
3263
3749
  }
3264
3750
  //#endregion
@@ -3301,7 +3787,7 @@ async function injectApexSubagentContext(cwd, home = homedir()) {
3301
3787
  const agentsPath = join(apexDir, "AGENTS.md");
3302
3788
  const agents = existsSync(agentsPath) ? readText(agentsPath).slice(0, 4e3) : "";
3303
3789
  const taskData = await readJsonFile(join(apexDir, "task.json"));
3304
- return contextResponse("SubagentStart", `## APEX Sub-Agent Instructions
3790
+ return contextResponse("SubagentStart", capFragment("apex-subagent", `## APEX Sub-Agent Instructions
3305
3791
 
3306
3792
  You are a sub-agent in APEX workflow. Follow these rules:
3307
3793
 
@@ -3322,7 +3808,7 @@ ${agents}
3322
3808
  - Use Context7/Exa for docs | Write notes to .claude/apex/docs/
3323
3809
 
3324
3810
  ### 6. When Done
3325
- - TaskUpdate(taskId, status: completed) triggers auto-commit${cartographerContext()}`);
3811
+ - TaskUpdate(taskId, status: completed) triggers auto-commit${cartographerContext()}`));
3326
3812
  }
3327
3813
  /** 16-char hex SHA-256 of `text` (project hash / doc topic key). */
3328
3814
  function hashText16(text) {
@@ -4544,10 +5030,9 @@ function dispatchLifecycle(input) {
4544
5030
  if (input.scope === "aipilot") return "";
4545
5031
  harvestSubagentTrack(input.payload, input.cwd, input.now);
4546
5032
  return trackAgentMemory(input.payload, void 0, input.now);
4547
- case "TeammateIdle": return validateTeammateOutput(input.payload);
4548
- case "PostToolUseFailure":
4549
- logToolFailure(input.payload, void 0, input.now);
4550
- 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) : "";
4551
5036
  case "PreCompact": return saveApexState(input.cwd, input.now);
4552
5037
  case "SessionEnd":
4553
5038
  if (input.scope !== "aipilot") cleanupSession(void 0, input.now);
@@ -6742,181 +7227,49 @@ function designGate(payload, event, cacheDir, cwd) {
6742
7227
  return null;
6743
7228
  }
6744
7229
  //#endregion
6745
- //#region src/policy/lessons/trigger-index.ts
6746
- /**
6747
- * Compile the triggered-lesson index from `MEMORY/LESSON.md`. A lesson is a
6748
- * bullet (`- [YYYY-MM-DD HH:MM] ...`); it opts into decision-time injection by
6749
- * ending with a `[TRIGGERS tool:.. path:.. error:.. keyword:..]` line. Lessons
6750
- * WITHOUT that tag are skipped here (they keep the SessionStart block behavior —
6751
- * zero regression). Parsed once per file version (mtime-memoized).
6752
- */
6753
- /** Matches a trailing `[TRIGGERS ...]` line (its body captured). */
6754
- const TRIGGER_RE = /^\[TRIGGERS\s+(.+?)\]$/;
6755
- /** Comma list for `key:` in a trigger body (values are space-delimited). */
6756
- function list(body, key) {
6757
- const val = body.match(new RegExp(`\\b${key}:([^\\s\\]]+)`))?.[1];
6758
- return val ? val.split(",").filter(Boolean) : [];
6759
- }
6760
- /** Parse a `[TRIGGERS ...]` body into predicates (error is a single regex). */
6761
- function parseTriggers(body) {
6762
- const err = body.match(/\berror:([^\s\]]+)/);
6763
- return {
6764
- tools: list(body, "tool"),
6765
- paths: list(body, "path"),
6766
- error: err?.[1],
6767
- keywords: list(body, "keyword")
6768
- };
6769
- }
6770
- /** Collapse to a single ≤3-line compact string (cap length). */
6771
- function compact(text) {
6772
- const one = text.replace(/\s+/g, " ").trim();
6773
- return one.length > 280 ? `${one.slice(0, 277)}…` : one;
6774
- }
6775
- /**
6776
- * Parse LESSON.md content into triggered entries. A bullet's text spans its
6777
- * `- ` line plus any following non-blank continuation lines up to the next
6778
- * bullet; a `[TRIGGERS ...]` continuation line arms it.
6779
- * @param content - Raw LESSON.md text.
6780
- * @returns Entries that declared triggers (others skipped).
6781
- */
6782
- function parseLessons(content) {
6783
- const lines = content.split("\n");
6784
- const out = [];
6785
- for (let i = 0; i < lines.length; i++) {
6786
- const line = lines[i];
6787
- if (line === void 0 || !line.startsWith("- ")) continue;
6788
- let text = line.slice(2);
6789
- let triggers = null;
6790
- for (let j = i + 1; j < lines.length; j++) {
6791
- const cont = lines[j];
6792
- if (cont === void 0 || cont.trim() === "" || cont.startsWith("- ")) break;
6793
- const body = cont.trim().match(TRIGGER_RE)?.[1];
6794
- if (body !== void 0) triggers = parseTriggers(body);
6795
- else text += ` ${cont.trim()}`;
6796
- }
6797
- if (triggers) out.push({
6798
- text: compact(text),
6799
- triggers
6800
- });
6801
- }
6802
- return out;
6803
- }
6804
- let memo = null;
6805
- /**
6806
- * Compile (once per file version) the triggered-lesson index from `file`.
6807
- * Memoized by path+mtime: re-parses only when LESSON.md changes.
6808
- * @param file - Absolute path to MEMORY/LESSON.md.
6809
- * @returns The compiled entries (missing/unreadable file → empty).
6810
- */
6811
- function lessonIndex(file) {
6812
- let key;
6813
- try {
6814
- key = `${file}:${statSync(file).mtimeMs}`;
6815
- } catch {
6816
- return [];
6817
- }
6818
- if (memo?.key === key) return memo.entries;
6819
- let entries = [];
6820
- try {
6821
- entries = parseLessons(readFileSync(file, "utf-8"));
6822
- } catch {
6823
- entries = [];
6824
- }
6825
- memo = {
6826
- key,
6827
- entries
6828
- };
6829
- return entries;
6830
- }
6831
- /** Glob (`*`/`**`) → RegExp, matching a path segment/tail. */
6832
- function globToRe(glob) {
6833
- const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(//g, ".*");
6834
- return new RegExp(`(^|/)${esc}$`);
6835
- }
6836
- /** Safe case-insensitive regex test (absent source or invalid → false). */
6837
- function safeTest(src, s) {
6838
- if (!src) return false;
6839
- try {
6840
- return new RegExp(src, "i").test(s);
6841
- } catch {
6842
- return false;
6843
- }
6844
- }
6845
- /** Score one entry against the call; null = no predicate matched. */
6846
- function scoreEntry(e, tool, filePath, inputJson, prevError) {
6847
- const tr = e.triggers;
6848
- if (tr.tools.includes(tool)) return {
6849
- entry: e,
6850
- rank: 3
6851
- };
6852
- if (filePath && tr.paths.some((g) => globToRe(g).test(filePath))) return {
6853
- entry: e,
6854
- rank: 2
6855
- };
6856
- if (prevError && safeTest(tr.error, prevError)) return {
6857
- entry: e,
6858
- rank: 1
6859
- };
6860
- if (tr.keywords.some((k) => inputJson.includes(k))) return {
6861
- entry: e,
6862
- rank: 0
6863
- };
6864
- return null;
6865
- }
6866
- /** Stable, filesystem-safe cooldown key from a lesson's compact text (djb2). */
6867
- function cooldownKey(text) {
6868
- let h = 5381;
6869
- for (let i = 0; i < text.length; i++) h = (h << 5) + h + text.charCodeAt(i) | 0;
6870
- return `lesson:${(h >>> 0).toString(36)}`;
6871
- }
6872
- /**
6873
- * The single most-specific lesson for this PreToolUse call, or null. Matching
6874
- * priority: exact tool > path glob > error regex > input-JSON keyword. Cooldown
6875
- * suppresses a lesson already injected within the window.
6876
- * @param tool - The tool being called (e.g. `Write`).
6877
- * @param toolInput - The raw `tool_input`.
6878
- * @param opts - Index file, cooldown gate, and optional prior error.
6879
- * @returns An `inform` prompt, or null when nothing matches / in cooldown.
6880
- */
6881
- function lessonFor(tool, toolInput, opts) {
6882
- const entries = lessonIndex(opts.file);
6883
- if (entries.length === 0) return null;
6884
- const filePath = typeof toolInput?.file_path === "string" ? toolInput.file_path : "";
6885
- const inputJson = JSON.stringify(toolInput ?? {});
6886
- let best = null;
6887
- for (const e of entries) {
6888
- const m = scoreEntry(e, tool, filePath, inputJson, opts.prevError);
6889
- if (m && (!best || m.rank > best.rank)) best = m;
6890
- }
6891
- if (!best) return null;
6892
- if (!opts.once(cooldownKey(best.entry.text), opts.cooldownMs ?? 18e5)) return null;
6893
- return {
6894
- kind: "inform",
6895
- title: `Project lesson${filePath ? ` (${basename(filePath)})` : ""}`,
6896
- reason: best.entry.text
6897
- };
6898
- }
6899
- //#endregion
6900
7230
  //#region src/runtime/pre-allow.ts
6901
7231
  /**
6902
7232
  * PreToolUse ALLOW-path response assembly. Reached only after every gate
6903
7233
  * allowed (a deny/ask already returned upstream), so nothing here can block nor
6904
7234
  * override a decision. Combines the Python-parity pass notice (systemMessage)
6905
- * with the single most-specific decision-time lesson (additionalContext).
6906
- */
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
+ }
6907
7258
  /**
6908
7259
  * Build the native outcome for a PreToolUse call that passed every gate: emit a
6909
- * user-visible pass notice (once per allowed call) and, when its TRIGGERS match
6910
- * this call, the one cooldown-guarded decision-time lesson. Both channels ride a
6911
- * 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).
6912
7264
  * @param id - Harness id for {@link respond}.
6913
7265
  * @param event - The normalized PreToolUse event.
6914
7266
  * @param payload - The raw hook payload (for `agent_id`).
6915
7267
  * @param mcpDir - MCP state dir backing the pass-notice throttle.
6916
7268
  * @param cwd - Project root (lesson file + notice scope).
7269
+ * @param evidence - Session track + clock for the "evidence fresh" notice (omit to disable it).
6917
7270
  * @returns The native hook outcome (empty stdout when nothing to emit).
6918
7271
  */
6919
- function allowOutcome(id, event, payload, mcpDir, cwd) {
7272
+ async function allowOutcome(id, event, payload, mcpDir, cwd, evidence) {
6920
7273
  const notice = designPassNotice({
6921
7274
  agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
6922
7275
  tool: event.tool,
@@ -6929,13 +7282,32 @@ function allowOutcome(id, event, payload, mcpDir, cwd) {
6929
7282
  file: lessonsFileFor(projectRoot(cwd)),
6930
7283
  once: oncePerWindow
6931
7284
  });
6932
- if (lesson) return {
6933
- stdout: respond(id, notice?.userMessage ? {
6934
- ...lesson,
6935
- userMessage: notice.userMessage
6936
- } : lesson),
6937
- exit: 0
6938
- };
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
+ }
6939
7311
  return {
6940
7312
  stdout: notice ? respond(id, notice) : "",
6941
7313
  exit: 0
@@ -7006,7 +7378,11 @@ async function handlePre(ctx) {
7006
7378
  stdout: respond(id, prompt),
7007
7379
  exit: 0
7008
7380
  };
7009
- 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
+ });
7010
7386
  }
7011
7387
  //#endregion
7012
7388
  //#region src/freshness/query-framework.ts
@@ -7237,27 +7613,38 @@ async function handlePost(ctx) {
7237
7613
  url: "",
7238
7614
  phase: "post"
7239
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;
7240
7618
  if (designWarn) return {
7241
- stdout: respond(id, notice?.userMessage ? {
7619
+ stdout: respond(id, userMessage ? {
7242
7620
  ...designWarn,
7243
- userMessage: notice.userMessage
7621
+ userMessage
7244
7622
  } : designWarn),
7245
7623
  exit: 0
7246
7624
  };
7247
- if (!notice?.userMessage) return {
7625
+ if (!userMessage) return {
7248
7626
  stdout: extra,
7249
7627
  exit: 0
7250
7628
  };
7629
+ const withUserMessage = notice ? {
7630
+ ...notice,
7631
+ userMessage
7632
+ } : {
7633
+ kind: "inform",
7634
+ title: "Compliance",
7635
+ reason: "",
7636
+ userMessage
7637
+ };
7251
7638
  if (!extra) return {
7252
- stdout: respond(id, notice),
7639
+ stdout: respond(id, withUserMessage),
7253
7640
  exit: 0
7254
7641
  };
7255
7642
  if (id === "claude-code" || id === "codex") return {
7256
- stdout: attachSystemMessage(extra, notice.userMessage),
7643
+ stdout: attachSystemMessage(extra, userMessage),
7257
7644
  exit: 0
7258
7645
  };
7259
7646
  return {
7260
- stdout: respond(id, notice) || extra,
7647
+ stdout: respond(id, withUserMessage) || extra,
7261
7648
  exit: 0
7262
7649
  };
7263
7650
  }
@@ -7342,4 +7729,4 @@ async function handleHook(id, payload, opts) {
7342
7729
  });
7343
7730
  }
7344
7731
  //#endregion
7345
- 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 };