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