@fusengine/harness 0.1.56 → 0.1.58

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.
@@ -4,7 +4,7 @@ import { r as projectLayout } from "./layout-C0jaaCQC.mjs";
4
4
  import { i as walkUpFor, n as projectRoot, r as projectRootOrNull, t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
5
5
  import { A as detectCreationIntent, E as isExcludedSwiftPath, F as docConsultedGate, H as detectProjectType$1, I as evaluateApex, M as POST_AUTH_GATES, N as PRE_AUTH_GATES, S as usesTailwindUtilities, T as isExcludedJsPath, V as detectModularArchitecture, W as requiredArchSkill, _ as scanPlugin, c as EXCLUDE_DIRS$1, d as buildApexTaskInjection, h as buildClaudeMdContext, k as capVerbosity, l as PROJECT_INDICATORS, n as missingSeoElements, o as parseEnrichment, r as descFromText, s as parseEntry, t as isHtmlLike, w as frameworkSolidGate, x as skillTriggerGate, y as parseField, z as detectFramework } from "./validate-DCQ8dkdL.mjs";
6
6
  import { a as sanitizeSessionId, c as sessionsDir, d as countFrameworkCodeLines, f as countLines, i as loadSessionState, l as PLUGINS_DIR, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, t as claudeHome } from "./home-state-D0RLWP8J.mjs";
7
- import { n as FAIL_CLOSED, t as evaluate } from "./evaluate-CNAzgxnN.mjs";
7
+ import { n as FAIL_CLOSED, t as evaluate } from "./evaluate-BgFLlqrs.mjs";
8
8
  import { a as writeJsonFile, i as readJsonFile, r as hashText, t as atomicWrite } from "./json-io-DisYd2fb.mjs";
9
9
  import { r as isDocConsulted } from "./doc-helpers-CWZegVdR.mjs";
10
10
  import { n as findMarketplacePlugins, r as readPluginMeta, t as resolveSkillPath } from "./skill-path-DhItkBzk.mjs";
@@ -13,8 +13,9 @@ import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as se
13
13
  import { d as loadIndex, i as cacheLookupMeta, n as webfetchCacheWrite, o as cacheLookupSubstringMeta, t as mcpCacheWrite, u as extractText } from "./mcp-store-BkBDmuxN.mjs";
14
14
  import { t as loadRefs } from "./loader-AGz4nK7d.mjs";
15
15
  import { a as writeLastNonce, c as recordAgent, d as recordRefRead, f as recordTarget, h as apexAuthorizationGate, i as verifyTrack, l as recordBrainstormRequired, m as trivialCount, n as saveTrack, o as agentsFresh, p as recordTrivialEdit, r as signTrack, s as emptyTrack, t as loadTrack, u as recordDoc } from "./store-CQ4roWrU.mjs";
16
- import { d as collectFiles, f as pathExists, g as writeText, h as spawnCapture, i as denyResponse, l as systemMessage, m as sleep, n as blockResponse, p as readText, r as contextResponse, s as informResponse, t as attachSystemMessage } from "./claude-BxC9semG.mjs";
17
- import { r as toHermesResponse } from "./hermes-DWXCRFZU.mjs";
16
+ import { t as parseApplyPatch } from "./apply-patch-CIS2EZ_q.mjs";
17
+ import { d as collectFiles, f as pathExists, g as writeText, h as spawnCapture, i as denyResponse, l as systemMessage, m as sleep, n as blockResponse, p as readText, r as contextResponse, s as informResponse, t as attachSystemMessage } from "./claude-BLuab-tR.mjs";
18
+ import { r as toHermesResponse } from "./hermes-CyhCYqD7.mjs";
18
19
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
19
20
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
20
21
  import { homedir } from "node:os";
@@ -86,15 +87,31 @@ function normalizeEvent(id, payload) {
86
87
  }
87
88
  const event = str(payload.hook_event_name) ?? "";
88
89
  const input = payload.tool_input ?? payload;
89
- return {
90
+ const tool = str(payload.tool_name) ?? "";
91
+ const base = {
90
92
  phase: /post|after/i.test(event) ? "post" : "pre",
91
- tool: str(payload.tool_name) ?? "",
93
+ tool,
92
94
  input,
93
95
  sessionId: str(payload.session_id) ?? str(payload.conversation_id) ?? "",
96
+ agentType: str(payload.agent_type) ?? str(input.subagent_type)
97
+ };
98
+ if (tool === "apply_patch") {
99
+ const files = parseApplyPatch(str(input.command) ?? str(payload.command) ?? "").map((f) => ({
100
+ filePath: f.path,
101
+ content: f.content,
102
+ op: f.op
103
+ }));
104
+ return {
105
+ ...base,
106
+ phase: "pre",
107
+ files: files.length > 0 ? files : void 0
108
+ };
109
+ }
110
+ return {
111
+ ...base,
94
112
  filePath: str(input.file_path) ?? str(input.path) ?? str(payload.file_path),
95
113
  content: str(input.content) ?? str(input.new_string),
96
- command: str(input.command) ?? str(payload.command),
97
- agentType: str(payload.agent_type) ?? str(input.subagent_type)
114
+ command: str(input.command) ?? str(payload.command)
98
115
  };
99
116
  }
100
117
  //#endregion
@@ -329,6 +346,95 @@ function designLifecycle(payload, cacheDir, cwd, stamp, now) {
329
346
  }
330
347
  return false;
331
348
  }
349
+ //#endregion
350
+ //#region src/runtime/inject-dedup-exclusive.ts
351
+ /**
352
+ * @module inject-dedup-exclusive
353
+ * Cross-process-exact-once cooldown gate, via EXCLUSIVE file creation.
354
+ *
355
+ * {@link module:inject-dedup.oncePerWindow}'s shared-JSON read-modify-write is
356
+ * best-effort under true concurrency: the ~11-process plugin hook fan-out for
357
+ * one Claude event can lose an update and let 2-3 siblings all observe "not
358
+ * seen yet" (lesson 2026-07-05 16:00 — the `saveTrack` lost-update race, same
359
+ * shape). `writeFileSync(path, data, { flag: "wx" })` sidesteps this: the OS
360
+ * guarantees exclusive creation is atomic, so of N concurrent siblings calling
361
+ * this for the SAME key, exactly one observes success and the rest get
362
+ * `EEXIST` — never a double-win, with no lock file or retry loop needed.
363
+ *
364
+ * One marker file per key (not a shared map) is the tradeoff for that
365
+ * guarantee. A bounded sweep on every call deletes markers older than
366
+ * `windowMs` so the directory never grows unbounded under many distinct keys.
367
+ *
368
+ * Reserve this for HIGH-CONCURRENCY callers on a short burst window (same
369
+ * tool-use/lifecycle event fanned out to every installed plugin) — e.g. the
370
+ * sniper reminder ({@link module:lifecycle/track-changes}) and compliance
371
+ * notices ({@link module:notices}). Low-frequency, long-window callers (e.g.
372
+ * the 30-min lessons Stop-reminder cooldown) are fine on the JSON mode: a
373
+ * single real Stop event per session is not concurrent with itself the way
374
+ * one PostToolUse's ~11 sibling hooks are within the same 2s burst.
375
+ * @packageDocumentation
376
+ */
377
+ /** Subdirectory (under the state dir) holding one marker file per dedup key. */
378
+ const EXCLUSIVE_SUBDIR = "inject-dedup-locks";
379
+ /**
380
+ * Filesystem-safe, collision-resistant basename for `key`. Full MD5 hex
381
+ * (unlike {@link module:util/json-io.hashText}'s 8-char truncation) — a
382
+ * collision here would silently merge two unrelated keys' exclusivity.
383
+ */
384
+ function lockFileName(key) {
385
+ return `${createHash("md5").update(key).digest("hex")}.lock`;
386
+ }
387
+ /**
388
+ * Delete marker files older than `windowMs` in `dir` — O(n) per call, bounds
389
+ * directory growth. Compares against the creation timestamp STORED IN the
390
+ * marker's content (written by {@link onceExclusive} below), not the file's
391
+ * fs `mtime`: callers may pass a fake logical `now` (tests), which would never
392
+ * agree with the real OS clock backing `mtime`.
393
+ */
394
+ function sweepExclusiveDir(dir, now, windowMs) {
395
+ let entries;
396
+ try {
397
+ entries = readdirSync(dir);
398
+ } catch {
399
+ return;
400
+ }
401
+ for (const entry of entries) {
402
+ const path = join(dir, entry);
403
+ try {
404
+ const createdAt = Number(readFileSync(path, "utf8"));
405
+ if (!Number.isFinite(createdAt) || now - createdAt >= windowMs) unlinkSync(path);
406
+ } catch {}
407
+ }
408
+ }
409
+ /**
410
+ * Cooldown gate via exclusive marker-file creation. Returns `true` exactly
411
+ * once per `key` within `windowMs` across ALL concurrent processes sharing
412
+ * `opts.dir` (the caller MAY emit), `false` for every other concurrent or
413
+ * subsequent call inside the same window (the caller SHOULD suppress).
414
+ *
415
+ * Fails open on any unwritable state dir or unexpected fs error: the emission
416
+ * is allowed rather than silently dropped.
417
+ * @param key - Stable identity of the block (same semantics as {@link module:inject-dedup.oncePerWindow}).
418
+ * @param windowMs - Suppression window in ms (also the sweep threshold).
419
+ * @param opts - Optional clock + state-dir overrides (for tests).
420
+ * @returns `true` to proceed/emit, `false` to suppress.
421
+ */
422
+ function onceExclusive(key, windowMs, opts = {}) {
423
+ const now = opts.now ?? Date.now();
424
+ const dir = join(opts.dir ?? defaultStateDir(), EXCLUSIVE_SUBDIR);
425
+ try {
426
+ mkdirSync(dir, { recursive: true });
427
+ } catch {
428
+ return true;
429
+ }
430
+ sweepExclusiveDir(dir, now, windowMs);
431
+ try {
432
+ writeFileSync(join(dir, lockFileName(key)), String(now), { flag: "wx" });
433
+ return true;
434
+ } catch (err) {
435
+ return err.code !== "EEXIST";
436
+ }
437
+ }
332
438
  /** Sidecar basename under the per-project state dir. */
333
439
  const SIDECAR$2 = "inject-dedup.json";
334
440
  /** Load the `{ key -> epochMs }` map, or `{}` when missing/corrupt. */
@@ -373,6 +479,91 @@ function oncePerWindow(key, windowMs, opts = {}) {
373
479
  return true;
374
480
  }
375
481
  //#endregion
482
+ //#region src/runtime/fragment-registry.ts
483
+ let registry = [];
484
+ /** Clear the registry. Call once before dispatching a hook event. */
485
+ function resetFragmentRegistry() {
486
+ registry = [];
487
+ }
488
+ /** Record one fragment's post-cap size. Called by {@link module:inject-budget.capFragment}. */
489
+ function recordFragment(label, chars) {
490
+ registry.push({
491
+ label,
492
+ chars
493
+ });
494
+ }
495
+ /** Snapshot of every fragment recorded since the last {@link resetFragmentRegistry}. */
496
+ function fragmentRegistry() {
497
+ return [...registry];
498
+ }
499
+ //#endregion
500
+ //#region src/runtime/inject-budget.ts
501
+ /**
502
+ * @module inject-budget
503
+ * Hard per-fragment ceiling for harness-PRODUCED context injections (lessons,
504
+ * snapshot sections, APEX task context, cartographer/dev-context blocks) —
505
+ * inspired by Codex's "ContextualUserFragment, no item > 10K tokens" pattern.
506
+ * NEVER apply this to owner-authored content (CLAUDE.md/rules): that path is
507
+ * a locked invariant (see the "owner invariant" tests in
508
+ * test/dedup-inject.test.ts) and must always ship uncapped and in full.
509
+ *
510
+ * A real regression motivated this: MEMORY/LESSON.md's SessionStart injection
511
+ * had grown to ~44k tokens with no per-fragment or total alarm, silently
512
+ * bloating every SessionStart/SubagentStart turn.
513
+ * @packageDocumentation
514
+ */
515
+ /**
516
+ * Hard cap per fragment, in characters. ~8000 chars ≈ 2000 tokens at the
517
+ * conservative ~4 chars/token ratio for mixed FR/EN prose — the accepted
518
+ * approximation absent a real tokenizer (cf. LangChain's `trim_messages`
519
+ * fallback token_counter). Chosen because a SINGLE SessionStart already
520
+ * stacks several harness fragments (lessons, snapshot, dev-context,
521
+ * cartographer) on top of the uncapped CLAUDE.md block, and the known 11x
522
+ * multi-plugin hook fan-out can multiply whatever ships — 2k tokens/fragment
523
+ * keeps the harness-produced share bounded even under that fan-out, while
524
+ * still leaving room for a few dozen useful lines of content.
525
+ */
526
+ const FRAGMENT_CHAR_CAP = 8e3;
527
+ /**
528
+ * Cap `text` at {@link FRAGMENT_CHAR_CAP} characters, cutting at the last
529
+ * newline within budget so no line is chopped mid-sentence. Text at or under
530
+ * the cap is returned byte-identical (no-op). Over the cap, a single English
531
+ * truncation line is appended so the reader knows content was dropped and
532
+ * that the SOURCE FILE itself is untouched (only this injected view is cut).
533
+ * @param label - Short identifier of the fragment (e.g. "lessons", "Git").
534
+ * @param text - The candidate fragment body.
535
+ * @returns `text` unchanged, or a truncated copy ending in the cut notice — always ≤ the cap.
536
+ */
537
+ function capFragment(label, text) {
538
+ if (text.length <= 8e3) {
539
+ recordFragment(label, text.length);
540
+ return text;
541
+ }
542
+ const totalLen = text.length;
543
+ const safeLabel = label.length <= 80 ? label : `${label.slice(0, 77)}...`;
544
+ const suffixFor = (keptLen) => `\n[truncated ${safeLabel}: kept ${keptLen} of ${totalLen} chars — source file unchanged]`;
545
+ const reserve = suffixFor(totalLen).length;
546
+ const budget = Math.max(0, FRAGMENT_CHAR_CAP - reserve);
547
+ const slice = text.slice(0, budget);
548
+ const lastNl = slice.lastIndexOf("\n");
549
+ const kept = (lastNl > 0 ? slice.slice(0, lastNl) : slice).trimEnd();
550
+ const result = kept + suffixFor(kept.length);
551
+ recordFragment(label, result.length);
552
+ return result;
553
+ }
554
+ /**
555
+ * One-line numeric recap of what a batch of fragments actually injected —
556
+ * the owner-requested visibility so a silent blowup (like the 44k-token
557
+ * lessons block) shows up as a number instead of going unnoticed.
558
+ * @param fragments - The injected fragments (post-cap sizes).
559
+ * @returns e.g. `"injected 5 fragments, 14.2k chars"`, or `"injected 0 fragments"` when empty.
560
+ */
561
+ function budgetReport(fragments) {
562
+ if (fragments.length === 0) return "injected 0 fragments";
563
+ const total = fragments.reduce((sum, f) => sum + f.chars, 0);
564
+ return `injected ${fragments.length} fragments, ${(total / 1e3).toFixed(1)}k chars`;
565
+ }
566
+ //#endregion
376
567
  //#region src/runtime/inject-context.ts
377
568
  /**
378
569
  * Build the {@link oncePerWindow} key for the CLAUDE.md preamble gate. The
@@ -408,12 +599,14 @@ function promptSubmitContext(prompt, cwd) {
408
599
  /**
409
600
  * PreToolUse Task context injection: render the APEX sub-agent context as a
410
601
  * Claude `additionalContext` response when `.claude/apex/` exists, else "".
602
+ * Harness-produced (not owner CLAUDE.md content), so it is subject to the
603
+ * per-fragment {@link capFragment} budget — unlike {@link promptSubmitContext}.
411
604
  * @param cwd - Fallback project root when `CLAUDE_PROJECT_DIR` is unset.
412
605
  * @returns The native hook stdout (possibly empty).
413
606
  */
414
607
  function taskContext(cwd) {
415
608
  const ctx = buildApexTaskInjection(process.env.CLAUDE_PROJECT_DIR ?? cwd);
416
- return ctx ? contextResponse("PreToolUse", ctx) : "";
609
+ return ctx ? contextResponse("PreToolUse", capFragment("apex-task", ctx)) : "";
417
610
  }
418
611
  //#endregion
419
612
  //#region src/runtime/dev-context.ts
