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