@@ -459,7 +652,7 @@ function projectContext(cwd) {
459
652
  * @returns The joined additionalContext text (possibly empty).
460
653
  */
461
654
  function devContext(cwd) {
462
- return [...gitContext(cwd), ...projectContext(cwd)].join("\n");
655
+ return capFragment("dev-context", [...gitContext(cwd), ...projectContext(cwd)].join("\n"));
463
656
  }
464
657
  //#endregion
465
658
  //#region src/runtime/fs-cleanup.ts
@@ -1314,6 +1507,78 @@ function validateRulesLoaded(data, home = homedir()) {
1314
1507
  /** Fan-out dedup window (ms). The ~11 sibling hooks for one event land in <2s. */
1315
1508
  const BURST_DEDUP_MS = 2e3;
1316
1509
  //#endregion
1510
+ //#region src/runtime/notices.ts
1511
+ /**
1512
+ * @module notices
1513
+ * Compact, uniform "compliance" notices for the user-visible `systemMessage`
1514
+ * channel — the visual counterpart to the additionalContext-only gate/credit
1515
+ * signals, which land in agent-only context and stay invisible to the human in
1516
+ * the Claude Code UI (deny reasons show in red; these did not show at all).
1517
+ * Owner-reported gap: pass-notices existed in v0.1.49 for design gates, but
1518
+ * nothing surfaced skill credits, freshness, or the sniper reminder to the human.
1519
+ *
1520
+ * The text builders are pure. `refCreditNoticeFor` is the one exception — like
1521
+ * `pre-allow.ts` uses the JSON `oncePerWindow`, this one uses the exclusive
1522
+ * {@link onceExclusive} mode instead: it fires on the same short burst window
1523
+ * as `track-changes.ts`'s sniper reminder, fanned out across every installed
1524
+ * plugin's PostToolUse hook for ONE real edit, so the same lost-update race
1525
+ * applies (lesson 2026-07-05 16:00). Rendering onto a harness's native stdout
1526
+ * always goes through the existing adapter helpers (`respond`/
1527
+ * `attachSystemMessage`); a harness with no `systemMessage` channel (e.g.
1528
+ * cline) silently drops the notice there (documented no-op, never a crash) —
1529
+ * nothing in this module renders directly.
1530
+ */
1531
+ /** One compliance line: `✓ <gate> — <detail>` (detail omitted when empty). */
1532
+ function complianceNotice(gate, detail) {
1533
+ return detail ? `✓ ${gate} — ${detail}` : `✓ ${gate}`;
1534
+ }
1535
+ /** One non-blocking requirement line: `⚠ <requirement> — <detail>`. */
1536
+ function requirementNotice(requirement, detail) {
1537
+ return detail ? `⚠ ${requirement} — ${detail}` : `⚠ ${requirement}`;
1538
+ }
1539
+ /** 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). */
1540
+ function skillNameFromRefPath(path) {
1541
+ return /skills\/([^/]+)\//.exec(path)?.[1] ?? null;
1542
+ }
1543
+ /** Notice for a SOLID/skill reference credited via an in-session Read, or null for a non-skill `.md`. */
1544
+ function refCreditedNotice(path) {
1545
+ const skill = skillNameFromRefPath(path);
1546
+ return skill ? complianceNotice("SOLID refs read", skill) : null;
1547
+ }
1548
+ /** Notice for the APEX freshness gate (explore+research) currently satisfied. */
1549
+ function evidenceFreshNotice() {
1550
+ return complianceNotice("evidence fresh", "explore+research");
1551
+ }
1552
+ /** Notice mirroring the existing sniper-required additionalContext reminder. */
1553
+ function sniperRequiredNotice(fileName) {
1554
+ return requirementNotice("sniper required", fileName);
1555
+ }
1556
+ /**
1557
+ * The one `✓ SOLID refs read (<skill>)` notice to show for this PostToolUse
1558
+ * call, or null. Scans the activities `activityFor` recorded from this event
1559
+ * for a skill-ref Read, deduped per (session, path) against the same burst
1560
+ * window as the sniper reminder — the ~11 sibling-plugin fan-out for one real
1561
+ * Read must never repeat it (lesson 2026-07-05 15:21).
1562
+ * @param activities - This event's recorded activities (only `ref` entries matter).
1563
+ * @param sessionId - Current session id (dedup scope).
1564
+ * @param now - Event clock.
1565
+ * @param dir - State-dir override for the dedup sidecar (tests MUST pass an
1566
+ * isolated dir; production passes the per-project state dir).
1567
+ */
1568
+ function refCreditNoticeFor(activities, sessionId, now, dir) {
1569
+ for (const a of activities) {
1570
+ if (a.kind !== "ref" || !a.path) continue;
1571
+ const notice = refCreditedNotice(a.path);
1572
+ if (!notice) continue;
1573
+ if (!onceExclusive(`ref-credited:${sessionId}:${a.path}`, 2e3, {
1574
+ now,
1575
+ dir
1576
+ })) continue;
1577
+ return notice;
1578
+ }
1579
+ return null;
1580
+ }
1581
+ //#endregion
1317
1582
  //#region src/runtime/lifecycle/track-changes.ts
1318
1583
  /** Code-file extensions tracked for sniper (mirrors track-session-changes.py). */
1319
1584
  const CODE_EXT$1 = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|vue|svelte|astro)$/;
@@ -1348,11 +1613,12 @@ function trackSessionChanges(sessionIdRaw, filePath, home = homedir(), now = Dat
1348
1613
  lastCheck: new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z")
1349
1614
  };
1350
1615
  saveSessionState(sid, state, home);
1351
- if (!oncePerWindow(`sniper:${sid}:${filePath}`, 2e3, {
1616
+ if (!onceExclusive(`sniper:${sid}:${filePath}`, 2e3, {
1352
1617
  now,
1353
1618
  dir: sessionsDir(home)
1354
1619
  })) 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.`);
1620
+ const fname = basename(filePath);
1621
+ 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
1622
  }
1357
1623
  //#endregion
1358
1624
  //#region src/runtime/lifecycle/post-edit-ts.ts
@@ -1698,1567 +1964,1918 @@ function harvestSubagentTrack(payload, cwd, now, baseDir = defaultStateDir(cwd))
1698
1964
  } catch {}
1699
1965
  }
1700
1966
  //#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
- }
1967
+ //#region src/runtime/lifecycle/teammate-idle-check.ts
1729
1968
  /**
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.
1969
+ * @module teammate-idle-check
1970
+ * TeammateIdle anti-"false done": alongside the existing sniper suggestion
1971
+ * ({@link validateTeammateOutput}), verify that the files this teammate ANNOUNCED
1972
+ * (session-changes `modifiedFiles`) actually exist on disk. A claimed deliverable
1973
+ * missing on disk is a mechanically-verifiable false-done signal warn the lead.
1974
+ * Deduped across the fan-out; silent when nothing is verifiable. Fail-open.
1975
+ *
1976
+ * Claude-Code-only: no equivalent `TeammateIdle` hook exists on Codex or Hermes,
1977
+ * so this handler is never reached through those adapters.
1978
+ * @packageDocumentation
1737
1979
  */
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
- });
1980
+ /** Re-warn about the same idle teammate at most once per 30s (fan-out + retries). */
1981
+ const IDLE_DEDUP_MS = 3e4;
1982
+ /** Announced files (session changes) that are ABSENT on disk (the false-done set). */
1983
+ function missingDeliverables(sessionId, home) {
1984
+ return (loadSessionState(sessionId, home).changes?.modifiedFiles ?? []).filter((f) => typeof f === "string" && f !== "" && !existsSync(f));
1746
1985
  }
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 {}
1986
+ /** Pull the `additionalContext` body out of a `contextResponse` stdout ("" when empty/unparseable). */
1987
+ function bodyOf(stdout) {
1988
+ if (!stdout) return "";
1989
+ try {
1990
+ return JSON.parse(stdout).hookSpecificOutput?.additionalContext ?? "";
1991
+ } catch {
1992
+ return "";
1762
1993
  }
1763
- return violations;
1764
1994
  }
1765
1995
  /**
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`).
1996
+ * Handle TeammateIdle: merge the existing sniper suggestion with a missing-
1997
+ * deliverable warning (deduped) into one `additionalContext` response, or "" when
1998
+ * neither fires.
1999
+ * @param data - The raw TeammateIdle payload (`teammate_name`, `session_id`).
2000
+ * @param cwd - Project root (state dir for the dedup sidecar).
1771
2001
  * @param home - Home dir (defaults to `~`).
1772
2002
  * @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.
2003
+ * @returns The native hook stdout, or "".
1775
2004
  */
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("; "));
2005
+ function teammateIdleContext(data, cwd, home = homedir(), now = Date.now()) {
2006
+ const sniper = bodyOf(validateTeammateOutput(data, home));
2007
+ const sessionId = sanitizeSessionId(data.session_id);
2008
+ const teammate = String(data.teammate_name ?? data.team_name ?? "unknown");
2009
+ let notice = "";
2010
+ if (sessionId) {
2011
+ const missing = missingDeliverables(sessionId, home);
2012
+ if (missing.length > 0 && oncePerWindow(`idle:${sessionId}:${teammate}`, IDLE_DEDUP_MS, {
2013
+ now,
2014
+ dir: defaultStateDir(cwd)
2015
+ })) notice = `Teammate '${teammate}' idle but expected deliverable(s) not found on disk: ${missing.slice(0, 5).join(", ")} — verify before treating as done.`;
2016
+ }
2017
+ const merged = [sniper, notice].filter(Boolean).join("\n\n");
2018
+ return merged ? contextResponse("TeammateIdle", merged) : "";
1786
2019
  }
1787
2020
  //#endregion
1788
- //#region src/runtime/lifecycle/cartographer/fs-util.ts
2021
+ //#region src/policy/lessons/trigger-index.ts
1789
2022
  /**
1790
- * Filesystem helpers for the cartographer tree walk. Ports the fs parts of
1791
- * `describe.py` (file desc) and `write_recursive.py` (children + counts).
2023
+ * Compile the triggered-lesson index from `MEMORY/LESSON.md`. A lesson is a
2024
+ * bullet (`- [YYYY-MM-DD HH:MM] ...`); it opts into decision-time injection by
2025
+ * ending with a `[TRIGGERS tool:.. path:.. error:.. keyword:..]` line. Lessons
2026
+ * WITHOUT that tag are skipped here (they keep the SessionStart block behavior —
2027
+ * zero regression). Parsed once per file version (mtime-memoized).
1792
2028
  */
2029
+ /** Matches a trailing `[TRIGGERS ...]` line (its body captured). */
2030
+ const TRIGGER_RE = /^\[TRIGGERS\s+(.+?)\]$/;
2031
+ /** Comma list for `key:` in a trigger body (values are space-delimited). */
2032
+ function list(body, key) {
2033
+ const val = body.match(new RegExp(`\\b${key}:([^\\s\\]]+)`))?.[1];
2034
+ return val ? val.split(",").filter(Boolean) : [];
2035
+ }
2036
+ /** Parse a `[TRIGGERS ...]` body into predicates (error is a single regex). */
2037
+ function parseTriggers(body) {
2038
+ const err = body.match(/\berror:([^\s\]]+)/);
2039
+ return {
2040
+ tools: list(body, "tool"),
2041
+ paths: list(body, "path"),
2042
+ error: err?.[1],
2043
+ keywords: list(body, "keyword")
2044
+ };
2045
+ }
2046
+ /** Collapse to a single ≤3-line compact string (cap length). */
2047
+ function compact(text) {
2048
+ const one = text.replace(/\s+/g, " ").trim();
2049
+ return one.length > 280 ? `${one.slice(0, 277)}…` : one;
2050
+ }
1793
2051
  /**
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 "".
2052
+ * Parse LESSON.md content into triggered entries. A bullet's text spans its
2053
+ * `- ` line plus any following non-blank continuation lines up to the next
2054
+ * bullet; a `[TRIGGERS ...]` continuation line arms it.
2055
+ * @param content - Raw LESSON.md text.
2056
+ * @returns Entries that declared triggers (others skipped).
1798
2057
  */
1799
- function getFileDesc(filePath) {
1800
- let text = "";
1801
- try {
1802
- text = readFileSync(filePath, "utf-8");
1803
- } catch {
1804
- return "";
2058
+ function parseLessons(content) {
2059
+ const lines = content.split("\n");
2060
+ const out = [];
2061
+ for (let i = 0; i < lines.length; i++) {
2062
+ const line = lines[i];
2063
+ if (line === void 0 || !line.startsWith("- ")) continue;
2064
+ let text = line.slice(2);
2065
+ let triggers = null;
2066
+ for (let j = i + 1; j < lines.length; j++) {
2067
+ const cont = lines[j];
2068
+ if (cont === void 0 || cont.trim() === "" || cont.startsWith("- ")) break;
2069
+ const body = cont.trim().match(TRIGGER_RE)?.[1];
2070
+ if (body !== void 0) triggers = parseTriggers(body);
2071
+ else text += ` ${cont.trim()}`;
2072
+ }
2073
+ if (triggers) out.push({
2074
+ text: compact(text),
2075
+ triggers
2076
+ });
1805
2077
  }
1806
- const suffix = extname(filePath);
1807
- const mdField = suffix === ".md" ? parseField(text, "description") : "";
1808
- return descFromText(suffix, text, mdField);
2078
+ return out;
1809
2079
  }
2080
+ let memo = null;
1810
2081
  /**
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.
2082
+ * Compile (once per file version) the triggered-lesson index from `file`.
2083
+ * Memoized by path+mtime: re-parses only when LESSON.md changes.
2084
+ * @param file - Absolute path to MEMORY/LESSON.md.
2085
+ * @returns The compiled entries (missing/unreadable file empty).
1816
2086
  */
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;
2087
+ function lessonIndex(file) {
2088
+ let key;
1833
2089
  try {
1834
- entries = readdirSync(source, { withFileTypes: true });
2090
+ key = `${file}:${statSync(file).mtimeMs}`;
1835
2091
  } catch {
1836
- return {
1837
- dirs,
1838
- files
1839
- };
2092
+ return [];
1840
2093
  }
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);
2094
+ if (memo?.key === key) return memo.entries;
2095
+ let entries = [];
2096
+ try {
2097
+ entries = parseLessons(readFileSync(file, "utf-8"));
2098
+ } catch {
2099
+ entries = [];
1846
2100
  }
1847
- return {
1848
- dirs: dirs.sort(),
1849
- files: files.sort()
2101
+ memo = {
2102
+ key,
2103
+ entries
1850
2104
  };
2105
+ return entries;
1851
2106
  }
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");
2107
+ /** Glob (`*`/`**`) → RegExp, matching a path segment/tail. */
2108
+ function globToRe(glob) {
2109
+ const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(//g, ".*");
2110
+ return new RegExp(`(^|/)${esc}$`);
2111
+ }
2112
+ /** Safe case-insensitive regex test (absent source or invalid → false). */
2113
+ function safeTest(src, s) {
2114
+ if (!src) return false;
1865
2115
  try {
1866
- if (!existsSync(sidecar)) return {};
1867
- return JSON.parse(readFileSync(sidecar, "utf-8")).entries ?? {};
2116
+ return new RegExp(src, "i").test(s);
1868
2117
  } catch {
1869
- return {};
2118
+ return false;
1870
2119
  }
1871
2120
  }
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
- });
2121
+ /** Score one entry against the call; null = no predicate matched. */
2122
+ function scoreEntry(e, tool, filePath, inputJson, prevError) {
2123
+ const tr = e.triggers;
2124
+ if (tr.tools.includes(tool)) return {
2125
+ entry: e,
2126
+ rank: 3
2127
+ };
2128
+ if (filePath && tr.paths.some((g) => globToRe(g).test(filePath))) return {
2129
+ entry: e,
2130
+ rank: 2
2131
+ };
2132
+ if (prevError && safeTest(tr.error, prevError)) return {
2133
+ entry: e,
2134
+ rank: 1
2135
+ };
2136
+ if (tr.keywords.some((k) => inputJson.includes(k))) return {
2137
+ entry: e,
2138
+ rank: 0
2139
+ };
2140
+ return null;
2141
+ }
2142
+ /** Stable, filesystem-safe cooldown key from a lesson's compact text (djb2). */
2143
+ function cooldownKey(text) {
2144
+ let h = 5381;
2145
+ for (let i = 0; i < text.length; i++) h = (h << 5) + h + text.charCodeAt(i) | 0;
2146
+ return `lesson:${(h >>> 0).toString(36)}`;
1896
2147
  }
1897
- //#endregion
1898
- //#region src/runtime/lifecycle/cartographer/write-tree.ts
1899
- /**
1900
- * Recursive index.md tree writer. Ports `write_recursive.py`.
1901
- */
1902
2148
  /**
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.
2149
+ * The single most-specific lesson for this PreToolUse call, or null. Matching
2150
+ * priority: exact tool > path glob > error regex > input-JSON keyword. Cooldown
2151
+ * suppresses a lesson already injected within the window.
2152
+ * @param tool - The tool being called (e.g. `Write`).
2153
+ * @param toolInput - The raw `tool_input`.
2154
+ * @param opts - Index file, cooldown gate, and optional prior error.
2155
+ * @returns An `inform` prompt, or null when nothing matches / in cooldown.
1910
2156
  */
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}`);
2157
+ function lessonFor(tool, toolInput, opts) {
2158
+ const entries = lessonIndex(opts.file);
2159
+ if (entries.length === 0) return null;
2160
+ const filePath = typeof toolInput?.file_path === "string" ? toolInput.file_path : "";
2161
+ const inputJson = JSON.stringify(toolInput ?? {});
2162
+ let best = null;
2163
+ for (const e of entries) {
2164
+ const m = scoreEntry(e, tool, filePath, inputJson, opts.prevError);
2165
+ if (m && (!best || m.rank > best.rank)) best = m;
1933
2166
  }
1934
- const indexPath = join(output, "index.md");
1935
- writeFileSync(indexPath, mergeLines(lines, indexPath).join("\n") + "\n", "utf-8");
2167
+ if (!best) return null;
2168
+ if (!opts.once(cooldownKey(best.entry.text), opts.cooldownMs ?? 18e5)) return null;
2169
+ return {
2170
+ kind: "inform",
2171
+ title: `Project lesson${filePath ? ` (${basename(filePath)})` : ""}`,
2172
+ reason: best.entry.text
2173
+ };
1936
2174
  }
1937
2175
  //#endregion
1938
- //#region src/runtime/lifecycle/cartographer/project-map.ts
2176
+ //#region src/runtime/lifecycle/lessons/state.ts
1939
2177
  /**
1940
- * Project map generation. Ports `generate_project_map.py` (project map only).
2178
+ * Per-project lessons paths. The `fuse-lessons` plugin stores its lessons under
2179
+ * `<root>/MEMORY/` (NOT the harness `.harness/memory/`), so these two path
2180
+ * helpers override the layout while ALL state/gitignore/throttle logic is
2181
+ * reused from `src/memory` (`setStateField`, `ensureMemoryGitignore`,
2182
+ * `readState`, `nowStamp`, `throttleMs`).
1941
2183
  */
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
- }
2184
+ /** Absolute `<root>/MEMORY/LESSON.md` the curated, committable lessons file. */
2185
+ function lessonsFileFor(root) {
2186
+ return join(root, "MEMORY", "LESSON.md");
1949
2187
  }
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;
2188
+ /** Absolute `<root>/MEMORY/LESSON-archive.md` — cold storage for capped-out bullets. */
2189
+ function lessonsArchiveFileFor(root) {
2190
+ return join(root, "MEMORY", "LESSON-archive.md");
1961
2191
  }
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 "";
2192
+ /** Absolute `<root>/MEMORY/state.json` — machine-local throttle counter. */
2193
+ function lessonsStateFileFor(root) {
2194
+ return join(root, "MEMORY", "state.json");
1976
2195
  }
1977
2196
  //#endregion
1978
- //#region src/policy/cartographer/build-tree.ts
1979
- const SECTION_ORDER = [
1980
- "agent",
1981
- "skill",
1982
- "command"
1983
- ];
2197
+ //#region src/tracking/one-shot-store.ts
2198
+ /** A fresh, empty state — always spread (`{ ...EMPTY }`) so the const is never shared. */
2199
+ const EMPTY = {
2200
+ gates: {},
2201
+ firstTry: 0,
2202
+ corrected: 0,
2203
+ pending: {},
2204
+ updatedAt: 0
2205
+ };
1984
2206
  /**
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.
2207
+ * Drop stale data: whole-state idle reset past the window, else per-entry prune of
2208
+ * gates/pending older than `windowMs`. Keeps the "7d" window honest, bounds size.
1993
2209
  */
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
- });
2210
+ function pruneState(s, now, windowMs) {
2211
+ if (now - s.updatedAt >= windowMs) return { ...EMPTY };
2212
+ const gates = {};
2213
+ for (const [k, g] of Object.entries(s.gates)) if (now - g.lastTs < windowMs) gates[k] = g;
2214
+ const pending = {};
2215
+ for (const [k, p] of Object.entries(s.pending)) if (now - p.ts < windowMs) pending[k] = p;
2216
+ return {
2217
+ ...s,
2218
+ gates,
2219
+ pending
2220
+ };
2004
2221
  }
2005
2222
  /**
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.
2223
+ * Record a deny for gate `title` on operation `op` (content-free tool identity):
2224
+ * bump the gate's deny count and mark `op` pending for a later fix.
2011
2225
  */
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");
2226
+ function applyDeny(s, title, op, now) {
2227
+ const g = s.gates[title] ?? {
2228
+ denies: 0,
2229
+ corrected: 0,
2230
+ lastTs: 0
2231
+ };
2232
+ return {
2233
+ ...s,
2234
+ gates: {
2235
+ ...s.gates,
2236
+ [title]: {
2237
+ denies: g.denies + 1,
2238
+ corrected: g.corrected,
2239
+ lastTs: now
2240
+ }
2241
+ },
2242
+ pending: {
2243
+ ...s.pending,
2244
+ [op]: {
2245
+ title,
2246
+ ts: now
2247
+ }
2248
+ },
2249
+ updatedAt: now
2250
+ };
2037
2251
  }
2038
- //#endregion
2039
- //#region src/runtime/lifecycle/cartographer/write-plugin-map.ts
2040
2252
  /**
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`.
2253
+ * Record an allow for a gateable `op`. A non-gateable allow (Read/Task/MCP) leaves
2254
+ * state untouched it never counts and never clears a pending deny. Otherwise: a
2255
+ * pending deny `corrected` (a fix, credited to the blocking gate); no pending →
2256
+ * `firstTry` (one-shot).
2044
2257
  */
2045
- /** True when `dir` is a real directory. */
2046
- function isDir(dir) {
2047
- try {
2048
- return statSync(dir).isDirectory();
2049
- } catch {
2050
- return false;
2258
+ function applyAllow(s, op, now, gateable) {
2259
+ if (!gateable) return s;
2260
+ const pend = s.pending[op];
2261
+ if (pend) {
2262
+ const g = s.gates[pend.title] ?? {
2263
+ denies: 0,
2264
+ corrected: 0,
2265
+ lastTs: 0
2266
+ };
2267
+ const { [op]: _drop, ...pending } = s.pending;
2268
+ return {
2269
+ ...s,
2270
+ gates: {
2271
+ ...s.gates,
2272
+ [pend.title]: {
2273
+ ...g,
2274
+ corrected: g.corrected + 1,
2275
+ lastTs: now
2276
+ }
2277
+ },
2278
+ corrected: s.corrected + 1,
2279
+ pending,
2280
+ updatedAt: now
2281
+ };
2051
2282
  }
2283
+ return gateable ? {
2284
+ ...s,
2285
+ firstTry: s.firstTry + 1,
2286
+ updatedAt: now
2287
+ } : s;
2052
2288
  }
2053
2289
  /**
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).
2290
+ * Compact injectable summary (one line); "" when there is nothing to report.
2291
+ * @returns e.g. `gates 7d: 88% one-shot (44/50 clean); SOLID file-size limit 4den/3fix`.
2061
2292
  */
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
- }
2293
+ function formatSummary(s) {
2294
+ const keys = Object.keys(s.gates);
2295
+ const total = s.firstTry + s.corrected;
2296
+ if (keys.length === 0 && total === 0) return "";
2297
+ const head = total > 0 ? `${Math.round(s.firstTry / total * 100)}% one-shot (${s.firstTry}/${total} clean)` : "no clean pass yet";
2298
+ const parts = keys.map((k) => ({
2299
+ k,
2300
+ g: s.gates[k]
2301
+ })).sort((a, b) => b.g.denies - a.g.denies).map(({ k, g }) => `${k} ${g.denies}den/${g.corrected}fix`);
2302
+ return `gates 7d: ${head}${parts.length ? `; ${parts.join("; ")}` : ""}`;
2076
2303
  }
2077
2304
  //#endregion
2078
- //#region src/runtime/lifecycle/cartographer/ecosystem-map.ts
2305
+ //#region src/policy/deny-loop.ts
2079
2306
  /**
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`.
2307
+ * @module deny-loop
2308
+ * Pure anti-loop logic: hash a tool-call, decide if it repeats a prior deny, and
2309
+ * enrich the repeated block's message.
2310
+ *
2311
+ * The proprietary rule "NEVER propose the same fix twice" is prose a model under
2312
+ * pressure ignores. This makes it machine-enforced: when a call whose
2313
+ * `(tool + normalized input)` hash was ALREADY denied in-window is retried, the
2314
+ * harness keeps the deny but rewrites the message — `[REPEAT]` title, STOP
2315
+ * prefix, forced `research-expert` action. State + wiring live in the sidecar
2316
+ * store ({@link module:deny-loop-store}); this file is IO-free and pure.
2317
+ * @packageDocumentation
2084
2318
  */
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", " ");
2319
+ /** Stable JSON: keys sorted at every depth so `{a,b}` and `{b,a}` hash identically. */
2320
+ function stableStringify(v) {
2321
+ if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
2322
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
2323
+ const o = v;
2324
+ return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(",")}}`;
2102
2325
  }
2103
2326
  /**
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 "".
2327
+ * Stable identity hash of a tool-call = tool name + normalized (key-sorted) input,
2328
+ * so re-ordered keys never mask a repeat.
2329
+ * @param tool - Tool name (e.g. "Write", "Bash").
2330
+ * @param input - Identifying tool input (filePath/content/command...).
2331
+ * @returns 8-char hex hash.
2108
2332
  */
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.`;
2333
+ function denyHash(tool, input) {
2334
+ return hashText(`${tool}\n${stableStringify(input)}`);
2134
2335
  }
2135
- //#endregion
2136
- //#region src/runtime/lifecycle/cartographer/session-start.ts
2137
- /**
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.
2142
- */
2143
2336
  /**
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.
2337
+ * Pure loop check: given the already-pruned in-window map, compute the running
2338
+ * count for `hash` and whether it repeats (count > 1). No IO — the caller persists.
2339
+ *
2340
+ * When `dedupMs` is set (>0) and an identical prior deny landed within that
2341
+ * window, the current call is a sibling hook echoing the SAME event (see
2342
+ * {@link module:burst-window}): it returns the prior verdict VERBATIM with
2343
+ * `deduped:true` and does NOT bump the count, so all N fan-out processes agree
2344
+ * on one number instead of counting to N. Absent `dedupMs` (mono-process
2345
+ * callers / unit tests) the historical increment-every-time behaviour holds.
2346
+ * @param hash - {@link denyHash}-derived map key of the current call.
2347
+ * @param priorDenies - The `{ hash -> DenyEntry }` map, pruned to `now`/`windowMs`.
2348
+ * @param opts - Clock + window, plus an optional burst-dedup window.
2349
+ * @returns `{ isRepeat, count, hash, deduped? }`.
2147
2350
  */
2148
- function pluginsDirFromEnv() {
2149
- const root = process.env.CLAUDE_PLUGIN_ROOT;
2150
- return root ? resolve(root, "..") : void 0;
2351
+ function denyLoopCheck(hash, priorDenies, opts) {
2352
+ const prev = priorDenies[hash];
2353
+ if (!(prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs)) return {
2354
+ isRepeat: false,
2355
+ count: 1,
2356
+ hash
2357
+ };
2358
+ if ((opts.dedupMs ?? 0) > 0 && opts.now - prev.lastTs < (opts.dedupMs ?? 0)) return {
2359
+ isRepeat: prev.count > 1,
2360
+ count: prev.count,
2361
+ hash,
2362
+ deduped: true
2363
+ };
2364
+ const count = prev.count + 1;
2365
+ return {
2366
+ isRepeat: count > 1,
2367
+ count,
2368
+ hash
2369
+ };
2151
2370
  }
2152
2371
  /**
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 "".
2372
+ * Enrich a REPEATED block prompt a NEW object, never a mutation (the input may
2373
+ * be a shared const like FAIL_CLOSED). The decision stays `block`; only the
2374
+ * message changes, so every harness renders it through the same adapter.
2375
+ * @param prompt - The original block prompt.
2376
+ * @param count - The running identical-deny count (n).
2377
+ * @returns A block prompt with `[REPEAT]` title, STOP-prefixed reason, forced research action.
2158
2378
  */
2159
- function cartoSessionStart(cwd, now = Date.now()) {
2160
- generateProjectMap(cwd);
2161
- const ctx = generateEcosystemMap(now, pluginsDirFromEnv());
2162
- return ctx ? contextResponse("SessionStart", ctx) : "";
2379
+ function enrichRepeatDeny(prompt, count) {
2380
+ const stop = `Identical attempt #${count} already denied for the same reason. STOP: do not retry this same call. `;
2381
+ const action = "Launch fuse-ai-pilot:research-expert to find a DIFFERENT approach";
2382
+ return {
2383
+ ...prompt,
2384
+ title: prompt.title.startsWith("[REPEAT]") ? prompt.title : `[REPEAT] ${prompt.title}`,
2385
+ reason: stop + prompt.reason,
2386
+ actions: [action, ...prompt.actions ?? []]
2387
+ };
2163
2388
  }
2164
2389
  //#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));
2390
+ //#region src/tracking/one-shot-dedup.ts
2391
+ /**
2392
+ * @module one-shot-dedup
2393
+ * Burst-dedup guard for the one-shot metric ({@link module:one-shot}).
2394
+ *
2395
+ * ONE Claude tool event fans out to ~11 sibling plugin-hook processes, each
2396
+ * calling {@link recordOneShot}; without this the metric would count a single
2397
+ * deny/allow ~11×. Reuses the proven {@link oncePerWindow} cooldown sidecar:
2398
+ * the FIRST process in the {@link module:burst-window} window mutates the
2399
+ * metric, the rest skip. The dedup key includes the outcome KIND (deny-title vs
2400
+ * allow) so a deny and its later fix different kinds are never folded into
2401
+ * each other. No `sessionId` always the first (mono-process + unit-test
2402
+ * parity; a burst can only exist when a real session drives the fan-out).
2403
+ * @packageDocumentation
2404
+ */
2405
+ /**
2406
+ * True when this `(op, kind)` is the FIRST of its burst for the session — the
2407
+ * process that should actually mutate the metric. Sibling processes firing the
2408
+ * SAME event within {@link BURST_DEDUP_MS} return false and skip the write.
2409
+ * @param op - Content-free operation key ({@link denyHash}("op", …)).
2410
+ * @param kind - Outcome discriminator (`deny:<title>` or `allow`).
2411
+ * @param opts - Clock + state dir + optional session id.
2412
+ * @returns `true` to apply the record, `false` to skip (already counted).
2413
+ */
2414
+ function burstFirst(op, kind, opts) {
2415
+ const sid = opts.sessionId?.trim();
2416
+ if (!sid) return true;
2417
+ return oncePerWindow(`oneshot:${sid}:${op}:${kind}`, BURST_DEDUP_MS, {
2418
+ now: opts.now,
2419
+ dir: opts.dir
2420
+ });
2194
2421
  }
2195
- /** True when a block carries a `[TRIGGERS …]` continuation line. */
2196
- function hasTrigger(b) {
2197
- return b.raw.some((l) => TRIG.test(l.trim()));
2422
+ //#endregion
2423
+ //#region src/tracking/one-shot.ts
2424
+ /**
2425
+ * @module one-shot
2426
+ * Sidecar store + gate wiring for the per-gate one-shot metric.
2427
+ *
2428
+ * STATE — a standalone sidecar (`one-shot.json`) in the same per-project state dir
2429
+ * as the session track, mirroring {@link module:deny-loop-store} (atomicWrite,
2430
+ * prune-by-window, fail-safe). A write error NEVER changes a gate decision nor its
2431
+ * prompt — metrics are pure observation.
2432
+ *
2433
+ * KEY — the operation identity is content-FREE (`tool + filePath/command`): a fix
2434
+ * changes the content, so a content hash would make every retry a new op and hide
2435
+ * the deny→allow transition this metric exists to see. The pure model lives in
2436
+ * {@link module:one-shot-store}; this file is the only IO surface.
2437
+ * @packageDocumentation
2438
+ */
2439
+ /** Sidecar basename under the per-project state dir. */
2440
+ const SIDECAR$1 = "one-shot.json";
2441
+ /** Retention window: 7 days. Aggregates and pending denies older than this are pruned. */
2442
+ const WINDOW_MS = 10080 * 60 * 1e3;
2443
+ /** Load the state, or a fresh copy when missing/corrupt. */
2444
+ function loadState(path) {
2445
+ try {
2446
+ if (!existsSync(path)) return { ...EMPTY };
2447
+ const d = JSON.parse(readFileSync(path, "utf8"));
2448
+ return d && typeof d === "object" && !Array.isArray(d) ? {
2449
+ ...EMPTY,
2450
+ ...d
2451
+ } : { ...EMPTY };
2452
+ } catch {
2453
+ return { ...EMPTY };
2454
+ }
2198
2455
  }
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)
2456
+ /**
2457
+ * Record a gate outcome: a `block` is a deny for its gate title; a `null` allow is
2458
+ * a fix (if the op was pending) or a one-shot (if gateable). `ask`/`inform` are
2459
+ * neither and are skipped. Fails silently — a metric write NEVER affects a decision.
2460
+ *
2461
+ * The op key is tool-INDEPENDENT (`filePath`/`command` only, constant `"op"` tool):
2462
+ * a deny (a `Write`) and its fix (an `Edit`) on the same file must link.
2463
+ * @param prompt - The gate's outcome (block, allow=null, or ask/inform).
2464
+ * @param input - Identifying tool input (content decides gateability only).
2465
+ * @param opts - Clock + state dir.
2466
+ */
2467
+ function recordOneShot(prompt, input, opts) {
2468
+ try {
2469
+ if (prompt && prompt.kind !== "block") return;
2470
+ const op = denyHash("op", {
2471
+ filePath: input.filePath,
2472
+ command: input.command
2212
2473
  });
2213
- else if (l.trim() && last) last.raw.push(l);
2474
+ if (!burstFirst(op, prompt ? `deny:${prompt.title}` : "allow", opts)) return;
2475
+ const path = join(opts.dir, SIDECAR$1);
2476
+ let s = pruneState(loadState(path), opts.now, WINDOW_MS);
2477
+ s = prompt ? applyDeny(s, prompt.title, op, opts.now) : applyAllow(s, op, opts.now, input.content != null || input.command != null);
2478
+ atomicWrite(path, JSON.stringify(s));
2479
+ } catch {}
2480
+ }
2481
+ /**
2482
+ * Compact, injection-ready one-shot summary for the project rooted at `cwd`. The
2483
+ * state dir is derived EXACTLY like the runtime writer ({@link defaultStateDir},
2484
+ * mirroring `handle.ts` `trackFile(sid, defaultStateDir(cwd))`), so the file read
2485
+ * here is the same one {@link recordOneShot} wrote. "" when no data or read error.
2486
+ * @param cwd - The project working directory (Claude `cwd`), NOT the state dir.
2487
+ * @returns One line, e.g. `gates 7d: 88% one-shot (44/50 clean); ...`, or "".
2488
+ */
2489
+ function oneShotSummary(cwd) {
2490
+ try {
2491
+ return formatSummary(pruneState(loadState(join(defaultStateDir(cwd), SIDECAR$1)), Date.now(), WINDOW_MS));
2492
+ } catch {
2493
+ return "";
2214
2494
  }
2215
- return {
2216
- preamble,
2217
- blocks
2218
- };
2219
2495
  }
2220
2496
  //#endregion
2221
- //#region src/runtime/lifecycle/aipilot/lesson-archive.ts
2497
+ //#region src/tracking/one-shot-failure.ts
2222
2498
  /**
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.
2499
+ * @module one-shot-failure
2500
+ * PostToolUseFailure tally for the one-shot sidecar. A pure per-tool counter
2501
+ * ({@link applyFailure}) plus its burst-deduped IO writer ({@link recordFailure}),
2502
+ * kept out of {@link module:one-shot-store} so that file stays under the SOLID
2503
+ * size limit. Reuses the SAME sidecar, prune window, and burst-dedup as
2504
+ * {@link module:one-shot} failures never touch the deny/allow gate rate.
2505
+ * @packageDocumentation
2229
2506
  */
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
2507
  /**
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`.
2508
+ * Bump the per-tool failure count. Orthogonal to gates/pending, so it can never
2509
+ * distort the one-shot rate.
2510
+ * @param s - Current state.
2511
+ * @param tool - The failing tool name (`unknown` when absent).
2512
+ * @param now - Clock.
2513
+ * @returns The next state with `failures[tool]` incremented.
2247
2514
  */
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
- }
2515
+ function applyFailure(s, tool, now) {
2263
2516
  return {
2264
- keep: blocks.filter((b) => !toArchive.has(b)),
2265
- archive: blocks.filter((b) => toArchive.has(b))
2517
+ ...s,
2518
+ failures: {
2519
+ ...s.failures ?? {},
2520
+ [tool]: (s.failures?.[tool] ?? 0) + 1
2521
+ },
2522
+ updatedAt: now
2266
2523
  };
2267
2524
  }
2268
2525
  /**
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.
2526
+ * Persist a PostToolUseFailure into the one-shot sidecar, burst-deduped across the
2527
+ * ~11-process fan-out (same window/store as {@link recordOneShot}). Fail-safe: a
2528
+ * write error never propagates out of the hook.
2529
+ * @param tool - The failing tool name.
2530
+ * @param opts - Clock + state dir + optional session id (arms the burst dedup).
2275
2531
  */
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`;
2532
+ function recordFailure(tool, opts) {
2533
+ try {
2534
+ if (!burstFirst(`fail:${tool}`, "failure", opts)) return;
2535
+ const path = join(opts.dir, SIDECAR$1);
2536
+ const next = applyFailure(pruneState(loadState(path), opts.now, WINDOW_MS), tool, opts.now);
2537
+ atomicWrite(path, JSON.stringify(next));
2538
+ } catch {}
2279
2539
  }
2280
2540
  //#endregion
2281
- //#region src/runtime/lifecycle/aipilot/curate-lessons.ts
2541
+ //#region src/runtime/lifecycle/failure-lesson.ts
2282
2542
  /**
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.
2543
+ * @module failure-lesson
2544
+ * PostToolUseFailure handler: keep the failure log, record the failure in the
2545
+ * one-shot metric, and inject the ONE most-specific lesson whose `error:` trigger
2546
+ * matches the failure message reusing the PreToolUse {@link lessonFor} index and
2547
+ * its cooldown (idempotent under the ~11-process fan-out). Fail-open throughout.
2548
+ *
2549
+ * Claude-Code-only: no equivalent `PostToolUseFailure` hook exists on Codex or
2550
+ * Hermes, so this handler is never reached through those adapters.
2551
+ * @packageDocumentation
2288
2552
  */
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(", ")}`];
2553
+ /** The failure message across the documented `error` field and defensive fallbacks; "" when none. */
2554
+ function failureError(data) {
2555
+ const raw = data.error ?? data.tool_error ?? data.tool_output;
2556
+ if (typeof raw === "string") return raw;
2557
+ return raw != null ? JSON.stringify(raw) : "";
2558
+ }
2559
+ /**
2560
+ * Handle PostToolUseFailure: log the failure, tally it per tool, and inject the
2561
+ * matching `error:`-triggered lesson as `additionalContext` ("" when none).
2562
+ * @param data - The raw PostToolUseFailure payload (`tool_name`, `error`, `session_id`).
2563
+ * @param cwd - Project root.
2564
+ * @param home - Home dir (defaults to `~`).
2565
+ * @param now - Clock (defaults to `Date.now()`).
2566
+ * @param once - Cooldown gate (injected for tests; defaults to {@link oncePerWindow}).
2567
+ * @returns The native hook stdout, or "" when nothing to inject.
2568
+ */
2569
+ function failureLessonContext(data, cwd, home = homedir(), now = Date.now(), once = oncePerWindow) {
2570
+ logToolFailure(data, home, now);
2571
+ const tool = typeof data.tool_name === "string" ? data.tool_name : "unknown";
2572
+ const sessionId = typeof data.session_id === "string" ? data.session_id : void 0;
2573
+ try {
2574
+ recordFailure(tool, {
2575
+ now,
2576
+ dir: defaultStateDir(cwd),
2577
+ sessionId
2578
+ });
2579
+ } catch {}
2580
+ const errorMsg = failureError(data);
2581
+ if (!errorMsg) return "";
2582
+ const lesson = lessonFor("", {}, {
2583
+ file: lessonsFileFor(projectRoot(cwd)),
2584
+ once,
2585
+ prevError: errorMsg
2299
2586
  });
2587
+ return lesson?.reason ? contextResponse("PostToolUseFailure", lesson.reason) : "";
2300
2588
  }
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);
2589
+ //#endregion
2590
+ //#region src/runtime/lifecycle/snapshot/git.ts
2591
+ /**
2592
+ * Run a git subcommand at `root` with a short timeout, returning trimmed stdout.
2593
+ * Uses `node:child_process` (the Bun shell can hang on some git plumbing) and
2594
+ * swallows every failure a non-repo, missing git, or timeout yields `""` so
2595
+ * the caller omits the section instead of throwing inside the hook.
2596
+ * @param root - Directory to run git in.
2597
+ * @param args - The git args (e.g. `"log --oneline -3"`).
2598
+ * @returns Trimmed stdout, or `""` on any error.
2599
+ */
2600
+ function git(root, args) {
2601
+ try {
2602
+ return execSync(`git ${args}`, {
2603
+ cwd: root,
2604
+ encoding: "utf8",
2605
+ timeout: 150,
2606
+ stdio: [
2607
+ "ignore",
2608
+ "pipe",
2609
+ "ignore"
2610
+ ]
2611
+ }).trim();
2612
+ } catch {
2613
+ return "";
2614
+ }
2615
+ }
2616
+ /** Count staged/unstaged/untracked files from porcelain v1 output (skips the `##` branch line). */
2617
+ function countWip(porcelain) {
2618
+ const w = {
2619
+ staged: 0,
2620
+ unstaged: 0,
2621
+ untracked: 0
2622
+ };
2623
+ for (const line of porcelain.split("\n")) {
2624
+ if (!line || line.startsWith("#")) continue;
2625
+ if (line.startsWith("??")) {
2626
+ w.untracked++;
2309
2627
  continue;
2310
2628
  }
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);
2629
+ const x = line[0], y = line[1];
2630
+ if (x && x !== " " && x !== "?") w.staged++;
2631
+ if (y === "M" || y === "D") w.unstaged++;
2632
+ }
2633
+ return w;
2634
+ }
2635
+ /** Parse the current branch from the leading `## branch...upstream` porcelain line. */
2636
+ function parseBranch(porcelain) {
2637
+ const head = porcelain.split("\n")[0] ?? "";
2638
+ if (!head.startsWith("## ")) return "";
2639
+ const rest = head.slice(3);
2640
+ const dots = rest.indexOf("...");
2641
+ return (dots >= 0 ? rest.slice(0, dots) : rest).split(" ")[0] ?? "";
2642
+ }
2643
+ /**
2644
+ * Collect a compact git reconciliation section for `root`: current branch, the
2645
+ * last three commits (oneline), and staged/unstaged/untracked WIP counts. When
2646
+ * `root` is not a git repo (status fails) the whole section is omitted (`""`).
2647
+ * @param root - The project/repo root.
2648
+ * @returns The rendered git section body, or `""` when not a repo.
2649
+ */
2650
+ function collectGit(root) {
2651
+ const status = git(root, "status --porcelain=v1 --branch");
2652
+ if (!status) return "";
2653
+ const branch = parseBranch(status) || "(unknown)";
2654
+ const w = countWip(status);
2655
+ const log = git(root, "log --oneline -3");
2656
+ const lines = [`- branch: ${branch}`];
2657
+ if (log) lines.push("- recent:", ...log.split("\n").map((l) => ` ${l}`));
2658
+ lines.push(`- WIP: ${w.staged} staged, ${w.unstaged} unstaged, ${w.untracked} untracked`);
2659
+ return lines.join("\n");
2660
+ }
2661
+ //#endregion
2662
+ //#region src/cli/doctor.ts
2663
+ /**
2664
+ * `harness doctor` — diagnose which `@fusengine/harness` is actually running.
2665
+ *
2666
+ * A confirmed, still-open bun bug (oven-sh/bun #5791; scoped-pkg behaviour
2667
+ * reinforced by #32019/#32150) makes `bunx <pkg>` (unpinned) prefer a stale
2668
+ * GLOBAL install over npm-latest, so a consumer can silently run an old harness
2669
+ * after a publish. This command surfaces the truth: the resolved version +
2670
+ * package path of the code executing right now, the runtime binary, and the
2671
+ * latest version published on npm. It queries the registry over HTTP (not
2672
+ * `npm view`, whose exit code is 0 even on an empty result — npm/cli#6408) and
2673
+ * never throws: an offline environment yields `latest: null`, never a crash.
2674
+ */
2675
+ const PKG = "@fusengine/harness";
2676
+ /** Walk up from `startDir` for the `@fusengine/harness` `package.json`. */
2677
+ function findPackage(startDir) {
2678
+ let dir = startDir;
2679
+ for (let depth = 0; depth < 6; depth++) {
2680
+ try {
2681
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
2682
+ if (pkg.name === PKG) return {
2683
+ version: pkg.version ?? "unknown",
2684
+ path: dir
2685
+ };
2686
+ } catch {}
2687
+ const parent = dirname(dir);
2688
+ if (parent === dir) break;
2689
+ dir = parent;
2690
+ }
2691
+ return null;
2692
+ }
2693
+ /** Resolve the running version + package path (no network), from a module URL. */
2694
+ function runningVersion(moduleUrl) {
2695
+ const found = findPackage(dirname(fileURLToPath(moduleUrl)));
2696
+ return {
2697
+ version: found?.version ?? "unknown",
2698
+ path: found?.path ?? "unknown"
2699
+ };
2700
+ }
2701
+ /** One-line `pkg vX.Y.Z` banner (stderr, only on explicit `--version`/`doctor` commands — never on `hook`, to avoid spamming automated invocations). */
2702
+ function versionBanner(moduleUrl) {
2703
+ return `${PKG} v${runningVersion(moduleUrl).version}`;
2704
+ }
2705
+ /** Latest published version via the npm registry HTTP API. `null` on any failure. */
2706
+ async function npmLatest() {
2707
+ try {
2708
+ const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(8e3) });
2709
+ if (!res.ok) return null;
2710
+ return (await res.json()).version ?? null;
2711
+ } catch {
2712
+ return null;
2713
+ }
2714
+ }
2715
+ /** Build the full diagnostic report for the module at `moduleUrl`. */
2716
+ async function buildDoctorReport(moduleUrl) {
2717
+ const { version, path } = runningVersion(moduleUrl);
2718
+ const latest = await npmLatest();
2719
+ return {
2720
+ running: version,
2721
+ packagePath: path,
2722
+ runtime: process.execPath,
2723
+ latest,
2724
+ stale: latest !== null && latest !== version
2725
+ };
2726
+ }
2727
+ /** Render a {@link DoctorReport} as human-readable stdout text. */
2728
+ function formatDoctor(r) {
2729
+ const lines = [
2730
+ `${PKG} doctor`,
2731
+ ` running: ${r.running}`,
2732
+ ` package: ${r.packagePath}`,
2733
+ ` runtime: ${r.runtime}`,
2734
+ ` npm latest: ${r.latest ?? "(unavailable — offline or unreachable)"}`
2735
+ ];
2736
+ if (r.stale) lines.push(` ! stale — npm serves ${r.latest}. Pin "@fusengine/harness@${r.latest}" in hooks.json (see README).`);
2737
+ else if (r.latest !== null) lines.push(` ok — running the latest published version.`);
2738
+ return lines.join("\n");
2739
+ }
2740
+ /** Run `harness doctor`: print the diagnostic to stdout. Always resolves 0 (pure info). */
2741
+ async function runDoctor(moduleUrl) {
2742
+ process.stdout.write(formatDoctor(await buildDoctorReport(moduleUrl)) + "\n");
2743
+ return 0;
2744
+ }
2745
+ //#endregion
2746
+ //#region src/runtime/lifecycle/snapshot/version.ts
2747
+ /** Read the `version` field of `<root>/package.json`, or `""` if absent/unreadable. */
2748
+ function pkgVersion(root) {
2749
+ try {
2750
+ return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version ?? "";
2751
+ } catch {
2752
+ return "";
2753
+ }
2754
+ }
2755
+ /**
2756
+ * Collect the version reconciliation section: the harness version actually
2757
+ * running (resolved from {@link runningVersion}, no network) and, when `root`
2758
+ * carries its own `package.json`, whether that project's version has drifted
2759
+ * from the running harness.
2760
+ * @param root - The project root (cwd repo).
2761
+ * @param moduleUrl - `import.meta.url` of the calling module (locates the running package.json).
2762
+ * @returns The rendered version section body (never `""`).
2763
+ */
2764
+ function collectVersion(root, moduleUrl) {
2765
+ const running = runningVersion(moduleUrl).version;
2766
+ const lines = [`- harness running: v${running}`];
2767
+ const project = pkgVersion(root);
2768
+ if (project && project !== running) lines.push(`- project package.json: v${project} (DRIFT — running harness differs)`);
2769
+ else if (project) lines.push(`- project package.json: v${project} (in sync)`);
2770
+ return lines.join("\n");
2771
+ }
2772
+ //#endregion
2773
+ //#region src/runtime/lifecycle/snapshot/board.ts
2774
+ /** Max board characters injected — a persistent board should stay small; over-long boards are truncated. */
2775
+ const MAX_BOARD = 4e3;
2776
+ /**
2777
+ * Collect the persistent task board: the contents of `<root>/.claude/BOARD.md`
2778
+ * (truncated to {@link MAX_BOARD}) plus an instruction to keep it current. The
2779
+ * board lives on disk so it survives context purges — rehydrated every session.
2780
+ * Missing/empty/unreadable board → `""` (section omitted).
2781
+ * @param root - The project root.
2782
+ * @returns The rendered board section body, or `""` when there is no board.
2783
+ */
2784
+ function collectBoard(root) {
2785
+ const path = join(root, ".claude", "BOARD.md");
2786
+ try {
2787
+ if (!existsSync(path)) return "";
2788
+ let body = readFileSync(path, "utf8").trim();
2789
+ if (!body) return "";
2790
+ if (body.length > MAX_BOARD) body = `${body.slice(0, MAX_BOARD)}\n… (truncated)`;
2791
+ return `- .claude/BOARD.md (keep current — Write to it as tasks start/finish):\n\n${body}`;
2792
+ } catch {
2793
+ return "";
2794
+ }
2795
+ }
2796
+ //#endregion
2797
+ //#region src/runtime/lifecycle/snapshot/format.ts
2798
+ /**
2799
+ * Render the non-empty `sections` under one reconciliation heading. Empty
2800
+ * sections are dropped; when every section is empty the whole snapshot is `""`.
2801
+ * Each surviving section is passed through {@link capFragment} (harness-produced
2802
+ * content, not owner CLAUDE.md), and a trailing {@link budgetReport} line gives
2803
+ * the owner-requested numeric visibility into what was actually injected.
2804
+ * @param sections - The collected sections in display order.
2805
+ * @returns The assembled markdown block, or `""` when nothing to report.
2806
+ */
2807
+ function renderSections(sections) {
2808
+ const kept = sections.filter((s) => s.body.trim()).map((s) => ({
2809
+ title: s.title,
2810
+ body: capFragment(s.title, s.body.trim())
2811
+ }));
2812
+ if (!kept.length) return "";
2813
+ const header = "# Reconciliation snapshot\nReal state of the world at session start — reconcile against this instead of re-discovering it.";
2814
+ const parts = kept.map((s) => `### ${s.title}\n${s.body}`);
2815
+ const report = budgetReport(kept.map((s) => ({
2816
+ label: s.title,
2817
+ chars: s.body.length
2818
+ })));
2819
+ return `${header}\n\n${parts.join("\n\n")}\n\n_${report}_`;
2820
+ }
2821
+ /**
2822
+ * Concatenate `snapshot` onto an existing SessionStart stdout's
2823
+ * `additionalContext` — it never replaces prior injected context (CLAUDE.md,
2824
+ * dev-context). When `stdout` is empty a fresh {@link contextResponse} is made;
2825
+ * a non-empty but unparseable `stdout` is returned UNCHANGED (the snapshot is
2826
+ * dropped) — fabricating a fresh response there would discard the very CLAUDE.md
2827
+ * injection the invariant protects, so preserving prior context always wins.
2828
+ * @param stdout - The core SessionStart JSON stdout (may be `""`).
2829
+ * @param snapshot - The snapshot markdown to append (no-op when `""`).
2830
+ * @returns The merged hook stdout JSON.
2831
+ */
2832
+ function attachSnapshot(stdout, snapshot) {
2833
+ if (!snapshot) return stdout;
2834
+ if (!stdout) return contextResponse("SessionStart", snapshot);
2835
+ try {
2836
+ const parsed = JSON.parse(stdout);
2837
+ const prev = parsed.hookSpecificOutput?.additionalContext ?? "";
2838
+ const merged = prev ? `${prev}\n\n${snapshot}` : snapshot;
2839
+ return JSON.stringify({
2840
+ ...parsed,
2841
+ hookSpecificOutput: {
2842
+ ...parsed.hookSpecificOutput,
2843
+ hookEventName: "SessionStart",
2844
+ additionalContext: merged
2845
+ }
2846
+ });
2847
+ } catch {
2848
+ return stdout;
2849
+ }
2850
+ }
2851
+ //#endregion
2852
+ //#region src/runtime/lifecycle/snapshot/index.ts
2853
+ /** Run `fn`, swallowing any throw into `""` so no single collector can break the hook. */
2854
+ function safe(fn) {
2855
+ try {
2856
+ return fn();
2857
+ } catch {
2858
+ return "";
2859
+ }
2860
+ }
2861
+ /**
2862
+ * Build the reconciliation snapshot markdown for `cwd`: git state, running
2863
+ * harness version + drift, the persistent board, and one-shot gate status. Each
2864
+ * collector is isolated by {@link safe}; an all-empty result yields `""`.
2865
+ * @param cwd - The session working directory.
2866
+ * @param moduleUrl - `import.meta.url` of the caller (locates the running package).
2867
+ * @returns The snapshot markdown, or `""` when nothing to report.
2868
+ */
2869
+ function renderSnapshot(cwd, moduleUrl) {
2870
+ const root = projectRootOrNull(cwd) ?? cwd;
2871
+ return renderSections([
2872
+ {
2873
+ title: "Git",
2874
+ body: safe(() => collectGit(root))
2875
+ },
2876
+ {
2877
+ title: "Version",
2878
+ body: safe(() => collectVersion(root, moduleUrl))
2879
+ },
2880
+ {
2881
+ title: "Board",
2882
+ body: safe(() => collectBoard(root))
2883
+ },
2884
+ {
2885
+ title: "One-shot gates",
2886
+ body: safe(() => oneShotSummary(cwd))
2316
2887
  }
2317
- fused.push(`merged: kept ${(win.raw[0] ?? "").slice(0, 60)} · dropped ${(drop.raw[0] ?? "").slice(0, 60)}`);
2888
+ ]);
2889
+ }
2890
+ /**
2891
+ * Concatenate the reconciliation snapshot onto a core SessionStart stdout. Fully
2892
+ * fail-safe: any error returns `stdout` unchanged so the hook never breaks.
2893
+ * @param stdout - The core SessionStart JSON stdout (may be `""`).
2894
+ * @param cwd - The session working directory.
2895
+ * @param moduleUrl - `import.meta.url` of the caller.
2896
+ * @returns The merged hook stdout.
2897
+ */
2898
+ function withSnapshot(stdout, cwd, moduleUrl) {
2899
+ try {
2900
+ return attachSnapshot(stdout, renderSnapshot(cwd, moduleUrl));
2901
+ } catch {
2902
+ return stdout;
2903
+ }
2904
+ }
2905
+ //#endregion
2906
+ //#region src/runtime/lifecycle/post-compact.ts
2907
+ /**
2908
+ * @module post-compact
2909
+ * PostCompact handler (core scope): re-inject the reconciliation snapshot plus a
2910
+ * one-line reminder that read-state may have been reset by compaction, so the
2911
+ * agent re-reads files before editing. Deduped per session/window (compaction can
2912
+ * fan out too). Fully fail-open — any error yields "".
2913
+ * @packageDocumentation
2914
+ */
2915
+ /** Re-inject at most once per 30s per session (compaction fan-out + retries). */
2916
+ const COMPACT_DEDUP_MS = 3e4;
2917
+ /** One-line reminder prepended to the re-injected snapshot. */
2918
+ const REMINDER = "Context was compacted — reread files before editing (read-state may be reset).";
2919
+ /**
2920
+ * Handle PostCompact: emit the reminder + reconciliation snapshot as
2921
+ * `additionalContext`, deduped per session. "" on dedup-suppress or any error.
2922
+ * @param data - The raw PostCompact payload (`session_id`, `trigger`).
2923
+ * @param cwd - Project root.
2924
+ * @param moduleUrl - `import.meta.url` of the caller (locates the running package for the version line).
2925
+ * @param now - Clock (defaults to `Date.now()`).
2926
+ * @returns The native hook stdout, or "".
2927
+ */
2928
+ function postCompactContext(data, cwd, moduleUrl, now = Date.now()) {
2929
+ try {
2930
+ if (!oncePerWindow(`postcompact:${typeof data.session_id === "string" ? data.session_id : "unknown"}`, COMPACT_DEDUP_MS, {
2931
+ now,
2932
+ dir: defaultStateDir(cwd)
2933
+ })) return "";
2934
+ const snapshot = renderSnapshot(cwd, moduleUrl);
2935
+ return contextResponse("PostCompact", snapshot ? `${REMINDER}\n\n${snapshot}` : REMINDER);
2936
+ } catch {
2937
+ return "";
2938
+ }
2939
+ }
2940
+ //#endregion
2941
+ //#region src/runtime/lifecycle/task-completed.ts
2942
+ /** Code-file extensions audited on task completion (mirrors validate-task-solid.py). */
2943
+ const CODE_EXTENSIONS$2 = /* @__PURE__ */ new Set([
2944
+ ".ts",
2945
+ ".tsx",
2946
+ ".js",
2947
+ ".jsx",
2948
+ ".py",
2949
+ ".go",
2950
+ ".rs",
2951
+ ".java",
2952
+ ".php",
2953
+ ".cpp",
2954
+ ".c",
2955
+ ".rb",
2956
+ ".swift",
2957
+ ".kt",
2958
+ ".dart",
2959
+ ".vue",
2960
+ ".svelte",
2961
+ ".astro"
2962
+ ]);
2963
+ /** 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. */
2964
+ const RECEIPT_TTL_MULTIPLIER = 5;
2965
+ /** The modified files that are code (by extension) — the receipt gate's trigger set. */
2966
+ function codeFiles(files) {
2967
+ return files.filter((fp) => CODE_EXTENSIONS$2.has(extname(fp)));
2968
+ }
2969
+ /**
2970
+ * Refuse completion when code files changed but no fresh, passing verification
2971
+ * receipt (`tsc`/test, exit 0, zero failures, within TTL×{@link RECEIPT_TTL_MULTIPLIER})
2972
+ * exists in the signed track. TaskCompleted does NOT honor `decision:"block"`
2973
+ * (verified against the official hooks docs — `TeammateIdle/TaskCreated/
2974
+ * TaskCompleted` are excluded from that list); the documented stdout refusal is
2975
+ * `{"continue":false,"stopReason":…}`, which halts the teammate with the reason
2976
+ * shown to the user. Returns that JSON, or `null` when the session is clear.
2977
+ */
2978
+ function receiptGate(sid, files, now, stateDir) {
2979
+ if (codeFiles(files).length === 0) return null;
2980
+ const windowMs = resolveTtlSec(process.env) * 1e3 * RECEIPT_TTL_MULTIPLIER;
2981
+ if (freshReceiptFromFile(trackFile(sid, stateDir), windowMs, now)) return null;
2982
+ return JSON.stringify({
2983
+ continue: false,
2984
+ 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."
2985
+ });
2986
+ }
2987
+ /**
2988
+ * Re-count physical lines of every modified code file and collect SOLID
2989
+ * violations (`<basename>: <n> lines (max <max>)`) for those exceeding `max`.
2990
+ * @param files - Candidate modified file paths.
2991
+ * @param max - The SOLID line ceiling.
2992
+ * @returns The list of violation strings (empty when all files comply).
2993
+ */
2994
+ function collectViolations(files, max) {
2995
+ const violations = [];
2996
+ for (const fp of files) {
2997
+ if (!CODE_EXTENSIONS$2.has(extname(fp)) || !existsSync(fp)) continue;
2998
+ try {
2999
+ const lines = countLines(readFileSync(fp, "utf-8"));
3000
+ if (lines > max) violations.push(`${basename(fp)}: ${lines} lines (max ${max})`);
3001
+ } catch {}
2318
3002
  }
2319
- return {
2320
- kept,
2321
- fused
2322
- };
3003
+ return violations;
2323
3004
  }
2324
3005
  /**
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.
3006
+ * Handle TaskCompleted (ports `task-completed/validate-task-solid.py`, plus the
3007
+ * receipt gate). SOLID violations surface first as `SOLID VIOLATION`
3008
+ * additionalContext; once the files comply, {@link receiptGate} refuses a "done"
3009
+ * that has no fresh passing tsc/test receipt.
3010
+ * @param payload - The TaskCompleted payload (`task_id`, `task_subject`, `session_id`).
3011
+ * @param home - Home dir (defaults to `~`).
3012
+ * @param now - Clock (defaults to `Date.now()`).
3013
+ * @param stateDir - Track base dir (defaults to the cwd-derived state dir; matches `handleHook`).
3014
+ * @returns The native hook stdout, or `""` when the session is clean.
2333
3015
  */
2334
- function curateLessons(content, now, root = process.cwd()) {
2335
- const { preamble, blocks } = parse(content);
2336
- const { kept, fused } = dedup(blocks);
2337
- const { keep, archive } = splitAtCap(kept, now);
2338
- const rebuilt = fused.length > 0 || archive.length > 0 ? `${preamble}\n${keep.map((b) => b.raw.join("\n")).join("\n\n")}\n` : content;
2339
- const capReport = archive.length ? [`${kept.length} bullets (> 50) — ${archive.length} oldest archived → LESSON-archive.md`] : [];
2340
- const report = [
2341
- ...fused,
2342
- ...capReport,
2343
- ...staleReport(blocks, now, root)
2344
- ].join("\n");
2345
- return {
2346
- content: rebuilt,
2347
- archive: formatArchive(archive, now),
2348
- report
2349
- };
3016
+ function validateTaskSolid(payload, home = homedir(), now = Date.now(), stateDir = defaultStateDir(process.cwd())) {
3017
+ const sid = sanitizeSessionId(payload.session_id ?? "unknown");
3018
+ if (!sid) return "";
3019
+ const files = loadSessionState(sid, home).changes?.modifiedFiles ?? [];
3020
+ if (files.length === 0) return "";
3021
+ const max = resolveMaxLines();
3022
+ const violations = collectViolations(files, max);
3023
+ if (violations.length === 0) return receiptGate(sid, files, now, stateDir) ?? "";
3024
+ const taskId = String(payload.task_id ?? "");
3025
+ return contextResponse("TaskCompleted", `SOLID VIOLATION in task '${String(payload.task_subject ?? "")}' (${taskId}): ${violations.length} file(s) exceed ${max} lines: ` + violations.slice(0, 5).join("; "));
2350
3026
  }
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] ?? "";
3027
+ //#endregion
3028
+ //#region src/runtime/lifecycle/cartographer/fs-util.ts
3029
+ /**
3030
+ * Filesystem helpers for the cartographer tree walk. Ports the fs parts of
3031
+ * `describe.py` (file desc) and `write_recursive.py` (children + counts).
3032
+ */
3033
+ /**
3034
+ * Read a file and derive its one-line description (frontmatter / heading /
3035
+ * comment). "" on any error or when nothing is found.
3036
+ * @param filePath - Absolute path to the file.
3037
+ * @returns The description, or "".
3038
+ */
3039
+ function getFileDesc(filePath) {
3040
+ let text = "";
3041
+ try {
3042
+ text = readFileSync(filePath, "utf-8");
3043
+ } catch {
3044
+ return "";
3045
+ }
3046
+ const suffix = extname(filePath);
3047
+ const mdField = suffix === ".md" ? parseField(text, "description") : "";
3048
+ return descFromText(suffix, text, mdField);
2354
3049
  }
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();
3050
+ /**
3051
+ * Recursively count files whose relative path parts are all visible (no leading
3052
+ * "." or "_") and none excluded. Best-effort (partial count on errors).
3053
+ * @param dir - Directory to count under.
3054
+ * @param exclude - Directory/name set to skip.
3055
+ * @returns The file count.
3056
+ */
3057
+ function countFiles(dir, exclude) {
3058
+ let total = 0;
3059
+ try {
3060
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
3061
+ if (e.name.startsWith(".") || e.name.startsWith("_") || exclude.has(e.name)) continue;
3062
+ if (e.isDirectory()) total += countFiles(join(dir, e.name), exclude);
3063
+ else if (e.isFile()) total += 1;
3064
+ }
3065
+ } catch {}
3066
+ return total;
2358
3067
  }
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();
3068
+ /** Absolute children of `source`, split into dirs/files, sorted by full path. */
3069
+ function listChildren(source, exclude) {
3070
+ const dirs = [];
3071
+ const files = [];
3072
+ let entries;
3073
+ try {
3074
+ entries = readdirSync(source, { withFileTypes: true });
3075
+ } catch {
3076
+ return {
3077
+ dirs,
3078
+ files
3079
+ };
3080
+ }
3081
+ for (const e of entries) {
3082
+ if (e.name.startsWith(".") || e.name.startsWith("_") || exclude.has(e.name)) continue;
3083
+ const abs = join(source, e.name);
3084
+ if (e.isDirectory()) dirs.push(abs);
3085
+ else if (e.isFile()) files.push(abs);
3086
+ }
3087
+ return {
3088
+ dirs: dirs.sort(),
3089
+ files: files.sort()
3090
+ };
2362
3091
  }
3092
+ //#endregion
3093
+ //#region src/runtime/lifecycle/cartographer/merge.ts
2363
3094
  /**
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.
3095
+ * Index merge preserves enriched descriptions across regenerations. Ports
3096
+ * `merge_index.py` (merge_lines + .enriched.json sidecar).
2368
3097
  */
2369
- const RULE_ARROW = /\s+→\s+/;
2370
3098
  /**
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.
3099
+ * Load the `.enriched.json` sidecar's `entries` map for an output index.
3100
+ * @param outputIndexPath - Path to the index.md being written.
3101
+ * @returns The path→desc enrichment map (possibly empty).
2379
3102
  */
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);
2388
- }
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}`;
3103
+ function loadEnriched(outputIndexPath) {
3104
+ const sidecar = join(dirname(outputIndexPath), ".enriched.json");
3105
+ try {
3106
+ if (!existsSync(sidecar)) return {};
3107
+ return JSON.parse(readFileSync(sidecar, "utf-8")).entries ?? {};
3108
+ } catch {
3109
+ return {};
3110
+ }
2395
3111
  }
2396
3112
  /**
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.
3113
+ * Merge freshly generated lines with prior descriptions: enriched sidecar wins,
3114
+ * else a longer pre-existing description is preserved.
3115
+ * @param newLines - The freshly generated index lines.
3116
+ * @param outputIndexPath - Path to the existing index.md (if any).
3117
+ * @returns The merged lines.
2403
3118
  */
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");
3119
+ function mergeLines(newLines, outputIndexPath) {
3120
+ const enriched = loadEnriched(outputIndexPath);
3121
+ const existingDescs = {};
3122
+ if (existsSync(outputIndexPath)) try {
3123
+ for (const line of readFileSync(outputIndexPath, "utf-8").split("\n")) {
3124
+ const e = parseEntry(line);
3125
+ if (e) existingDescs[e.path] = e.desc;
3126
+ }
3127
+ } catch {}
3128
+ return newLines.map((line) => {
3129
+ const e = parseEntry(line);
3130
+ if (!e) return line;
3131
+ if (e.path in enriched) return `${e.prefix}[${e.name}](${e.path}) — ${enriched[e.path]}`;
3132
+ const old = existingDescs[e.path] ?? "";
3133
+ if (old.length > e.desc.length) return `${e.prefix}[${e.name}](${e.path}) — ${old}`;
3134
+ return line;
3135
+ });
2410
3136
  }
2411
3137
  //#endregion
2412
- //#region src/runtime/lifecycle/lessons/state.ts
3138
+ //#region src/runtime/lifecycle/cartographer/write-tree.ts
2413
3139
  /**
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`).
3140
+ * Recursive index.md tree writer. Ports `write_recursive.py`.
2419
3141
  */
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");
3142
+ /**
3143
+ * Write `index.md` files mirroring `source` under `output`, recursing into
3144
+ * subdirectories. Directory lines carry a file-count hint; file lines carry a
3145
+ * derived description and link to the real absolute source path.
3146
+ * @param source - Absolute source directory.
3147
+ * @param output - Absolute output directory for the index tree.
3148
+ * @param back - Relative `← back` link target ("" at the root).
3149
+ * @param exclude - Directory/name set to skip.
3150
+ */
3151
+ function writeTree(source, output, back = "", exclude) {
3152
+ const ex = exclude ?? /* @__PURE__ */ new Set();
3153
+ mkdirSync(output, { recursive: true });
3154
+ const { dirs, files } = listChildren(source, ex);
3155
+ const lines = [`# ${basename(source)}\n`];
3156
+ if (back) lines.push(`> [← back](${back})\n`);
3157
+ const total = dirs.length + files.length;
3158
+ let idx = 0;
3159
+ for (const d of dirs) {
3160
+ idx += 1;
3161
+ const conn = idx === total ? "└──" : "├──";
3162
+ const count = countFiles(d, ex);
3163
+ const hint = count ? ` — ${count} files` : "";
3164
+ lines.push(`${conn} [${basename(d)}/](./${basename(d)}/index.md)${hint}`);
3165
+ writeTree(d, join(output, basename(d)), "../index.md", exclude);
3166
+ }
3167
+ for (const f of files) {
3168
+ idx += 1;
3169
+ const conn = idx === total ? "└──" : "├──";
3170
+ const desc = getFileDesc(f);
3171
+ const suffix = desc ? ` — ${desc}` : "";
3172
+ lines.push(`${conn} [${basename(f)}](${f})${suffix}`);
3173
+ }
3174
+ const indexPath = join(output, "index.md");
3175
+ writeFileSync(indexPath, mergeLines(lines, indexPath).join("\n") + "\n", "utf-8");
2431
3176
  }
2432
3177
  //#endregion
2433
- //#region src/memory/session-roots.ts
3178
+ //#region src/runtime/lifecycle/cartographer/project-map.ts
2434
3179
  /**
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).
3180
+ * Project map generation. Ports `generate_project_map.py` (project map only).
2443
3181
  */
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;
2451
- }
2452
- /** Read the registry; missing/corrupt/legacy (array) shapes collapse to `{}`. */
2453
- function read(home) {
2454
- const f = file(home);
2455
- if (!f) return {};
3182
+ /** True when `dir` is a real directory. */
3183
+ function isDirectory(dir) {
2456
3184
  try {
2457
- const parsed = JSON.parse(readFileSync(f, "utf8"));
2458
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3185
+ return statSync(dir).isDirectory();
2459
3186
  } catch {
2460
- return {};
3187
+ return false;
2461
3188
  }
2462
3189
  }
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];
2468
- try {
2469
- mkdirSync(dirname(f), { recursive: true });
2470
- atomicWrite(f, JSON.stringify(reg));
2471
- } catch {}
2472
- }
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);
3190
+ /**
3191
+ * True when `dir` looks like a project root (has an indicator file) and is not
3192
+ * the home directory or filesystem root.
3193
+ * @param dir - Directory to test.
3194
+ * @returns Whether `dir` is a project root.
3195
+ */
3196
+ function isProject(dir) {
3197
+ const resolved = resolve(dir);
3198
+ if (resolved === resolve(homedir()) || resolved === "/") return false;
3199
+ for (const f of PROJECT_INDICATORS) if (existsSync(join(dir, f))) return true;
3200
+ return false;
2492
3201
  }
2493
3202
  /**
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`.
3203
+ * Generate the `.cartographer/project` index tree for `cwd` when it is a real
3204
+ * project directory. Always returns "" (no additionalContext emitted).
3205
+ * @param cwd - The working directory.
3206
+ * @param outputDir - Override for the output tree root.
3207
+ * @returns "" (side-effect only).
2497
3208
  */
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
- };
2511
- }
2512
- if (pending.length > 0) write(home, reg, now);
2513
- return pending;
3209
+ function generateProjectMap(cwd, outputDir) {
3210
+ const projectDir = resolve(cwd);
3211
+ const out = outputDir ?? join(projectDir, ".cartographer", "project");
3212
+ if (!isDirectory(projectDir)) return "";
3213
+ if (!isProject(projectDir)) return "";
3214
+ writeTree(projectDir, out, "", EXCLUDE_DIRS$1);
3215
+ return "";
2514
3216
  }
2515
3217
  //#endregion
2516
- //#region src/runtime/lifecycle/lessons/reminder.ts
3218
+ //#region src/policy/cartographer/build-tree.ts
3219
+ const SECTION_ORDER = [
3220
+ "agent",
3221
+ "skill",
3222
+ "command"
3223
+ ];
2517
3224
  /**
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.
3225
+ * Format grouped items with tree connectors and optional markdown links.
3226
+ * Skill sections link to `./skills/<name>/index.md`; other sections to
3227
+ * `./<folder>/<name>.md`; unlinked sections render the bare name.
3228
+ * @param prefix - The line prefix (indent + branch glyphs).
3229
+ * @param items - The `[name, desc]` pairs to render.
3230
+ * @param folder - The link folder ("" disables linking).
3231
+ * @param asDirs - Whether items link to a subdirectory `index.md`.
3232
+ * @returns The rendered lines.
2527
3233
  */
2528
- /** Sanitized session id from a raw hook payload, or null (→ legacy fallback). */
2529
- function sessionOf(payload) {
2530
- return sanitizeSessionId(payload.session_id);
3234
+ function printItems(prefix, items, folder, asDirs) {
3235
+ return items.map(([name, desc], i) => {
3236
+ const connector = i === items.length - 1 ? "└──" : "├──";
3237
+ const safe = name.replace(/^\/+/, "");
3238
+ let label = name;
3239
+ if (folder && asDirs) label = `[${name}](./${folder}/${safe}/index.md)`;
3240
+ else if (folder) label = `[${name}](./${folder}/${safe}.md)`;
3241
+ const short = desc && desc !== "(no description)" ? ` — ${desc.slice(0, 80)}` : "";
3242
+ return `${prefix}${connector} ${label}${short}`;
3243
+ });
2531
3244
  }
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);
3245
+ /**
3246
+ * Build an indented tree from scanned items. The `hooks` row renders as a single
3247
+ * trailing `└── hooks: …` line; agents/skills/commands render as folder sections.
3248
+ * @param items - The scanned `[type, name, desc]` rows.
3249
+ * @param linked - When true, leaf names become markdown links.
3250
+ * @returns The joined tree text.
3251
+ */
3252
+ function buildTree(items, linked = false) {
3253
+ const groups = {};
3254
+ let hooksLine = "";
3255
+ for (const [typ, name, desc] of items) if (typ === "hooks") hooksLine = name;
3256
+ else (groups[typ] ??= []).push([name, desc]);
3257
+ const sections = SECTION_ORDER.filter((s) => s in groups);
3258
+ if (hooksLine) sections.push("hooks");
3259
+ const lines = [];
3260
+ const total = sections.length;
3261
+ for (let idx = 0; idx < total; idx++) {
3262
+ const section = sections[idx] ?? "";
3263
+ if (section === "hooks") {
3264
+ lines.push(`└── hooks: ${hooksLine}`);
3265
+ continue;
3266
+ }
3267
+ const isLast = idx === total - 1;
3268
+ const folder = `${section}s`;
3269
+ const prefix = isLast ? "└──" : "├──";
3270
+ const subPrefix = isLast ? " " : "│ ";
3271
+ lines.push(`${prefix} ${folder}/`);
3272
+ const linkFolder = linked ? folder : "";
3273
+ const isDirSection = section === "skill";
3274
+ lines.push(...printItems(subPrefix, groups[section] ?? [], linkFolder, linked && isDirSection));
2542
3275
  }
2543
- return pending;
2544
- }
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")}`;
3276
+ return lines.join("\n");
2548
3277
  }
3278
+ //#endregion
3279
+ //#region src/runtime/lifecycle/cartographer/write-plugin-map.ts
2549
3280
  /**
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.
3281
+ * Per-plugin map writer (fs). Ports `write_plugin_map.py`: writes a level-2
3282
+ * `<plugin>/index.md` (indented linked tree) then recurses agents/skills/
3283
+ * commands into deeper index trees. Reuses `buildTree`, `mergeLines`, `writeTree`.
2554
3284
  */
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));
3285
+ /** True when `dir` is a real directory. */
3286
+ function isDir(dir) {
3287
+ try {
3288
+ return statSync(dir).isDirectory();
3289
+ } catch {
3290
+ return false;
3291
+ }
2561
3292
  }
2562
3293
  /**
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.
3294
+ * Write `<outputDir>/<pluginName>/index.md` (indented linked tree) and recurse
3295
+ * agents/skills/commands into their own index trees rooted there.
3296
+ * @param outputDir - The map root directory.
3297
+ * @param pluginName - Display name of the plugin (the index subfolder).
3298
+ * @param version - Plugin version ("" to omit).
3299
+ * @param items - The scanned `[type, name, desc]` rows.
3300
+ * @param pluginPath - Absolute source plugin directory (for recursion).
2568
3301
  */
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);
3302
+ function writePluginMap(outputDir, pluginName, version, items, pluginPath) {
3303
+ const pluginDir = join(outputDir, pluginName);
3304
+ mkdirSync(pluginDir, { recursive: true });
3305
+ const newLines = `# ${pluginName}${version ? ` (v${version})` : ""}\n\n${items.length ? buildTree(items, true) : "└── (empty)"}`.split("\n");
3306
+ const indexPath = join(pluginDir, "index.md");
3307
+ writeFileSync(indexPath, mergeLines(newLines, indexPath).join("\n") + "\n", "utf-8");
3308
+ for (const section of [
3309
+ "agents",
3310
+ "skills",
3311
+ "commands"
3312
+ ]) {
3313
+ const src = join(pluginPath, section);
3314
+ if (isDir(src)) writeTree(src, join(pluginDir, section), "../index.md");
2583
3315
  }
2584
3316
  }
2585
3317
  //#endregion
2586
- //#region src/runtime/lifecycle/lessons/dispatch.ts
2587
- /**
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.
2595
- */
3318
+ //#region src/runtime/lifecycle/cartographer/ecosystem-map.ts
2596
3319
  /**
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.
3320
+ * Ecosystem (plugin) map generation (fs). Ports `generate_map.py`: scans every
3321
+ * installed plugin into a level-1 `.cartographer/index.md` + per-plugin level-2+
3322
+ * trees, preserving enriched descriptions. Reuses `findMarketplacePlugins`,
3323
+ * `readPluginMeta`, `scanPlugin`, `mergeLines`, `writePluginMap`.
2602
3324
  */
2603
- function persistCuration(file, root, curated, archive, original) {
3325
+ function pluginDirs(dir) {
3326
+ let entries = [];
2604
3327
  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;
3328
+ entries = readdirSync(dir);
2612
3329
  } catch {
2613
- return original;
3330
+ return [];
2614
3331
  }
3332
+ return entries.filter((n) => !n.startsWith("_") && !n.startsWith(".")).filter((n) => {
3333
+ try {
3334
+ return statSync(join(dir, n)).isDirectory();
3335
+ } catch {
3336
+ return false;
3337
+ }
3338
+ }).sort((a, b) => a.localeCompare(b, "en"));
3339
+ }
3340
+ function utcStamp(now) {
3341
+ return new Date(now).toISOString().slice(0, 16).replace("T", " ");
2615
3342
  }
2616
3343
  /**
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.
3344
+ * Generate the plugin ecosystem map under `<pluginsDir>/.cartographer`.
3345
+ * @param now - Clock for the banner timestamp.
3346
+ * @param pluginsDirOverride - Override for the marketplace plugins directory.
3347
+ * @returns The map navigation context, or "".
2621
3348
  */
2622
- function injectMemory(cwd, event, now) {
2623
- const root = projectRoot(cwd);
2624
- const file = lessonsFileFor(root);
2625
- if (!existsSync(file)) return "";
2626
- let content = "";
3349
+ function generateEcosystemMap(now, pluginsDirOverride) {
3350
+ const pluginsDir = resolve(pluginsDirOverride ?? findMarketplacePlugins());
2627
3351
  try {
2628
- content = readFileSync(file, "utf-8").trim();
3352
+ if (!statSync(pluginsDir).isDirectory()) return "";
2629
3353
  } catch {
2630
3354
  return "";
2631
3355
  }
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);
2637
- }
2638
- /**
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).
2647
- */
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 "";
3356
+ const outputDir = join(pluginsDir, ".cartographer");
3357
+ mkdirSync(outputDir, { recursive: true });
3358
+ const dirs = pluginDirs(pluginsDir);
3359
+ const lines = [`# Ecosystem Map (${dirs.length} plugins)\n`, `> Auto-generated by cartographer${utcStamp(now)}\n`];
3360
+ for (const name of dirs) {
3361
+ const pluginPath = join(pluginsDir, name);
3362
+ const [version, pkgName] = readPluginMeta(pluginPath);
3363
+ const display = pkgName || name;
3364
+ const items = scanPlugin(pluginPath);
3365
+ const agents = items.filter(([t]) => t === "agent").map(([, n]) => n);
3366
+ const ver = version ? ` (v${version})` : "";
3367
+ lines.push(`- [${display}](./${display}/index.md)${ver} ${agents.length ? agents.join(", ") : "(no agents)"}`);
3368
+ writePluginMap(outputDir, display, version, items, pluginPath);
3369
+ writePluginMap(pluginPath, ".cartographer", version, items, pluginPath);
2657
3370
  }
3371
+ const indexPath = join(outputDir, "index.md");
3372
+ writeFileSync(indexPath, mergeLines(lines, indexPath).join("\n") + "\n", "utf-8");
3373
+ 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.`;
2658
3374
  }
2659
3375
  //#endregion
2660
- //#region src/policy/deny-loop.ts
2661
- /**
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
2673
- */
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
- }
3376
+ //#region src/runtime/lifecycle/cartographer/session-start.ts
2681
3377
  /**
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.
3378
+ * Cartographer SessionStart handler. Ports BOTH halves of the Python maps:
3379
+ * `generate_project_map.py` (regenerate `.cartographer/project`) and
3380
+ * `generate_map.py` (regenerate the plugin ecosystem map), emitting the
3381
+ * navigation context from the latter as additionalContext.
2687
3382
  */
2688
- function denyHash(tool, input) {
2689
- return hashText(`${tool}\n${stableStringify(input)}`);
2690
- }
2691
3383
  /**
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? }`.
3384
+ * Resolve the marketplace plugins dir from `CLAUDE_PLUGIN_ROOT`, mirroring the
3385
+ * Python hook which passes `${CLAUDE_PLUGIN_ROOT}/..` to `generate_map.py`.
3386
+ * @returns The plugins dir (env `/..`), or `undefined` to fall back to auto-detect.
2705
3387
  */
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
- };
3388
+ function pluginsDirFromEnv() {
3389
+ const root = process.env.CLAUDE_PLUGIN_ROOT;
3390
+ return root ? resolve(root, "..") : void 0;
2725
3391
  }
2726
3392
  /**
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.
3393
+ * Regenerate the project map + plugin ecosystem map for `cwd` on SessionStart.
3394
+ * Emits the ecosystem navigation context as additionalContext (or "").
3395
+ * @param cwd - The working directory.
3396
+ * @param now - Clock for the ecosystem map banner timestamp.
3397
+ * @returns The SessionStart additionalContext response, or "".
2733
3398
  */
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
- };
3399
+ function cartoSessionStart(cwd, now = Date.now()) {
3400
+ generateProjectMap(cwd);
3401
+ const ctx = generateEcosystemMap(now, pluginsDirFromEnv());
3402
+ return ctx ? contextResponse("SessionStart", ctx) : "";
2743
3403
  }
2744
3404
  //#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
- /**
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.
2757
- */
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;
3405
+ //#region src/runtime/lifecycle/aipilot/lesson-parse.ts
3406
+ /** Milliseconds in a day. */
3407
+ const DAY_MS = 864e5;
3408
+ /** Case-sensitive decision-time tag line (`[TRIGGERS …]`) — opus-lessons format. */
3409
+ const TRIG = /^\[TRIGGERS\s+.+\]$/;
3410
+ /** Epoch ms for a `[YYYY-MM-DD HH:MM]` stamp; `NaN` if absent or out of range. */
3411
+ function parseTs$1(line) {
3412
+ const m = line.match(/\[(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/);
3413
+ if (!m) return NaN;
3414
+ const mo = +(m[2] ?? 0), d = +(m[3] ?? 0);
3415
+ if (mo < 1 || mo > 12 || d < 1 || d > 31) return NaN;
3416
+ return Date.UTC(+(m[1] ?? 0), mo - 1, d, +(m[4] ?? 0), +(m[5] ?? 0));
3417
+ }
3418
+ /** Content words (>=4 chars), timestamp & TRIGGERS marker stripped. */
3419
+ function tokenize(text) {
3420
+ 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));
3421
+ }
3422
+ /** Jaccard overlap of two token sets (0 when both empty). */
3423
+ function jaccard(a, b) {
3424
+ if (a.size === 0 && b.size === 0) return 0;
3425
+ const inter = [...a].filter((t) => b.has(t)).length;
3426
+ return inter / (a.size + b.size - inter);
3427
+ }
3428
+ /** Repo-relative cited paths (slash + extension) referenced in a block. */
3429
+ function citedPaths(text) {
3430
+ const out = /* @__PURE__ */ new Set();
3431
+ for (const m of text.matchAll(/`([^`]+)`/g)) if (m[1]) out.add(m[1]);
3432
+ for (const m of text.matchAll(/[\w./@-]+\.\w{1,5}/g)) if (m[0]) out.add(m[0]);
3433
+ return [...out].filter((p) => p.includes("/") && /\.\w{1,5}$/.test(p));
3434
+ }
3435
+ /** True when a block carries a `[TRIGGERS …]` continuation line. */
3436
+ function hasTrigger(b) {
3437
+ return b.raw.some((l) => TRIG.test(l.trim()));
3438
+ }
3439
+ /** Split content into a verbatim preamble and one Block per `- ` bullet. */
3440
+ function parse(content) {
3441
+ const lines = content.split("\n");
3442
+ const blocks = [];
3443
+ let i = 0;
3444
+ while (i < lines.length && !/^-\s/.test(lines[i] ?? "")) i++;
3445
+ const preamble = lines.slice(0, i).join("\n");
3446
+ for (; i < lines.length; i++) {
3447
+ const l = lines[i] ?? "", last = blocks[blocks.length - 1];
3448
+ if (/^-\s/.test(l)) blocks.push({
3449
+ raw: [l],
3450
+ ts: parseTs$1(l),
3451
+ tokens: tokenize(l)
3452
+ });
3453
+ else if (l.trim() && last) last.raw.push(l);
3454
+ }
2764
3455
  return {
2765
- ...s,
2766
- gates,
2767
- pending
3456
+ preamble,
3457
+ blocks
2768
3458
  };
2769
3459
  }
3460
+ //#endregion
3461
+ //#region src/runtime/lifecycle/aipilot/lesson-archive.ts
2770
3462
  /**
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.
3463
+ * Stage 1 cap→archive split for LESSON.md. When deduped bullets exceed CAP the
3464
+ * OLDEST excess is MOVED (never deleted) to LESSON-archive.md, EXCEPT a
3465
+ * `[TRIGGERS …]` bullet younger than STALE_DAYS: archiving it would blind the
3466
+ * PreToolUse trigger index (src/policy/lessons/trigger-index reads LESSON.md), so
3467
+ * it stays even past the cap. Pure: this module decides the partition and renders
3468
+ * the archive block; the fail-safe, archive-first file write is the caller's job.
2773
3469
  */
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
- };
3470
+ /** Sort key: undated bullets sort oldest, so malformed entries archive first. */
3471
+ function age(b) {
3472
+ return Number.isNaN(b.ts) ? -Infinity : b.ts;
3473
+ }
3474
+ /** A TRIGGERS bullet is protected from archival until older than STALE_DAYS. */
3475
+ function isProtected(b, staleBefore) {
3476
+ return hasTrigger(b) && !(b.ts <= staleBefore);
2799
3477
  }
2800
3478
  /**
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).
3479
+ * Partition deduped `blocks` (newest-first file order) into the bullets that
3480
+ * stay in LESSON.md and the oldest excess to archive. Archives only enough to
3481
+ * reach CAP, skipping protected TRIGGERS bullets (so the file MAY stay slightly
3482
+ * over cap by design). Order is preserved in both halves; `keep ∪ archive` is
3483
+ * exactly `blocks` with no loss and no mutation.
3484
+ * @param blocks - Deduped bullets, newest first.
3485
+ * @param now - Clock (ms) for the STALE_DAYS protection window.
3486
+ * @returns `{ keep, archive }` — a lossless partition of `blocks`.
2805
3487
  */
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
- };
3488
+ function splitAtCap(blocks, now) {
3489
+ if (blocks.length <= 50) return {
3490
+ keep: blocks,
3491
+ archive: []
3492
+ };
3493
+ const staleBefore = now - 90 * DAY_MS;
3494
+ const oldestFirst = [...blocks].sort((a, b) => age(a) - age(b));
3495
+ const toArchive = /* @__PURE__ */ new Set();
3496
+ let excess = blocks.length - 50;
3497
+ for (const b of oldestFirst) {
3498
+ if (excess <= 0) break;
3499
+ if (isProtected(b, staleBefore)) continue;
3500
+ toArchive.add(b);
3501
+ excess--;
2830
3502
  }
2831
- return gateable ? {
2832
- ...s,
2833
- firstTry: s.firstTry + 1,
2834
- updatedAt: now
2835
- } : s;
3503
+ return {
3504
+ keep: blocks.filter((b) => !toArchive.has(b)),
3505
+ archive: blocks.filter((b) => toArchive.has(b))
3506
+ };
2836
3507
  }
2837
3508
  /**
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`.
3509
+ * Render `archive` bullets as a dated block to PREPEND to LESSON-archive.md
3510
+ * (newest archive session on top). Bullets are emitted BYTE-IDENTICAL (raw lines
3511
+ * rejoined) — zero mutation, so the move stays reversible/auditable.
3512
+ * @param archive - Bullets chosen by {@link splitAtCap}.
3513
+ * @param now - Clock (ms) for the archival header date.
3514
+ * @returns The block text (trailing newline), or "" when nothing is archived.
2840
3515
  */
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("; ")}` : ""}`;
3516
+ function formatArchive(archive, now) {
3517
+ if (archive.length === 0) return "";
3518
+ 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`;
2851
3519
  }
2852
3520
  //#endregion
2853
- //#region src/tracking/one-shot-dedup.ts
2854
- /**
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
2867
- */
3521
+ //#region src/runtime/lifecycle/aipilot/curate-lessons.ts
2868
3522
  /**
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).
3523
+ * Stage-0 mechanical, LLM-free dedup of MEMORY/LESSON.md bullets + cap→archive
3524
+ * orchestration. Strict-dedup near-identical bullets (keep newest, `[TRIGGERS …]`
3525
+ * preserved), then hand the deduped set to lesson-archive's cap split. Returns the
3526
+ * rewritten LESSON.md content, the archive block to move out, and a human report.
3527
+ * Pure: all file I/O (archive-first, fail-safe) lives in the dispatch caller.
2876
3528
  */
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
3529
+ const SIM_THRESHOLD = .8;
3530
+ const MIN_TOKENS = 4;
3531
+ /** Report lines for bullets older than STALE_DAYS whose only cited path is gone. */
3532
+ function staleReport(blocks, now, root) {
3533
+ const cutoff = now - 90 * DAY_MS;
3534
+ return blocks.flatMap((b) => {
3535
+ if (!(b.ts <= cutoff)) return [];
3536
+ const paths = citedPaths(b.raw.join(" "));
3537
+ if (paths.length === 0 || paths.some((p) => existsSync(join(root, p)))) return [];
3538
+ return [`[STALE?] ${(b.raw[0] ?? "").slice(0, 90)} — missing path(s): ${paths.join(", ")}`];
2883
3539
  });
2884
3540
  }
2885
- //#endregion
2886
- //#region src/tracking/one-shot.ts
2887
- /**
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
2901
- */
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) {
2908
- 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 };
2915
- } catch {
2916
- return { ...EMPTY };
3541
+ /** Strict-dedup: keep the newest of each near-identical pair (TRIGGERS carried over). Returns kept blocks + merge report lines. */
3542
+ function dedup(blocks) {
3543
+ const kept = [];
3544
+ const fused = [];
3545
+ for (const b of blocks) {
3546
+ const hit = b.tokens.size >= MIN_TOKENS ? kept.find((k) => k.tokens.size >= MIN_TOKENS && jaccard(k.tokens, b.tokens) >= SIM_THRESHOLD) : void 0;
3547
+ if (!hit) {
3548
+ kept.push(b);
3549
+ continue;
3550
+ }
3551
+ const [win, drop] = b.ts > hit.ts || Number.isNaN(hit.ts) ? [b, hit] : [hit, b];
3552
+ if (win !== hit) kept[kept.indexOf(hit)] = win;
3553
+ if (!win.raw.some((l) => TRIG.test(l.trim()))) {
3554
+ const t = drop.raw.find((l) => TRIG.test(l.trim()));
3555
+ if (t) win.raw.push(t);
3556
+ }
3557
+ fused.push(`merged: kept ${(win.raw[0] ?? "").slice(0, 60)} · dropped ${(drop.raw[0] ?? "").slice(0, 60)}`);
2917
3558
  }
3559
+ return {
3560
+ kept,
3561
+ fused
3562
+ };
2918
3563
  }
2919
3564
  /**
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.
2929
- */
2930
- function recordOneShot(prompt, input, opts) {
2931
- try {
2932
- if (prompt && prompt.kind !== "block") return;
2933
- const op = denyHash("op", {
2934
- filePath: input.filePath,
2935
- command: input.command
2936
- });
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 {}
2943
- }
2944
- /**
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 "".
3565
+ * Dedup LESSON.md bullets, then archive the oldest excess over CAP (via
3566
+ * lesson-archive). `content` is byte-identical to the input when nothing is
3567
+ * deduped or archived. The `archive` block (possibly "") is what the caller must
3568
+ * PREPEND to LESSON-archive.md, archive-first, before writing `content`.
3569
+ * @param content - Raw LESSON.md text.
3570
+ * @param now - Clock (ms) for stale/archival windows.
3571
+ * @param root - Project root, for resolving cited paths in the stale report.
3572
+ * @returns The rewritten content, the archive block, and the report.
2951
3573
  */
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
- }
3574
+ function curateLessons(content, now, root = process.cwd()) {
3575
+ const { preamble, blocks } = parse(content);
3576
+ const { kept, fused } = dedup(blocks);
3577
+ const { keep, archive } = splitAtCap(kept, now);
3578
+ const rebuilt = fused.length > 0 || archive.length > 0 ? `${preamble}\n${keep.map((b) => b.raw.join("\n")).join("\n\n")}\n` : content;
3579
+ const capReport = archive.length ? [`${kept.length} bullets (> 50) — ${archive.length} oldest archived → LESSON-archive.md`] : [];
3580
+ const report = [
3581
+ ...fused,
3582
+ ...capReport,
3583
+ ...staleReport(blocks, now, root)
3584
+ ].join("\n");
3585
+ return {
3586
+ content: rebuilt,
3587
+ archive: formatArchive(archive, now),
3588
+ report
3589
+ };
3590
+ }
3591
+ /** The `[YYYY-MM-DD HH:MM]` (or date-only) stamp of a bullet, "" if absent. */
3592
+ function stamp(block) {
3593
+ return (block.raw[0] ?? "").match(/\[(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)\]/)?.[1] ?? "";
3594
+ }
3595
+ /** Bullet text: raw lines joined, leading "- ", date stamp & TRIGGERS lines stripped. */
3596
+ function bodyText(block) {
3597
+ return block.raw.filter((l) => !/^\s*\[TRIGGERS\s/.test(l)).join(" ").replace(/^-\s*/, "").replace(/\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*/, "").trim();
3598
+ }
3599
+ /** First sentence of `s` (split on a period + whitespace), whole string if none. */
3600
+ function firstSentence(s) {
3601
+ return (s.split(/(?<=\.)\s/)[0] ?? s).trim();
2958
3602
  }
2959
- //#endregion
2960
- //#region src/runtime/lifecycle/snapshot/git.ts
2961
3603
  /**
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.
3604
+ * The bullet's `narrative rule` delimiter: a SPACED arrow only. A GLUED arrow
3605
+ * between tokens (e.g. `120s→300s`, `s→3`) is prose the author wrote, never a
3606
+ * delimiter matching on `→` alone chopped rules mid-token (bug: `300s) pensant
3607
+ * corriger…`). Also used to split rule-internal clauses.
2969
3608
  */
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
- }
2985
- }
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++;
2997
- continue;
2998
- }
2999
- const x = line[0], y = line[1];
3000
- if (x && x !== " " && x !== "?") w.staged++;
3001
- if (y === "M" || y === "D") w.unstaged++;
3002
- }
3003
- return w;
3609
+ const RULE_ARROW = /\s+→\s+/;
3610
+ /**
3611
+ * Distil the actionable rule from a bullet body. With no spaced arrow the whole
3612
+ * bullet is the rule → its first sentence. Otherwise the rule is everything after
3613
+ * the FIRST spaced arrow; its spaced-arrow-delimited segments are kept whole
3614
+ * except for TRAILING short asides (< {@link MIN_RULE} chars, e.g. `→ (cf.
3615
+ * lecture).`) which are dropped — so an arrow used as PROSE inside a rule (`maps
3616
+ * X → Y doit…`) is preserved intact rather than chopped at the arrow. When the
3617
+ * kept rule is still under {@link MIN_RULE} chars, fall back to the first sentence
3618
+ * of the WHOLE rule part (never the narrative), avoiding an illegible stub.
3619
+ */
3620
+ function distillRule(text) {
3621
+ const sep = text.search(RULE_ARROW);
3622
+ if (sep < 0) return firstSentence(text);
3623
+ const rulePart = text.slice(sep).replace(RULE_ARROW, "").trim();
3624
+ const segments = rulePart.split(RULE_ARROW).map((s) => s.trim()).filter(Boolean);
3625
+ while (segments.length > 1 && (segments[segments.length - 1]?.length ?? 0) < 40) segments.pop();
3626
+ const rule = firstSentence(segments.join(" → "));
3627
+ return rule.length >= 40 ? rule : firstSentence(rulePart);
3004
3628
  }
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] ?? "";
3629
+ /** Collapse one older bullet to `- [date] <rule>`: {@link distillRule}, capped. */
3630
+ function compressBullet(block) {
3631
+ let rule = distillRule(bodyText(block));
3632
+ if (rule.length > 200) rule = `${rule.slice(0, 199).trimEnd()}…`;
3633
+ const date = stamp(block);
3634
+ return `- ${date ? `[${date}] ` : ""}${rule}`;
3012
3635
  }
3013
3636
  /**
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.
3637
+ * Build the compressed injection body for `content`. The preamble comments are
3638
+ * dropped (format docs, noise for the reader); the `recentFull` newest bullets
3639
+ * stay whole, every older bullet becomes one distilled rule-line.
3640
+ * @param content - Raw LESSON.md text.
3641
+ * @param recentFull - Count of newest bullets to keep verbatim.
3642
+ * @returns The compressed block (bullets only), or the trimmed content when there are no bullets.
3019
3643
  */
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");
3644
+ function compressInjection(content, recentFull = 10) {
3645
+ const { blocks } = parse(content);
3646
+ if (blocks.length === 0) return content.trim();
3647
+ const full = blocks.slice(0, recentFull).map((b) => b.raw.join("\n"));
3648
+ const rest = blocks.slice(recentFull).map(compressBullet);
3649
+ return [...full, ...rest].join("\n");
3030
3650
  }
3031
3651
  //#endregion
3032
- //#region src/cli/doctor.ts
3652
+ //#region src/memory/session-roots.ts
3033
3653
  /**
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.
3654
+ * Session-scoped lessons roots registry. The flat {@link module:memory/registry}
3655
+ * keeps ONE global list of pending roots — correct mono-session, but wrong with
3656
+ * several concurrent Claude Code sessions: at Stop, one session lists (and, by
3657
+ * bumping the throttle, STEALS) another session's pending lesson on a project it
3658
+ * never touched. This registry keys "which project got code edits, and was its
3659
+ * Stop reminder already fired" by `session_id`, so each Stop sees and consumes
3660
+ * ONLY its own roots. Stored at `$HOME/.fuse-harness/cache/lessons/session-roots.json`;
3661
+ * non-fatal on any I/O failure (a missed reminder never blocks a session).
3044
3662
  */
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;
3062
- }
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
- };
3070
- }
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}`;
3663
+ /** Registry path (rel. home) + stale-bucket purge horizon (bounds growth). */
3664
+ const SUBPATH = ".fuse-harness/cache/lessons/session-roots.json";
3665
+ const PURGE_MS = 10080 * 60 * 1e3;
3666
+ /** Absolute registry path, or null when home is unusable. */
3667
+ function file(home) {
3668
+ const h = home?.trim();
3669
+ return h && h.startsWith("/") ? `${h}/${SUBPATH}` : null;
3074
3670
  }
3075
- /** Latest published version via the npm registry HTTP API. `null` on any failure. */
3076
- async function npmLatest() {
3671
+ /** Read the registry; missing/corrupt/legacy (array) shapes collapse to `{}`. */
3672
+ function read(home) {
3673
+ const f = file(home);
3674
+ if (!f) return {};
3077
3675
  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;
3676
+ const parsed = JSON.parse(readFileSync(f, "utf8"));
3677
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3081
3678
  } catch {
3082
- return null;
3679
+ return {};
3083
3680
  }
3084
3681
  }
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
3682
+ /** Purge stale buckets, then atomically persist (unique tmp + rename). Non-throwing. */
3683
+ function write(home, reg, now) {
3684
+ const f = file(home);
3685
+ if (!f) return;
3686
+ for (const [sid, entry] of Object.entries(reg)) if (!entry || now - (entry.updatedAt ?? 0) > PURGE_MS) delete reg[sid];
3687
+ try {
3688
+ mkdirSync(dirname(f), { recursive: true });
3689
+ atomicWrite(f, JSON.stringify(reg));
3690
+ } catch {}
3691
+ }
3692
+ /** Record `field` for `(sid, root)`, refreshing the purge cursor. `home` defaults to `$HOME`. */
3693
+ function markSessionRoot(sid, root, field, value, home = process.env.HOME) {
3694
+ const reg = read(home);
3695
+ const prev = reg[sid];
3696
+ const entry = prev && typeof prev.roots === "object" && prev.roots !== null ? prev : {
3697
+ updatedAt: value,
3698
+ roots: {}
3699
+ };
3700
+ const mark = entry.roots[root] ?? {
3701
+ editedAt: 0,
3702
+ remindedAt: 0
3095
3703
  };
3704
+ entry.roots[root] = {
3705
+ ...mark,
3706
+ [field]: value
3707
+ };
3708
+ entry.updatedAt = value;
3709
+ reg[sid] = entry;
3710
+ write(home, reg, value);
3096
3711
  }
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");
3109
- }
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;
3712
+ /**
3713
+ * Roots of `sid` with an unsaved code edit past the `window`; each returned
3714
+ * root's `remindedAt` is bumped to `now` so the reminder fires at most once per
3715
+ * window and is consumed ONLY by this session. `home` defaults to `$HOME`.
3716
+ */
3717
+ function collectSessionPending(sid, now, window, home = process.env.HOME) {
3718
+ const reg = read(home);
3719
+ const entry = reg[sid];
3720
+ if (!entry || typeof entry.roots !== "object" || entry.roots === null) return [];
3721
+ const pending = [];
3722
+ for (const [root, mark] of Object.entries(entry.roots)) {
3723
+ if (mark.editedAt <= mark.remindedAt) continue;
3724
+ if (now - mark.remindedAt < window) continue;
3725
+ pending.push(root);
3726
+ entry.roots[root] = {
3727
+ ...mark,
3728
+ remindedAt: now
3729
+ };
3730
+ }
3731
+ if (pending.length > 0) write(home, reg, now);
3732
+ return pending;
3114
3733
  }
3115
3734
  //#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 "";
3735
+ //#region src/runtime/lifecycle/lessons/reminder.ts
3736
+ /**
3737
+ * fuse-lessons write-mark + Stop-reminder, scoped by `session_id` when present.
3738
+ *
3739
+ * WITH a session id (normal Claude Code): each `(session, root)` pair carries
3740
+ * its own edit/reminder throttle in {@link module:memory/session-roots}, so a
3741
+ * Stop lists and silences ONLY the roots THAT session edited — concurrent
3742
+ * sessions on different projects never cross-remind nor steal each other's
3743
+ * throttle. WITHOUT a usable session id (a harness that omits it, or the legacy
3744
+ * on-disk state) it falls back to the original mono-session behavior: the global
3745
+ * flat root registry + the per-project `MEMORY/state.json` throttle.
3746
+ */
3747
+ /** Sanitized session id from a raw hook payload, or null (→ legacy fallback). */
3748
+ function sessionOf(payload) {
3749
+ return sanitizeSessionId(payload.session_id);
3750
+ }
3751
+ /** Legacy (no session id): pending roots across the global flat registry. */
3752
+ function collectLegacyPending(now, window) {
3753
+ const pending = [];
3754
+ for (const root of readRoots()) {
3755
+ const stateFile = lessonsStateFileFor(root);
3756
+ const { lastRemindedAt, lastCodeEditAt } = readState(stateFile);
3757
+ if (lastCodeEditAt <= lastRemindedAt) continue;
3758
+ if (now - lastRemindedAt < window) continue;
3759
+ pending.push(root);
3760
+ setStateField(stateFile, "lastRemindedAt", now);
3123
3761
  }
3762
+ return pending;
3763
+ }
3764
+ /** Stop reminder body listing each pending project's lessons file. */
3765
+ function reminderText(pending) {
3766
+ 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
3767
  }
3125
3768
  /**
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 `""`).
3769
+ * Stop: emit one reminder covering the stopping session's pending projects.
3770
+ * @param payload - Raw hook payload (`session_id` selects the scoped path).
3771
+ * @param now - Clock.
3772
+ * @returns Native Stop stdout, or "" when nothing is pending.
3133
3773
  */
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");
3774
+ function remindWrite(payload, now) {
3775
+ const window = throttleMs();
3776
+ const sid = sessionOf(payload);
3777
+ const pending = sid ? collectSessionPending(sid, now, window) : collectLegacyPending(now, window);
3778
+ if (pending.length === 0) return "";
3779
+ return contextResponse("Stop", reminderText(pending));
3141
3780
  }
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
3781
  /**
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.
3782
+ * PostToolUse: record the edit against the throttle. A code file arms the
3783
+ * reminder; writing `MEMORY/LESSON.md` silences it (the lesson was just saved).
3784
+ * Session-scoped when `session_id` is present, else the legacy global path.
3785
+ * @param payload - Raw hook payload (`tool_input.file_path`, `session_id`).
3786
+ * @param now - Clock.
3153
3787
  */
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 "";
3788
+ function markWrite(payload, now) {
3789
+ const input = payload.tool_input;
3790
+ if (!input?.file_path) return;
3791
+ const abs = resolve(input.file_path);
3792
+ const root = projectRootOrNull(dirname(abs));
3793
+ if (!root) return;
3794
+ const isLesson = abs === resolve(root, "MEMORY", "LESSON.md");
3795
+ if (!isLesson && !isCodeFile(abs)) return;
3796
+ const sid = sessionOf(payload);
3797
+ if (sid) markSessionRoot(sid, root, isLesson ? "remindedAt" : "editedAt", now);
3798
+ else if (isLesson) setStateField(lessonsStateFileFor(root), "lastRemindedAt", now);
3799
+ else {
3800
+ setStateField(lessonsStateFileFor(root), "lastCodeEditAt", now);
3801
+ addRoot(root);
3164
3802
  }
3165
3803
  }
3166
3804
  //#endregion
3167
- //#region src/runtime/lifecycle/snapshot/format.ts
3805
+ //#region src/runtime/lifecycle/lessons/dispatch.ts
3168
3806
  /**
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.
3807
+ * fuse-lessons scope dispatch (TS port of the 4 handler scripts). Routes by
3808
+ * event: SessionStart/SubagentStart inject `MEMORY/LESSON.md`; Stop reminds the
3809
+ * stopping session about ITS OWN projects with unsaved code edits; PostToolUse
3810
+ * marks the write to arm/silence the throttle. The reminder + mark logic (incl.
3811
+ * the per-`session_id` scoping that fixes the multi-session misdirection) lives
3812
+ * in {@link module:runtime/lifecycle/lessons/reminder}; this module keeps the
3813
+ * event router + lesson-file injection. Non-fatal by design.
3173
3814
  */
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
3815
  /**
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.
3816
+ * Persist a curation ATOMICALLY and ARCHIVE-FIRST for zero-loss: prepend the
3817
+ * moved bullets to LESSON-archive.md, THEN rewrite LESSON.md. On ANY write error
3818
+ * the original file is left untouched (returns `original`) so a bullet is never
3819
+ * lost a rare archive-then-trim-fail leaves a duplicate (never a loss), which
3820
+ * the next dedup pass reconciles.
3190
3821
  */
3191
- function attachSnapshot(stdout, snapshot) {
3192
- if (!snapshot) return stdout;
3193
- if (!stdout) return contextResponse("SessionStart", snapshot);
3822
+ function persistCuration(file, root, curated, archive, original) {
3194
3823
  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
- });
3824
+ if (archive) {
3825
+ const af = lessonsArchiveFileFor(root);
3826
+ const prev = existsSync(af) ? readFileSync(af, "utf-8") : "";
3827
+ atomicWrite(af, prev ? `${archive}\n${prev}` : archive);
3828
+ }
3829
+ atomicWrite(file, curated);
3830
+ return curated;
3206
3831
  } catch {
3207
- return stdout;
3832
+ return original;
3208
3833
  }
3209
3834
  }
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) {
3835
+ /**
3836
+ * Inject `MEMORY/LESSON.md` for `event`. Mechanical curation (dedup + cap→archive)
3837
+ * rewrites the FILE; the injected BLOCK is then COMPRESSED (newest bullets whole,
3838
+ * older ones distilled to their rule) so a growing file never inflates the
3839
+ * SessionStart/SubagentStart context. A hard {@link capFragment} budget is the
3840
+ * last-resort backstop on top of compression — the regression that motivated it
3841
+ * was this exact block silently reaching ~44k tokens. Any curation report
3842
+ * surfaces via systemMessage.
3843
+ */
3844
+ function injectMemory(cwd, event, now) {
3845
+ const root = projectRoot(cwd);
3846
+ const file = lessonsFileFor(root);
3847
+ if (!existsSync(file)) return "";
3848
+ let content = "";
3214
3849
  try {
3215
- return fn();
3850
+ content = readFileSync(file, "utf-8").trim();
3216
3851
  } catch {
3217
3852
  return "";
3218
3853
  }
3854
+ if (!content) return "";
3855
+ const { content: curated, archive, report } = curateLessons(content, now, root);
3856
+ if (curated !== content) content = persistCuration(file, root, curated, archive, content);
3857
+ 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.`;
3858
+ return report ? attachSystemMessage(contextResponse(event, ctx), `LESSON.md curation:\n${report}`) : contextResponse(event, ctx);
3219
3859
  }
3220
3860
  /**
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.
3861
+ * Route a fuse-lessons event to its handler. Returns the native stdout for
3862
+ * context-injecting events (SessionStart/SubagentStart/Stop) or "" for the
3863
+ * side-effect-only PostToolUse mark.
3864
+ * @param event - The raw hook event name.
3865
+ * @param payload - The raw hook payload.
3866
+ * @param cwd - Project root for memory injection.
3867
+ * @param now - Clock.
3868
+ * @returns The native stdout (possibly empty).
3256
3869
  */
3257
- function withSnapshot(stdout, cwd, moduleUrl) {
3258
- try {
3259
- return attachSnapshot(stdout, renderSnapshot(cwd, moduleUrl));
3260
- } catch {
3261
- return stdout;
3870
+ function dispatchLessons(event, payload, cwd, now) {
3871
+ switch (event) {
3872
+ case "SessionStart":
3873
+ case "SubagentStart": return injectMemory(cwd, event, now);
3874
+ case "Stop": return remindWrite(payload, now);
3875
+ case "PostToolUse":
3876
+ markWrite(payload, now);
3877
+ return "";
3878
+ default: return "";
3262
3879
  }
3263
3880
  }
3264
3881
  //#endregion
@@ -3301,7 +3918,7 @@ async function injectApexSubagentContext(cwd, home = homedir()) {
3301
3918
  const agentsPath = join(apexDir, "AGENTS.md");
3302
3919
  const agents = existsSync(agentsPath) ? readText(agentsPath).slice(0, 4e3) : "";
3303
3920
  const taskData = await readJsonFile(join(apexDir, "task.json"));
3304
- return contextResponse("SubagentStart", `## APEX Sub-Agent Instructions
3921
+ return contextResponse("SubagentStart", capFragment("apex-subagent", `## APEX Sub-Agent Instructions
3305
3922
 
3306
3923
  You are a sub-agent in APEX workflow. Follow these rules:
3307
3924
 
@@ -3322,7 +3939,7 @@ ${agents}
3322
3939
  - Use Context7/Exa for docs | Write notes to .claude/apex/docs/
3323
3940
 
3324
3941
  ### 6. When Done
3325
- - TaskUpdate(taskId, status: completed) triggers auto-commit${cartographerContext()}`);
3942
+ - TaskUpdate(taskId, status: completed) triggers auto-commit${cartographerContext()}`));
3326
3943
  }
3327
3944
  /** 16-char hex SHA-256 of `text` (project hash / doc topic key). */
3328
3945
  function hashText16(text) {
@@ -4544,10 +5161,9 @@ function dispatchLifecycle(input) {
4544
5161
  if (input.scope === "aipilot") return "";
4545
5162
  harvestSubagentTrack(input.payload, input.cwd, input.now);
4546
5163
  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 "";
5164
+ case "TeammateIdle": return teammateIdleContext(input.payload, input.cwd, void 0, input.now);
5165
+ case "PostToolUseFailure": return failureLessonContext(input.payload, input.cwd, void 0, input.now);
5166
+ case "PostCompact": return input.scope === "core" ? postCompactContext(input.payload, input.cwd, import.meta.url, input.now) : "";
4551
5167
  case "PreCompact": return saveApexState(input.cwd, input.now);
4552
5168
  case "SessionEnd":
4553
5169
  if (input.scope !== "aipilot") cleanupSession(void 0, input.now);
@@ -5640,6 +6256,93 @@ function reconcileRefReadsFromTranscript(track, transcriptPath, now) {
5640
6256
  return next;
5641
6257
  }
5642
6258
  //#endregion
6259
+ //#region src/freshness/ref-journal.ts
6260
+ /**
6261
+ * Append-only journal of `.md` reference reads — the FRESH, race-immune companion
6262
+ * to {@link reconcileRefReadsFromTranscript}.
6263
+ *
6264
+ * WHY a third source (track + transcript were not enough for teammates): the live
6265
+ * session track write is a non-atomic load→mutate→save, so under the multi-plugin
6266
+ * hook fan-out (one process per plugin, ×N) a lone `refsRead` write is clobbered
6267
+ * (lost update). The transcript reconcile recovers the LEAD's lost reads because,
6268
+ * by the time the lead edits, its Read has flushed to the platform transcript. But
6269
+ * the platform flushes that JSONL to disk with a MULTI-MINUTE lag (measured ~230s,
6270
+ * well past the 120s freshness TTL): a background TEAMMATE reads the exact listed
6271
+ * ref then edits within seconds — far faster than the flush — so the transcript on
6272
+ * disk does NOT yet contain the teammate's read, reconcile misses it, and (its live
6273
+ * track write having been lost to the fan-out) solidReadGate blocks despite a
6274
+ * genuine read. That is the "teammate solidRead" gap.
6275
+ *
6276
+ * This journal closes it: every credited `.md` read is appended (one JSON line) the
6277
+ * instant PostToolUse fires — O_APPEND is per-write atomic, so concurrent fan-out
6278
+ * processes each add their own line and none is lost, and there is no flush lag. The
6279
+ * gate folds it back BEFORE any refsRead consumer, alongside the transcript.
6280
+ *
6281
+ * ANTI-FORGERY: it lives in the out-of-tree state dir the protected-path guard
6282
+ * denies agents from writing (SAME boundary as the signed track); the only writer is
6283
+ * our PostToolUse on a real `.md` Read. COST: the gate reads it once per edit, bounded
6284
+ * by {@link appendRefRead}'s trim cap — cheaper than the multi-MB transcript parse.
6285
+ */
6286
+ /** Journal filename inside the per-session state dir. */
6287
+ const JOURNAL = "refs-read.log";
6288
+ /**
6289
+ * Append a credited `.md` read to the state-dir journal (O_APPEND, atomic under
6290
+ * the fan-out). No-op for non-`.md` paths (parity with the reconcile filter).
6291
+ * Fully fail-open: a mkdir/append error is swallowed so recording never blocks the
6292
+ * PostToolUse path.
6293
+ * @param dir - Per-session state dir (`dirname(trackFile)`).
6294
+ * @param path - The read file's absolute path.
6295
+ * @param ts - The tool event's epoch-ms timestamp.
6296
+ */
6297
+ function appendRefRead(dir, path, ts) {
6298
+ if (!path.endsWith(".md")) return;
6299
+ try {
6300
+ mkdirSync(dir, { recursive: true });
6301
+ const file = join(dir, JOURNAL);
6302
+ appendFileSync(file, JSON.stringify({
6303
+ p: path,
6304
+ t: ts
6305
+ }) + "\n", "utf-8");
6306
+ trimLogFile(file, 128 * 1024, 1e3);
6307
+ } catch {}
6308
+ }
6309
+ /**
6310
+ * Fold every `.md` read in the state-dir journal into `track` (immutably), each
6311
+ * stamped with its journalled timestamp and never rolling back a MORE-recent
6312
+ * existing stamp — identical merge semantics to
6313
+ * {@link reconcileRefReadsFromTranscript}, but from the fresh append-only journal
6314
+ * instead of the lagged transcript. Fail-open: an absent/unreadable journal returns
6315
+ * `track` unchanged (same reference).
6316
+ * @param track - The current (possibly race-damaged) session track.
6317
+ * @param dir - Per-session state dir (`dirname(trackFile)`).
6318
+ * @param now - Fallback epoch-ms for entries with an invalid timestamp.
6319
+ * @returns The track with journalled `.md` reads merged into `refsRead`/`refsReadAt`.
6320
+ */
6321
+ function reconcileRefReadsFromJournal(track, dir, now) {
6322
+ let text;
6323
+ try {
6324
+ text = readText(join(dir, JOURNAL));
6325
+ } catch {
6326
+ return track;
6327
+ }
6328
+ let next = track;
6329
+ for (const line of text.split("\n")) {
6330
+ if (!line.trim()) continue;
6331
+ let entry;
6332
+ try {
6333
+ entry = JSON.parse(line);
6334
+ } catch {
6335
+ continue;
6336
+ }
6337
+ const path = typeof entry.p === "string" ? entry.p : "";
6338
+ if (!path.endsWith(".md")) continue;
6339
+ const ts = typeof entry.t === "number" && Number.isFinite(entry.t) ? entry.t : now;
6340
+ const prev = next.refsReadAt?.[path];
6341
+ if (prev === void 0 || prev < ts) next = recordRefRead(next, path, ts);
6342
+ }
6343
+ return next;
6344
+ }
6345
+ //#endregion
5643
6346
  //#region src/policy/shadcn-skill-gate.ts
5644
6347
  /** File extensions the shadcn gate polices (source: `\.(tsx|jsx|css|scss|json)$`). */
5645
6348
  const SHADCN_FILE_RE = /\.(tsx|jsx|css|scss|json)$/;
@@ -6101,7 +6804,7 @@ async function runGates(input) {
6101
6804
  if (modular) return modular;
6102
6805
  if (!input.filePath) return null;
6103
6806
  const filePath = input.filePath;
6104
- const track = reconcileRefReadsFromTranscript(await loadTrack(input.trackFile), input.transcriptPath, input.now);
6807
+ const track = reconcileRefReadsFromJournal(reconcileRefReadsFromTranscript(await loadTrack(input.trackFile), input.transcriptPath, input.now), dirname(input.trackFile), input.now);
6105
6808
  const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingCodeLines);
6106
6809
  if (solidOrSkill) return solidOrSkill;
6107
6810
  if (isShadcnWrite(input.tool, filePath)) {
@@ -6244,6 +6947,7 @@ function mcpPostStore(tool, input, response, dir, now = Date.now()) {
6244
6947
  async function recordActivity(file, activity) {
6245
6948
  const track = await loadTrack(file);
6246
6949
  await saveTrack(file, activity.kind === "agent" ? recordAgent(track, activity.name, activity.ts, activity.quality) : activity.kind === "doc" ? recordDoc(track, activity.framework, activity.sessionId, activity.source, activity.ts) : recordRefRead(track, activity.path, activity.ts));
6950
+ if (activity.kind === "ref") appendRefRead(dirname(file), activity.path, activity.ts ?? Date.now());
6247
6951
  }
6248
6952
  //#endregion
6249
6953
  //#region src/runtime/respond.ts
@@ -6284,6 +6988,7 @@ function respond(id, prompt) {
6284
6988
  case "codex":
6285
6989
  if (kind === "block") return denyResponse("PreToolUse", message);
6286
6990
  if (kind === "inform") return userMessage ? informResponse("PreToolUse", userMessage, reason ? message : "") : contextResponse("PreToolUse", message);
6991
+ if (id === "codex") return denyResponse("PreToolUse", `[downgraded from ask — Codex has no interactive approval]\n${message}`);
6287
6992
  return JSON.stringify({ hookSpecificOutput: {
6288
6993
  hookEventName: "PreToolUse",
6289
6994
  permissionDecision: "ask",
@@ -6742,181 +7447,49 @@ function designGate(payload, event, cacheDir, cwd) {
6742
7447
  return null;
6743
7448
  }
6744
7449
  //#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
7450
  //#region src/runtime/pre-allow.ts
6901
7451
  /**
6902
7452
  * PreToolUse ALLOW-path response assembly. Reached only after every gate
6903
7453
  * allowed (a deny/ask already returned upstream), so nothing here can block nor
6904
7454
  * override a decision. Combines the Python-parity pass notice (systemMessage)
6905
- * with the single most-specific decision-time lesson (additionalContext).
6906
- */
7455
+ * with the single most-specific decision-time lesson (additionalContext), plus
7456
+ * a compact "evidence fresh" compliance notice on the first APEX-scoped code
7457
+ * Write/Edit that clears the freshness gate.
7458
+ */
7459
+ /**
7460
+ * `✓ evidence fresh (explore+research)` for the first APEX-scoped Write/Edit
7461
+ * that finds explore-codebase + research-expert evidence still within the
7462
+ * freshness window — the user-visible confirmation for a gate that, until now,
7463
+ * only ever spoke up when it BLOCKED. Deduped once per freshness window per
7464
+ * session (same window the gate itself re-validates on), so it reads as "just
7465
+ * confirmed", not a notice on every edit. Returns null for anything but a
7466
+ * Write/Edit on an APEX-scoped path, or when evidence isn't fresh.
7467
+ */
7468
+ async function freshEvidenceNotice(event, evidence, cwd) {
7469
+ if (event.tool !== "Write" && event.tool !== "Edit" || !isApexScoped(event.filePath)) return null;
7470
+ const windowMs = evidence.windowMs ?? 12e4;
7471
+ if (!agentsFreshInTrack(await loadTrack(evidence.trackFile), REQUIRED_AGENTS, windowMs, evidence.now)) return null;
7472
+ if (!oncePerWindow(`evidence-fresh:${event.sessionId}`, windowMs, {
7473
+ now: evidence.now,
7474
+ dir: defaultStateDir(cwd)
7475
+ })) return null;
7476
+ return evidenceFreshNotice();
7477
+ }
6907
7478
  /**
6908
7479
  * 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).
7480
+ * user-visible pass notice (once per allowed call), the "evidence fresh"
7481
+ * compliance notice when applicable, and, when its TRIGGERS match this call,
7482
+ * the one cooldown-guarded decision-time lesson. All 3 channels ride a single
7483
+ * response (lesson → additionalContext, notices → systemMessage).
6912
7484
  * @param id - Harness id for {@link respond}.
6913
7485
  * @param event - The normalized PreToolUse event.
6914
7486
  * @param payload - The raw hook payload (for `agent_id`).
6915
7487
  * @param mcpDir - MCP state dir backing the pass-notice throttle.
6916
7488
  * @param cwd - Project root (lesson file + notice scope).
7489
+ * @param evidence - Session track + clock for the "evidence fresh" notice (omit to disable it).
6917
7490
  * @returns The native hook outcome (empty stdout when nothing to emit).
6918
7491
  */
6919
- function allowOutcome(id, event, payload, mcpDir, cwd) {
7492
+ async function allowOutcome(id, event, payload, mcpDir, cwd, evidence) {
6920
7493
  const notice = designPassNotice({
6921
7494
  agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
6922
7495
  tool: event.tool,
@@ -6929,19 +7502,79 @@ function allowOutcome(id, event, payload, mcpDir, cwd) {
6929
7502
  file: lessonsFileFor(projectRoot(cwd)),
6930
7503
  once: oncePerWindow
6931
7504
  });
6932
- if (lesson) return {
6933
- stdout: respond(id, notice?.userMessage ? {
6934
- ...lesson,
6935
- userMessage: notice.userMessage
6936
- } : lesson),
6937
- exit: 0
6938
- };
7505
+ const evidenceNotice = evidence ? await freshEvidenceNotice(event, evidence, cwd) : null;
7506
+ if (lesson) {
7507
+ const userMessage = [notice?.userMessage, evidenceNotice].filter(Boolean).join("\n") || void 0;
7508
+ return {
7509
+ stdout: respond(id, userMessage ? {
7510
+ ...lesson,
7511
+ userMessage
7512
+ } : lesson),
7513
+ exit: 0
7514
+ };
7515
+ }
7516
+ if (evidenceNotice) {
7517
+ const userMessage = [notice?.userMessage, evidenceNotice].filter(Boolean).join("\n");
7518
+ return {
7519
+ stdout: respond(id, notice ? {
7520
+ ...notice,
7521
+ userMessage
7522
+ } : {
7523
+ kind: "inform",
7524
+ title: "APEX freshness",
7525
+ reason: "",
7526
+ userMessage
7527
+ }),
7528
+ exit: 0
7529
+ };
7530
+ }
6939
7531
  return {
6940
7532
  stdout: notice ? respond(id, notice) : "",
6941
7533
  exit: 0
6942
7534
  };
6943
7535
  }
6944
7536
  //#endregion
7537
+ //#region src/runtime/apply-patch-gate.ts
7538
+ /**
7539
+ * OR the static per-file verdict for a Codex `apply_patch` envelope: run the
7540
+ * file-level gates (protected-path, SOLID file-size, DRY) that key off
7541
+ * `filePath`/`content` over EACH touched file and return the first blocking
7542
+ * {@link Prompt}. One violating hunk blocks the whole patch — the parity the
7543
+ * single-file `Write`/`Edit` path already has, extended to the multi-file
7544
+ * primitive.
7545
+ *
7546
+ * Only the read-only/pure gates run here (no session-state writes), so the
7547
+ * `~11×` hook fan-out stays idempotent — the stateful APEX freshness/skill
7548
+ * gates are tool-level and never policed `apply_patch` (its `filePath` was
7549
+ * always undefined), so they are intentionally out of scope.
7550
+ *
7551
+ * File-size tool mapping mirrors Claude: an `add` is judged like a `Write`
7552
+ * (full new content), an `update` like an `Edit` (partial content, compared
7553
+ * against the on-disk count so an already-oversized file still blocks).
7554
+ * @param files - Per-file changes from {@link NormalizedFile}.
7555
+ * @param cwd - Project root for the DRY codebase grep.
7556
+ * @returns The first blocking prompt, or null when every file passes.
7557
+ */
7558
+ function applyPatchGate(files, cwd) {
7559
+ for (const f of files) {
7560
+ const tool = f.op === "add" ? "Write" : "Edit";
7561
+ const protectedDeny = protectedPathGate(tool, f.filePath);
7562
+ if (protectedDeny) return protectedDeny;
7563
+ if (f.op === "delete") continue;
7564
+ const { raw: existingLines } = existingLineCounts(f.filePath);
7565
+ const quick = evaluate({
7566
+ tool,
7567
+ filePath: f.filePath,
7568
+ content: f.content,
7569
+ existingLines
7570
+ });
7571
+ if (quick.decision !== "allow" && quick.prompt) return quick.prompt;
7572
+ const dry = dryGate(tool, f.filePath, f.content, cwd);
7573
+ if (dry) return dry;
7574
+ }
7575
+ return null;
7576
+ }
7577
+ //#endregion
6945
7578
  //#region src/runtime/handle-pre.ts
6946
7579
  /**
6947
7580
  * Run the PreToolUse pipeline: MCP/WebFetch cache intercept, design gate, APEX
@@ -6985,6 +7618,13 @@ async function handlePre(ctx) {
6985
7618
  exit: 0
6986
7619
  };
6987
7620
  }
7621
+ if (event.files && event.files.length > 0) {
7622
+ const patchPrompt = applyPatchGate(event.files, opts.cwd);
7623
+ if (patchPrompt) return {
7624
+ stdout: respond(id, patchPrompt),
7625
+ exit: 0
7626
+ };
7627
+ }
6988
7628
  const prompt = await gate({
6989
7629
  sessionId: event.sessionId,
6990
7630
  framework,
@@ -7006,7 +7646,11 @@ async function handlePre(ctx) {
7006
7646
  stdout: respond(id, prompt),
7007
7647
  exit: 0
7008
7648
  };
7009
- return allowOutcome(id, event, payload, mcpDir, opts.cwd);
7649
+ return allowOutcome(id, event, payload, mcpDir, opts.cwd, {
7650
+ trackFile: file,
7651
+ windowMs: opts.windowMs,
7652
+ now: opts.now
7653
+ });
7010
7654
  }
7011
7655
  //#endregion
7012
7656
  //#region src/freshness/query-framework.ts
@@ -7237,27 +7881,38 @@ async function handlePost(ctx) {
7237
7881
  url: "",
7238
7882
  phase: "post"
7239
7883
  }, mcpDir);
7884
+ const refNotice = refCreditNoticeFor(activities, event.sessionId, opts.now, defaultStateDir(opts.cwd));
7885
+ const userMessage = [notice?.userMessage, refNotice].filter(Boolean).join("\n") || void 0;
7240
7886
  if (designWarn) return {
7241
- stdout: respond(id, notice?.userMessage ? {
7887
+ stdout: respond(id, userMessage ? {
7242
7888
  ...designWarn,
7243
- userMessage: notice.userMessage
7889
+ userMessage
7244
7890
  } : designWarn),
7245
7891
  exit: 0
7246
7892
  };
7247
- if (!notice?.userMessage) return {
7893
+ if (!userMessage) return {
7248
7894
  stdout: extra,
7249
7895
  exit: 0
7250
7896
  };
7897
+ const withUserMessage = notice ? {
7898
+ ...notice,
7899
+ userMessage
7900
+ } : {
7901
+ kind: "inform",
7902
+ title: "Compliance",
7903
+ reason: "",
7904
+ userMessage
7905
+ };
7251
7906
  if (!extra) return {
7252
- stdout: respond(id, notice),
7907
+ stdout: respond(id, withUserMessage),
7253
7908
  exit: 0
7254
7909
  };
7255
7910
  if (id === "claude-code" || id === "codex") return {
7256
- stdout: attachSystemMessage(extra, notice.userMessage),
7911
+ stdout: attachSystemMessage(extra, userMessage),
7257
7912
  exit: 0
7258
7913
  };
7259
7914
  return {
7260
- stdout: respond(id, notice) || extra,
7915
+ stdout: respond(id, withUserMessage) || extra,
7261
7916
  exit: 0
7262
7917
  };
7263
7918
  }
@@ -7283,6 +7938,51 @@ async function asyncScopeStdout(scope, event, payload, cwd, now) {
7283
7938
  return null;
7284
7939
  }
7285
7940
  //#endregion
7941
+ //#region src/runtime/inject-budget-recap.ts
7942
+ /**
7943
+ * @module inject-budget-recap
7944
+ * ONE event-level recap of every {@link module:inject-budget.capFragment}
7945
+ * -tracked fragment injected for a SessionStart/SubagentStart event — the
7946
+ * aggregated view the per-fragment caps never gave: each injection point
7947
+ * (dev-context, snapshot sections, lessons, apex-subagent, ...) caps and
7948
+ * reports itself in ISOLATION, with no total across the whole event.
7949
+ *
7950
+ * Rides the user-visible `systemMessage` channel, Claude-Code-only (gate at
7951
+ * the call site — mirrors the existing `id === "claude-code"` pattern for
7952
+ * `designLifecycle` in handle.ts): other adapters do not all re-export
7953
+ * `attachSystemMessage`, and stamping a Claude-shaped envelope onto another
7954
+ * harness's stdout shape would be silently wrong there.
7955
+ *
7956
+ * Deduped via {@link module:inject-dedup.onceExclusive}, not the JSON
7957
+ * `oncePerWindow`: SessionStart/SubagentStart fan out across every installed
7958
+ * plugin exactly like PostToolUse does (see burst-window.ts) — a shared-JSON
7959
+ * read-modify-write here would risk the same lost-update race already fixed
7960
+ * for the sniper reminder (lesson 2026-07-05 16:00).
7961
+ * @packageDocumentation
7962
+ */
7963
+ /**
7964
+ * Attach the aggregated recap onto `stdout` when 2+ fragments were recorded
7965
+ * for `rawEvent`. A lone fragment already carries its own visibility (its
7966
+ * producer's own per-fragment report, when it has one) — no recap is added
7967
+ * for that case, so the common single-fragment event stays noise-free.
7968
+ * @param stdout - The already-rendered hook stdout for this event.
7969
+ * @param rawEvent - The raw hook event name (only SessionStart/SubagentStart qualify; others pass through unchanged).
7970
+ * @param sessionId - Current session id (dedup scope).
7971
+ * @param cwd - Project root (state-dir scope for the dedup marker).
7972
+ * @param now - Clock.
7973
+ * @returns `stdout` with a `systemMessage` recap attached, or `stdout` unchanged.
7974
+ */
7975
+ function attachBudgetRecap(stdout, rawEvent, sessionId, cwd, now) {
7976
+ if (rawEvent !== "SessionStart" && rawEvent !== "SubagentStart") return stdout;
7977
+ const fragments = fragmentRegistry();
7978
+ if (fragments.length <= 1) return stdout;
7979
+ if (!onceExclusive(`budget:${sessionId}:${rawEvent}`, 2e3, {
7980
+ now,
7981
+ dir: defaultStateDir(cwd)
7982
+ })) return stdout;
7983
+ return attachSystemMessage(stdout, budgetReport(fragments));
7984
+ }
7985
+ //#endregion
7286
7986
  //#region src/runtime/handle.ts
7287
7987
  /** Raw Claude hook event name from a payload (empty when absent). */
7288
7988
  function rawEventName(payload) {
@@ -7296,6 +7996,7 @@ function rawEventName(payload) {
7296
7996
  */
7297
7997
  async function handleHook(id, payload, opts) {
7298
7998
  const event = normalizeEvent(id, payload);
7999
+ resetFragmentRegistry();
7299
8000
  const layout = projectLayout(opts.cwd);
7300
8001
  const file = trackFile(event.sessionId, defaultStateDir(opts.cwd));
7301
8002
  const mcpDir = layout.cacheDir;
@@ -7311,7 +8012,7 @@ async function handleHook(id, payload, opts) {
7311
8012
  };
7312
8013
  const life = lifecycleStdout(payload, opts.cwd, opts.scope ?? "core", opts.now);
7313
8014
  if (life !== null) return {
7314
- stdout: life,
8015
+ stdout: id === "claude-code" ? attachBudgetRecap(life, rawEventName(payload), event.sessionId, opts.cwd, opts.now) : life,
7315
8016
  exit: 0
7316
8017
  };
7317
8018
  const userPrompt = typeof payload.prompt === "string" ? payload.prompt : void 0;
@@ -7342,4 +8043,4 @@ async function handleHook(id, payload, opts) {
7342
8043
  });
7343
8044
  }
7344
8045
  //#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 };
8046
+ 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 };