@fusengine/harness 0.1.52 → 0.1.54
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.
|
@@ -20,7 +20,8 @@ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, read
|
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { mkdir, rmdir } from "node:fs/promises";
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
|
-
import { execFileSync } from "node:child_process";
|
|
23
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
24
25
|
//#region src/runtime/lifecycle/security/skill-state.ts
|
|
25
26
|
/**
|
|
26
27
|
* Shared security-tracker state: per-UTC-day JSON under
|
|
@@ -329,7 +330,7 @@ function designLifecycle(payload, cacheDir, cwd, stamp, now) {
|
|
|
329
330
|
return false;
|
|
330
331
|
}
|
|
331
332
|
/** Sidecar basename under the per-project state dir. */
|
|
332
|
-
const SIDECAR$
|
|
333
|
+
const SIDECAR$2 = "inject-dedup.json";
|
|
333
334
|
/** Load the `{ key -> epochMs }` map, or `{}` when missing/corrupt. */
|
|
334
335
|
function loadMap$1(path) {
|
|
335
336
|
try {
|
|
@@ -361,7 +362,7 @@ function prune$1(map, now, windowMs) {
|
|
|
361
362
|
*/
|
|
362
363
|
function oncePerWindow(key, windowMs, opts = {}) {
|
|
363
364
|
const now = opts.now ?? Date.now();
|
|
364
|
-
const path = join(opts.dir ?? defaultStateDir(), SIDECAR$
|
|
365
|
+
const path = join(opts.dir ?? defaultStateDir(), SIDECAR$2);
|
|
365
366
|
const map = prune$1(loadMap$1(path), now, windowMs);
|
|
366
367
|
const last = map[key];
|
|
367
368
|
if (typeof last === "number" && now - last < windowMs) return false;
|
|
@@ -417,7 +418,7 @@ function taskContext(cwd) {
|
|
|
417
418
|
//#endregion
|
|
418
419
|
//#region src/runtime/dev-context.ts
|
|
419
420
|
/** Run a git subcommand in `cwd`, returning trimmed stdout or "" on error. */
|
|
420
|
-
function git(cwd, args) {
|
|
421
|
+
function git$1(cwd, args) {
|
|
421
422
|
try {
|
|
422
423
|
return execFileSync("git", args, {
|
|
423
424
|
cwd,
|
|
@@ -432,8 +433,8 @@ function git(cwd, args) {
|
|
|
432
433
|
/** Build the git portion of the dev context (branch + up to 5 changed files). */
|
|
433
434
|
function gitContext(cwd) {
|
|
434
435
|
if (!existsSync(join(cwd, ".git"))) return [];
|
|
435
|
-
const parts = [`Git branch: ${git(cwd, ["branch", "--show-current"]) || "unknown"}`];
|
|
436
|
-
const status = git(cwd, ["status", "--porcelain"]);
|
|
436
|
+
const parts = [`Git branch: ${git$1(cwd, ["branch", "--show-current"]) || "unknown"}`];
|
|
437
|
+
const status = git$1(cwd, ["status", "--porcelain"]);
|
|
437
438
|
if (status) parts.push("Modified files:\n" + status.split("\n").slice(0, 5).join("\n"));
|
|
438
439
|
return parts;
|
|
439
440
|
}
|
|
@@ -902,10 +903,10 @@ function trunc(text, limit) {
|
|
|
902
903
|
/** Render fresh cache entries as the markdown injection block. */
|
|
903
904
|
function render(entries) {
|
|
904
905
|
const lines = [
|
|
905
|
-
"# MCP
|
|
906
|
-
"
|
|
907
|
-
"
|
|
908
|
-
"APEX:
|
|
906
|
+
"# MCP cache available this session",
|
|
907
|
+
"Before calling mcp__context7/exa, check if the result is already cached.",
|
|
908
|
+
"Read the .md file via Read to retrieve the result.",
|
|
909
|
+
"APEX: reading the MCP cache via Read satisfies the research-expert step.",
|
|
909
910
|
"",
|
|
910
911
|
"| Tool | Query | File |",
|
|
911
912
|
"| --- | --- | --- |"
|
|
@@ -1232,7 +1233,7 @@ function pad(n) {
|
|
|
1232
1233
|
return String(n).padStart(2, "0");
|
|
1233
1234
|
}
|
|
1234
1235
|
/** Compact local timestamp `YYYYMMDD-HHMMSS` (mirrors Python strftime). */
|
|
1235
|
-
function stamp(now) {
|
|
1236
|
+
function stamp$1(now) {
|
|
1236
1237
|
const d = new Date(now);
|
|
1237
1238
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
1238
1239
|
}
|
|
@@ -1249,7 +1250,7 @@ function saveApexState(cwd, now = Date.now()) {
|
|
|
1249
1250
|
if (!existsSync(stateFile)) return "";
|
|
1250
1251
|
const backupDir = join(apexDir, "backups");
|
|
1251
1252
|
mkdirSync(backupDir, { recursive: true });
|
|
1252
|
-
copyFileSync(stateFile, join(backupDir, `task-${stamp(now)}.json`));
|
|
1253
|
+
copyFileSync(stateFile, join(backupDir, `task-${stamp$1(now)}.json`));
|
|
1253
1254
|
const backups = readdirSync(backupDir).filter((n) => n.startsWith("task-") && n.endsWith(".json")).sort().reverse();
|
|
1254
1255
|
for (const old of backups.slice(5)) try {
|
|
1255
1256
|
rmSync(join(backupDir, old), { force: true });
|
|
@@ -2135,16 +2136,10 @@ function cartoSessionStart(cwd, now = Date.now()) {
|
|
|
2135
2136
|
return ctx ? contextResponse("SessionStart", ctx) : "";
|
|
2136
2137
|
}
|
|
2137
2138
|
//#endregion
|
|
2138
|
-
//#region src/runtime/lifecycle/aipilot/
|
|
2139
|
-
/**
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
* flag over-cap + stale (>90d, cited path gone) in a report. Only dedup writes.
|
|
2143
|
-
*/
|
|
2144
|
-
const CAP = 50;
|
|
2145
|
-
const STALE_DAYS = 90;
|
|
2146
|
-
const SIM_THRESHOLD = .8;
|
|
2147
|
-
const MIN_TOKENS = 4;
|
|
2139
|
+
//#region src/runtime/lifecycle/aipilot/lesson-parse.ts
|
|
2140
|
+
/** Milliseconds in a day. */
|
|
2141
|
+
const DAY_MS = 864e5;
|
|
2142
|
+
/** Case-sensitive decision-time tag line (`[TRIGGERS …]`) — opus-lessons format. */
|
|
2148
2143
|
const TRIG = /^\[TRIGGERS\s+.+\]$/;
|
|
2149
2144
|
/** Epoch ms for a `[YYYY-MM-DD HH:MM]` stamp; `NaN` if absent or out of range. */
|
|
2150
2145
|
function parseTs$1(line) {
|
|
@@ -2171,7 +2166,11 @@ function citedPaths(text) {
|
|
|
2171
2166
|
for (const m of text.matchAll(/[\w./@-]+\.\w{1,5}/g)) if (m[0]) out.add(m[0]);
|
|
2172
2167
|
return [...out].filter((p) => p.includes("/") && /\.\w{1,5}$/.test(p));
|
|
2173
2168
|
}
|
|
2174
|
-
/**
|
|
2169
|
+
/** True when a block carries a `[TRIGGERS …]` continuation line. */
|
|
2170
|
+
function hasTrigger(b) {
|
|
2171
|
+
return b.raw.some((l) => TRIG.test(l.trim()));
|
|
2172
|
+
}
|
|
2173
|
+
/** Split content into a verbatim preamble and one Block per `- ` bullet. */
|
|
2175
2174
|
function parse(content) {
|
|
2176
2175
|
const lines = content.split("\n");
|
|
2177
2176
|
const blocks = [];
|
|
@@ -2192,23 +2191,89 @@ function parse(content) {
|
|
|
2192
2191
|
blocks
|
|
2193
2192
|
};
|
|
2194
2193
|
}
|
|
2194
|
+
//#endregion
|
|
2195
|
+
//#region src/runtime/lifecycle/aipilot/lesson-archive.ts
|
|
2196
|
+
/**
|
|
2197
|
+
* Stage 1 — cap→archive split for LESSON.md. When deduped bullets exceed CAP the
|
|
2198
|
+
* OLDEST excess is MOVED (never deleted) to LESSON-archive.md, EXCEPT a
|
|
2199
|
+
* `[TRIGGERS …]` bullet younger than STALE_DAYS: archiving it would blind the
|
|
2200
|
+
* PreToolUse trigger index (src/policy/lessons/trigger-index reads LESSON.md), so
|
|
2201
|
+
* it stays even past the cap. Pure: this module decides the partition and renders
|
|
2202
|
+
* the archive block; the fail-safe, archive-first file write is the caller's job.
|
|
2203
|
+
*/
|
|
2204
|
+
/** Sort key: undated bullets sort oldest, so malformed entries archive first. */
|
|
2205
|
+
function age(b) {
|
|
2206
|
+
return Number.isNaN(b.ts) ? -Infinity : b.ts;
|
|
2207
|
+
}
|
|
2208
|
+
/** A TRIGGERS bullet is protected from archival until older than STALE_DAYS. */
|
|
2209
|
+
function isProtected(b, staleBefore) {
|
|
2210
|
+
return hasTrigger(b) && !(b.ts <= staleBefore);
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* Partition deduped `blocks` (newest-first file order) into the bullets that
|
|
2214
|
+
* stay in LESSON.md and the oldest excess to archive. Archives only enough to
|
|
2215
|
+
* reach CAP, skipping protected TRIGGERS bullets (so the file MAY stay slightly
|
|
2216
|
+
* over cap by design). Order is preserved in both halves; `keep ∪ archive` is
|
|
2217
|
+
* exactly `blocks` with no loss and no mutation.
|
|
2218
|
+
* @param blocks - Deduped bullets, newest first.
|
|
2219
|
+
* @param now - Clock (ms) for the STALE_DAYS protection window.
|
|
2220
|
+
* @returns `{ keep, archive }` — a lossless partition of `blocks`.
|
|
2221
|
+
*/
|
|
2222
|
+
function splitAtCap(blocks, now) {
|
|
2223
|
+
if (blocks.length <= 50) return {
|
|
2224
|
+
keep: blocks,
|
|
2225
|
+
archive: []
|
|
2226
|
+
};
|
|
2227
|
+
const staleBefore = now - 90 * DAY_MS;
|
|
2228
|
+
const oldestFirst = [...blocks].sort((a, b) => age(a) - age(b));
|
|
2229
|
+
const toArchive = /* @__PURE__ */ new Set();
|
|
2230
|
+
let excess = blocks.length - 50;
|
|
2231
|
+
for (const b of oldestFirst) {
|
|
2232
|
+
if (excess <= 0) break;
|
|
2233
|
+
if (isProtected(b, staleBefore)) continue;
|
|
2234
|
+
toArchive.add(b);
|
|
2235
|
+
excess--;
|
|
2236
|
+
}
|
|
2237
|
+
return {
|
|
2238
|
+
keep: blocks.filter((b) => !toArchive.has(b)),
|
|
2239
|
+
archive: blocks.filter((b) => toArchive.has(b))
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Render `archive` bullets as a dated block to PREPEND to LESSON-archive.md
|
|
2244
|
+
* (newest archive session on top). Bullets are emitted BYTE-IDENTICAL (raw lines
|
|
2245
|
+
* rejoined) — zero mutation, so the move stays reversible/auditable.
|
|
2246
|
+
* @param archive - Bullets chosen by {@link splitAtCap}.
|
|
2247
|
+
* @param now - Clock (ms) for the archival header date.
|
|
2248
|
+
* @returns The block text (trailing newline), or "" when nothing is archived.
|
|
2249
|
+
*/
|
|
2250
|
+
function formatArchive(archive, now) {
|
|
2251
|
+
if (archive.length === 0) return "";
|
|
2252
|
+
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`;
|
|
2253
|
+
}
|
|
2254
|
+
//#endregion
|
|
2255
|
+
//#region src/runtime/lifecycle/aipilot/curate-lessons.ts
|
|
2256
|
+
/**
|
|
2257
|
+
* Stage-0 mechanical, LLM-free dedup of MEMORY/LESSON.md bullets + cap→archive
|
|
2258
|
+
* orchestration. Strict-dedup near-identical bullets (keep newest, `[TRIGGERS …]`
|
|
2259
|
+
* preserved), then hand the deduped set to lesson-archive's cap split. Returns the
|
|
2260
|
+
* rewritten LESSON.md content, the archive block to move out, and a human report.
|
|
2261
|
+
* Pure: all file I/O (archive-first, fail-safe) lives in the dispatch caller.
|
|
2262
|
+
*/
|
|
2263
|
+
const SIM_THRESHOLD = .8;
|
|
2264
|
+
const MIN_TOKENS = 4;
|
|
2195
2265
|
/** Report lines for bullets older than STALE_DAYS whose only cited path is gone. */
|
|
2196
2266
|
function staleReport(blocks, now, root) {
|
|
2197
|
-
const cutoff = now -
|
|
2267
|
+
const cutoff = now - 90 * DAY_MS;
|
|
2198
2268
|
return blocks.flatMap((b) => {
|
|
2199
2269
|
if (!(b.ts <= cutoff)) return [];
|
|
2200
2270
|
const paths = citedPaths(b.raw.join(" "));
|
|
2201
2271
|
if (paths.length === 0 || paths.some((p) => existsSync(join(root, p)))) return [];
|
|
2202
|
-
return [`[STALE?] ${(b.raw[0] ?? "").slice(0, 90)} —
|
|
2272
|
+
return [`[STALE?] ${(b.raw[0] ?? "").slice(0, 90)} — missing path(s): ${paths.join(", ")}`];
|
|
2203
2273
|
});
|
|
2204
2274
|
}
|
|
2205
|
-
/**
|
|
2206
|
-
|
|
2207
|
-
* or carried over from the dropped twin if the kept one lacks it) and report
|
|
2208
|
-
* cap/stale. `content` unchanged unless a dedup occurred. Returns content + report.
|
|
2209
|
-
*/
|
|
2210
|
-
function curateLessons(content, now, root = process.cwd()) {
|
|
2211
|
-
const { preamble, blocks } = parse(content);
|
|
2275
|
+
/** Strict-dedup: keep the newest of each near-identical pair (TRIGGERS carried over). Returns kept blocks + merge report lines. */
|
|
2276
|
+
function dedup(blocks) {
|
|
2212
2277
|
const kept = [];
|
|
2213
2278
|
const fused = [];
|
|
2214
2279
|
for (const b of blocks) {
|
|
@@ -2223,20 +2288,76 @@ function curateLessons(content, now, root = process.cwd()) {
|
|
|
2223
2288
|
const t = drop.raw.find((l) => TRIG.test(l.trim()));
|
|
2224
2289
|
if (t) win.raw.push(t);
|
|
2225
2290
|
}
|
|
2226
|
-
fused.push(`
|
|
2291
|
+
fused.push(`merged: kept ${(win.raw[0] ?? "").slice(0, 60)} · dropped ${(drop.raw[0] ?? "").slice(0, 60)}`);
|
|
2227
2292
|
}
|
|
2228
|
-
|
|
2229
|
-
|
|
2293
|
+
return {
|
|
2294
|
+
kept,
|
|
2295
|
+
fused
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
/**
|
|
2299
|
+
* Dedup LESSON.md bullets, then archive the oldest excess over CAP (via
|
|
2300
|
+
* lesson-archive). `content` is byte-identical to the input when nothing is
|
|
2301
|
+
* deduped or archived. The `archive` block (possibly "") is what the caller must
|
|
2302
|
+
* PREPEND to LESSON-archive.md, archive-first, before writing `content`.
|
|
2303
|
+
* @param content - Raw LESSON.md text.
|
|
2304
|
+
* @param now - Clock (ms) for stale/archival windows.
|
|
2305
|
+
* @param root - Project root, for resolving cited paths in the stale report.
|
|
2306
|
+
* @returns The rewritten content, the archive block, and the report.
|
|
2307
|
+
*/
|
|
2308
|
+
function curateLessons(content, now, root = process.cwd()) {
|
|
2309
|
+
const { preamble, blocks } = parse(content);
|
|
2310
|
+
const { kept, fused } = dedup(blocks);
|
|
2311
|
+
const { keep, archive } = splitAtCap(kept, now);
|
|
2312
|
+
const rebuilt = fused.length > 0 || archive.length > 0 ? `${preamble}\n${keep.map((b) => b.raw.join("\n")).join("\n\n")}\n` : content;
|
|
2313
|
+
const capReport = archive.length ? [`${kept.length} bullets (> 50) — ${archive.length} oldest archived → LESSON-archive.md`] : [];
|
|
2230
2314
|
const report = [
|
|
2231
2315
|
...fused,
|
|
2232
|
-
...
|
|
2316
|
+
...capReport,
|
|
2233
2317
|
...staleReport(blocks, now, root)
|
|
2234
2318
|
].join("\n");
|
|
2235
2319
|
return {
|
|
2236
|
-
content:
|
|
2320
|
+
content: rebuilt,
|
|
2321
|
+
archive: formatArchive(archive, now),
|
|
2237
2322
|
report
|
|
2238
2323
|
};
|
|
2239
2324
|
}
|
|
2325
|
+
/** The `[YYYY-MM-DD HH:MM]` (or date-only) stamp of a bullet, "" if absent. */
|
|
2326
|
+
function stamp(block) {
|
|
2327
|
+
return (block.raw[0] ?? "").match(/\[(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)\]/)?.[1] ?? "";
|
|
2328
|
+
}
|
|
2329
|
+
/** Bullet text: raw lines joined, leading "- ", date stamp & TRIGGERS lines stripped. */
|
|
2330
|
+
function bodyText(block) {
|
|
2331
|
+
return block.raw.filter((l) => !/^\s*\[TRIGGERS\s/.test(l)).join(" ").replace(/^-\s*/, "").replace(/\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*/, "").trim();
|
|
2332
|
+
}
|
|
2333
|
+
/** First sentence of `s` (split on a period + whitespace), whole string if none. */
|
|
2334
|
+
function firstSentence(s) {
|
|
2335
|
+
return (s.split(/(?<=\.)\s/)[0] ?? s).trim();
|
|
2336
|
+
}
|
|
2337
|
+
/** Collapse one older bullet to `- [date] <rule>`: first sentence after last "→", capped. */
|
|
2338
|
+
function compressBullet(block) {
|
|
2339
|
+
const text = bodyText(block);
|
|
2340
|
+
const arrow = text.lastIndexOf("→");
|
|
2341
|
+
let rule = firstSentence((arrow >= 0 ? text.slice(arrow + 1) : text).trim());
|
|
2342
|
+
if (rule.length > 200) rule = `${rule.slice(0, 199).trimEnd()}…`;
|
|
2343
|
+
const date = stamp(block);
|
|
2344
|
+
return `- ${date ? `[${date}] ` : ""}${rule}`;
|
|
2345
|
+
}
|
|
2346
|
+
/**
|
|
2347
|
+
* Build the compressed injection body for `content`. The preamble comments are
|
|
2348
|
+
* dropped (format docs, noise for the reader); the `recentFull` newest bullets
|
|
2349
|
+
* stay whole, every older bullet becomes one distilled rule-line.
|
|
2350
|
+
* @param content - Raw LESSON.md text.
|
|
2351
|
+
* @param recentFull - Count of newest bullets to keep verbatim.
|
|
2352
|
+
* @returns The compressed block (bullets only), or the trimmed content when there are no bullets.
|
|
2353
|
+
*/
|
|
2354
|
+
function compressInjection(content, recentFull = 10) {
|
|
2355
|
+
const { blocks } = parse(content);
|
|
2356
|
+
if (blocks.length === 0) return content.trim();
|
|
2357
|
+
const full = blocks.slice(0, recentFull).map((b) => b.raw.join("\n"));
|
|
2358
|
+
const rest = blocks.slice(recentFull).map(compressBullet);
|
|
2359
|
+
return [...full, ...rest].join("\n");
|
|
2360
|
+
}
|
|
2240
2361
|
//#endregion
|
|
2241
2362
|
//#region src/runtime/lifecycle/lessons/state.ts
|
|
2242
2363
|
/**
|
|
@@ -2250,40 +2371,116 @@ function curateLessons(content, now, root = process.cwd()) {
|
|
|
2250
2371
|
function lessonsFileFor(root) {
|
|
2251
2372
|
return join(root, "MEMORY", "LESSON.md");
|
|
2252
2373
|
}
|
|
2374
|
+
/** Absolute `<root>/MEMORY/LESSON-archive.md` — cold storage for capped-out bullets. */
|
|
2375
|
+
function lessonsArchiveFileFor(root) {
|
|
2376
|
+
return join(root, "MEMORY", "LESSON-archive.md");
|
|
2377
|
+
}
|
|
2253
2378
|
/** Absolute `<root>/MEMORY/state.json` — machine-local throttle counter. */
|
|
2254
2379
|
function lessonsStateFileFor(root) {
|
|
2255
2380
|
return join(root, "MEMORY", "state.json");
|
|
2256
2381
|
}
|
|
2257
2382
|
//#endregion
|
|
2258
|
-
//#region src/
|
|
2259
|
-
/**
|
|
2260
|
-
*
|
|
2261
|
-
*
|
|
2262
|
-
*
|
|
2263
|
-
*
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2383
|
+
//#region src/memory/session-roots.ts
|
|
2384
|
+
/**
|
|
2385
|
+
* Session-scoped lessons roots registry. The flat {@link module:memory/registry}
|
|
2386
|
+
* keeps ONE global list of pending roots — correct mono-session, but wrong with
|
|
2387
|
+
* several concurrent Claude Code sessions: at Stop, one session lists (and, by
|
|
2388
|
+
* bumping the throttle, STEALS) another session's pending lesson on a project it
|
|
2389
|
+
* never touched. This registry keys "which project got code edits, and was its
|
|
2390
|
+
* Stop reminder already fired" by `session_id`, so each Stop sees and consumes
|
|
2391
|
+
* ONLY its own roots. Stored at `$HOME/.fuse-harness/cache/lessons/session-roots.json`;
|
|
2392
|
+
* non-fatal on any I/O failure (a missed reminder never blocks a session).
|
|
2393
|
+
*/
|
|
2394
|
+
/** Registry path (rel. home) + stale-bucket purge horizon (bounds growth). */
|
|
2395
|
+
const SUBPATH = ".fuse-harness/cache/lessons/session-roots.json";
|
|
2396
|
+
const PURGE_MS = 10080 * 60 * 1e3;
|
|
2397
|
+
/** Absolute registry path, or null when home is unusable. */
|
|
2398
|
+
function file(home) {
|
|
2399
|
+
const h = home?.trim();
|
|
2400
|
+
return h && h.startsWith("/") ? `${h}/${SUBPATH}` : null;
|
|
2401
|
+
}
|
|
2402
|
+
/** Read the registry; missing/corrupt/legacy (array) shapes collapse to `{}`. */
|
|
2403
|
+
function read(home) {
|
|
2404
|
+
const f = file(home);
|
|
2405
|
+
if (!f) return {};
|
|
2271
2406
|
try {
|
|
2272
|
-
|
|
2407
|
+
const parsed = JSON.parse(readFileSync(f, "utf8"));
|
|
2408
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
2273
2409
|
} catch {
|
|
2274
|
-
return
|
|
2410
|
+
return {};
|
|
2275
2411
|
}
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2412
|
+
}
|
|
2413
|
+
/** Purge stale buckets, then atomically persist (unique tmp + rename). Non-throwing. */
|
|
2414
|
+
function write(home, reg, now) {
|
|
2415
|
+
const f = file(home);
|
|
2416
|
+
if (!f) return;
|
|
2417
|
+
for (const [sid, entry] of Object.entries(reg)) if (!entry || now - (entry.updatedAt ?? 0) > PURGE_MS) delete reg[sid];
|
|
2418
|
+
try {
|
|
2419
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
2420
|
+
atomicWrite(f, JSON.stringify(reg));
|
|
2281
2421
|
} catch {}
|
|
2282
|
-
const ctx = `Project lessons — never reproduce these:\n${content}\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.`;
|
|
2283
|
-
return report ? attachSystemMessage(contextResponse(event, ctx), `LESSON.md curation:\n${report}`) : contextResponse(event, ctx);
|
|
2284
2422
|
}
|
|
2285
|
-
/**
|
|
2286
|
-
function
|
|
2423
|
+
/** Record `field` for `(sid, root)`, refreshing the purge cursor. `home` defaults to `$HOME`. */
|
|
2424
|
+
function markSessionRoot(sid, root, field, value, home = process.env.HOME) {
|
|
2425
|
+
const reg = read(home);
|
|
2426
|
+
const prev = reg[sid];
|
|
2427
|
+
const entry = prev && typeof prev.roots === "object" && prev.roots !== null ? prev : {
|
|
2428
|
+
updatedAt: value,
|
|
2429
|
+
roots: {}
|
|
2430
|
+
};
|
|
2431
|
+
const mark = entry.roots[root] ?? {
|
|
2432
|
+
editedAt: 0,
|
|
2433
|
+
remindedAt: 0
|
|
2434
|
+
};
|
|
2435
|
+
entry.roots[root] = {
|
|
2436
|
+
...mark,
|
|
2437
|
+
[field]: value
|
|
2438
|
+
};
|
|
2439
|
+
entry.updatedAt = value;
|
|
2440
|
+
reg[sid] = entry;
|
|
2441
|
+
write(home, reg, value);
|
|
2442
|
+
}
|
|
2443
|
+
/**
|
|
2444
|
+
* Roots of `sid` with an unsaved code edit past the `window`; each returned
|
|
2445
|
+
* root's `remindedAt` is bumped to `now` so the reminder fires at most once per
|
|
2446
|
+
* window and is consumed ONLY by this session. `home` defaults to `$HOME`.
|
|
2447
|
+
*/
|
|
2448
|
+
function collectSessionPending(sid, now, window, home = process.env.HOME) {
|
|
2449
|
+
const reg = read(home);
|
|
2450
|
+
const entry = reg[sid];
|
|
2451
|
+
if (!entry || typeof entry.roots !== "object" || entry.roots === null) return [];
|
|
2452
|
+
const pending = [];
|
|
2453
|
+
for (const [root, mark] of Object.entries(entry.roots)) {
|
|
2454
|
+
if (mark.editedAt <= mark.remindedAt) continue;
|
|
2455
|
+
if (now - mark.remindedAt < window) continue;
|
|
2456
|
+
pending.push(root);
|
|
2457
|
+
entry.roots[root] = {
|
|
2458
|
+
...mark,
|
|
2459
|
+
remindedAt: now
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
if (pending.length > 0) write(home, reg, now);
|
|
2463
|
+
return pending;
|
|
2464
|
+
}
|
|
2465
|
+
//#endregion
|
|
2466
|
+
//#region src/runtime/lifecycle/lessons/reminder.ts
|
|
2467
|
+
/**
|
|
2468
|
+
* fuse-lessons write-mark + Stop-reminder, scoped by `session_id` when present.
|
|
2469
|
+
*
|
|
2470
|
+
* WITH a session id (normal Claude Code): each `(session, root)` pair carries
|
|
2471
|
+
* its own edit/reminder throttle in {@link module:memory/session-roots}, so a
|
|
2472
|
+
* Stop lists and silences ONLY the roots THAT session edited — concurrent
|
|
2473
|
+
* sessions on different projects never cross-remind nor steal each other's
|
|
2474
|
+
* throttle. WITHOUT a usable session id (a harness that omits it, or the legacy
|
|
2475
|
+
* on-disk state) it falls back to the original mono-session behavior: the global
|
|
2476
|
+
* flat root registry + the per-project `MEMORY/state.json` throttle.
|
|
2477
|
+
*/
|
|
2478
|
+
/** Sanitized session id from a raw hook payload, or null (→ legacy fallback). */
|
|
2479
|
+
function sessionOf(payload) {
|
|
2480
|
+
return sanitizeSessionId(payload.session_id);
|
|
2481
|
+
}
|
|
2482
|
+
/** Legacy (no session id): pending roots across the global flat registry. */
|
|
2483
|
+
function collectLegacyPending(now, window) {
|
|
2287
2484
|
const pending = [];
|
|
2288
2485
|
for (const root of readRoots()) {
|
|
2289
2486
|
const stateFile = lessonsStateFileFor(root);
|
|
@@ -2295,26 +2492,99 @@ function collectPending(now, window) {
|
|
|
2295
2492
|
}
|
|
2296
2493
|
return pending;
|
|
2297
2494
|
}
|
|
2298
|
-
/** Stop
|
|
2299
|
-
function
|
|
2300
|
-
|
|
2495
|
+
/** Stop reminder body listing each pending project's lessons file. */
|
|
2496
|
+
function reminderText(pending) {
|
|
2497
|
+
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")}`;
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Stop: emit one reminder covering the stopping session's pending projects.
|
|
2501
|
+
* @param payload - Raw hook payload (`session_id` selects the scoped path).
|
|
2502
|
+
* @param now - Clock.
|
|
2503
|
+
* @returns Native Stop stdout, or "" when nothing is pending.
|
|
2504
|
+
*/
|
|
2505
|
+
function remindWrite(payload, now) {
|
|
2506
|
+
const window = throttleMs();
|
|
2507
|
+
const sid = sessionOf(payload);
|
|
2508
|
+
const pending = sid ? collectSessionPending(sid, now, window) : collectLegacyPending(now, window);
|
|
2301
2509
|
if (pending.length === 0) return "";
|
|
2302
|
-
return contextResponse("Stop",
|
|
2510
|
+
return contextResponse("Stop", reminderText(pending));
|
|
2303
2511
|
}
|
|
2304
|
-
/**
|
|
2512
|
+
/**
|
|
2513
|
+
* PostToolUse: record the edit against the throttle. A code file arms the
|
|
2514
|
+
* reminder; writing `MEMORY/LESSON.md` silences it (the lesson was just saved).
|
|
2515
|
+
* Session-scoped when `session_id` is present, else the legacy global path.
|
|
2516
|
+
* @param payload - Raw hook payload (`tool_input.file_path`, `session_id`).
|
|
2517
|
+
* @param now - Clock.
|
|
2518
|
+
*/
|
|
2305
2519
|
function markWrite(payload, now) {
|
|
2306
2520
|
const input = payload.tool_input;
|
|
2307
2521
|
if (!input?.file_path) return;
|
|
2308
2522
|
const abs = resolve(input.file_path);
|
|
2309
2523
|
const root = projectRootOrNull(dirname(abs));
|
|
2310
2524
|
if (!root) return;
|
|
2311
|
-
const
|
|
2312
|
-
if (
|
|
2313
|
-
|
|
2314
|
-
|
|
2525
|
+
const isLesson = abs === resolve(root, "MEMORY", "LESSON.md");
|
|
2526
|
+
if (!isLesson && !isCodeFile(abs)) return;
|
|
2527
|
+
const sid = sessionOf(payload);
|
|
2528
|
+
if (sid) markSessionRoot(sid, root, isLesson ? "remindedAt" : "editedAt", now);
|
|
2529
|
+
else if (isLesson) setStateField(lessonsStateFileFor(root), "lastRemindedAt", now);
|
|
2530
|
+
else {
|
|
2531
|
+
setStateField(lessonsStateFileFor(root), "lastCodeEditAt", now);
|
|
2315
2532
|
addRoot(root);
|
|
2316
2533
|
}
|
|
2317
2534
|
}
|
|
2535
|
+
//#endregion
|
|
2536
|
+
//#region src/runtime/lifecycle/lessons/dispatch.ts
|
|
2537
|
+
/**
|
|
2538
|
+
* fuse-lessons scope dispatch (TS port of the 4 handler scripts). Routes by
|
|
2539
|
+
* event: SessionStart/SubagentStart inject `MEMORY/LESSON.md`; Stop reminds the
|
|
2540
|
+
* stopping session about ITS OWN projects with unsaved code edits; PostToolUse
|
|
2541
|
+
* marks the write to arm/silence the throttle. The reminder + mark logic (incl.
|
|
2542
|
+
* the per-`session_id` scoping that fixes the multi-session misdirection) lives
|
|
2543
|
+
* in {@link module:runtime/lifecycle/lessons/reminder}; this module keeps the
|
|
2544
|
+
* event router + lesson-file injection. Non-fatal by design.
|
|
2545
|
+
*/
|
|
2546
|
+
/**
|
|
2547
|
+
* Persist a curation ATOMICALLY and ARCHIVE-FIRST for zero-loss: prepend the
|
|
2548
|
+
* moved bullets to LESSON-archive.md, THEN rewrite LESSON.md. On ANY write error
|
|
2549
|
+
* the original file is left untouched (returns `original`) so a bullet is never
|
|
2550
|
+
* lost — a rare archive-then-trim-fail leaves a duplicate (never a loss), which
|
|
2551
|
+
* the next dedup pass reconciles.
|
|
2552
|
+
*/
|
|
2553
|
+
function persistCuration(file, root, curated, archive, original) {
|
|
2554
|
+
try {
|
|
2555
|
+
if (archive) {
|
|
2556
|
+
const af = lessonsArchiveFileFor(root);
|
|
2557
|
+
const prev = existsSync(af) ? readFileSync(af, "utf-8") : "";
|
|
2558
|
+
atomicWrite(af, prev ? `${archive}\n${prev}` : archive);
|
|
2559
|
+
}
|
|
2560
|
+
atomicWrite(file, curated);
|
|
2561
|
+
return curated;
|
|
2562
|
+
} catch {
|
|
2563
|
+
return original;
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
/**
|
|
2567
|
+
* Inject `MEMORY/LESSON.md` for `event`. Mechanical curation (dedup + cap→archive)
|
|
2568
|
+
* rewrites the FILE; the injected BLOCK is then COMPRESSED (newest bullets whole,
|
|
2569
|
+
* older ones distilled to their rule) so a growing file never inflates the
|
|
2570
|
+
* SessionStart/SubagentStart context. Any curation report surfaces via systemMessage.
|
|
2571
|
+
*/
|
|
2572
|
+
function injectMemory(cwd, event, now) {
|
|
2573
|
+
const root = projectRoot(cwd);
|
|
2574
|
+
const file = lessonsFileFor(root);
|
|
2575
|
+
if (!existsSync(file)) return "";
|
|
2576
|
+
let content = "";
|
|
2577
|
+
try {
|
|
2578
|
+
content = readFileSync(file, "utf-8").trim();
|
|
2579
|
+
} catch {
|
|
2580
|
+
return "";
|
|
2581
|
+
}
|
|
2582
|
+
if (!content) return "";
|
|
2583
|
+
const { content: curated, archive, report } = curateLessons(content, now, root);
|
|
2584
|
+
if (curated !== content) content = persistCuration(file, root, curated, archive, content);
|
|
2585
|
+
const ctx = `Project lessons — never reproduce these:\n${compressInjection(content)}\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.`;
|
|
2586
|
+
return report ? attachSystemMessage(contextResponse(event, ctx), `LESSON.md curation:\n${report}`) : contextResponse(event, ctx);
|
|
2587
|
+
}
|
|
2318
2588
|
/**
|
|
2319
2589
|
* Route a fuse-lessons event to its handler. Returns the native stdout for
|
|
2320
2590
|
* context-injecting events (SessionStart/SubagentStart/Stop) or "" for the
|
|
@@ -2329,7 +2599,7 @@ function dispatchLessons(event, payload, cwd, now) {
|
|
|
2329
2599
|
switch (event) {
|
|
2330
2600
|
case "SessionStart":
|
|
2331
2601
|
case "SubagentStart": return injectMemory(cwd, event, now);
|
|
2332
|
-
case "Stop": return remindWrite(now);
|
|
2602
|
+
case "Stop": return remindWrite(payload, now);
|
|
2333
2603
|
case "PostToolUse":
|
|
2334
2604
|
markWrite(payload, now);
|
|
2335
2605
|
return "";
|
|
@@ -2337,6 +2607,559 @@ function dispatchLessons(event, payload, cwd, now) {
|
|
|
2337
2607
|
}
|
|
2338
2608
|
}
|
|
2339
2609
|
//#endregion
|
|
2610
|
+
//#region src/policy/deny-loop.ts
|
|
2611
|
+
/**
|
|
2612
|
+
* @module deny-loop
|
|
2613
|
+
* Pure anti-loop logic: hash a tool-call, decide if it repeats a prior deny, and
|
|
2614
|
+
* enrich the repeated block's message.
|
|
2615
|
+
*
|
|
2616
|
+
* The proprietary rule "NEVER propose the same fix twice" is prose a model under
|
|
2617
|
+
* pressure ignores. This makes it machine-enforced: when a call whose
|
|
2618
|
+
* `(tool + normalized input)` hash was ALREADY denied in-window is retried, the
|
|
2619
|
+
* harness keeps the deny but rewrites the message — `[REPEAT]` title, STOP
|
|
2620
|
+
* prefix, forced `research-expert` action. State + wiring live in the sidecar
|
|
2621
|
+
* store ({@link module:deny-loop-store}); this file is IO-free and pure.
|
|
2622
|
+
* @packageDocumentation
|
|
2623
|
+
*/
|
|
2624
|
+
/** Stable JSON: keys sorted at every depth so `{a,b}` and `{b,a}` hash identically. */
|
|
2625
|
+
function stableStringify(v) {
|
|
2626
|
+
if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
|
|
2627
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
|
|
2628
|
+
const o = v;
|
|
2629
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(",")}}`;
|
|
2630
|
+
}
|
|
2631
|
+
/**
|
|
2632
|
+
* Stable identity hash of a tool-call = tool name + normalized (key-sorted) input,
|
|
2633
|
+
* so re-ordered keys never mask a repeat.
|
|
2634
|
+
* @param tool - Tool name (e.g. "Write", "Bash").
|
|
2635
|
+
* @param input - Identifying tool input (filePath/content/command...).
|
|
2636
|
+
* @returns 8-char hex hash.
|
|
2637
|
+
*/
|
|
2638
|
+
function denyHash(tool, input) {
|
|
2639
|
+
return hashText(`${tool}\n${stableStringify(input)}`);
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* Pure loop check: given the already-pruned in-window map, compute the running
|
|
2643
|
+
* count for `hash` and whether it repeats (count > 1). No IO — the caller persists.
|
|
2644
|
+
* @param hash - {@link denyHash} of the current call.
|
|
2645
|
+
* @param priorDenies - The `{ hash -> DenyEntry }` map, pruned to `now`/`windowMs`.
|
|
2646
|
+
* @param opts - Clock + window.
|
|
2647
|
+
* @returns `{ isRepeat, count, hash }`.
|
|
2648
|
+
*/
|
|
2649
|
+
function denyLoopCheck(hash, priorDenies, opts) {
|
|
2650
|
+
const prev = priorDenies[hash];
|
|
2651
|
+
const count = (prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs ? prev.count : 0) + 1;
|
|
2652
|
+
return {
|
|
2653
|
+
isRepeat: count > 1,
|
|
2654
|
+
count,
|
|
2655
|
+
hash
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* Enrich a REPEATED block prompt — a NEW object, never a mutation (the input may
|
|
2660
|
+
* be a shared const like FAIL_CLOSED). The decision stays `block`; only the
|
|
2661
|
+
* message changes, so every harness renders it through the same adapter.
|
|
2662
|
+
* @param prompt - The original block prompt.
|
|
2663
|
+
* @param count - The running identical-deny count (n).
|
|
2664
|
+
* @returns A block prompt with `[REPEAT]` title, STOP-prefixed reason, forced research action.
|
|
2665
|
+
*/
|
|
2666
|
+
function enrichRepeatDeny(prompt, count) {
|
|
2667
|
+
const stop = `Identical attempt #${count} already denied for the same reason. STOP: do not retry this same call. `;
|
|
2668
|
+
const action = "Launch fuse-ai-pilot:research-expert to find a DIFFERENT approach";
|
|
2669
|
+
return {
|
|
2670
|
+
...prompt,
|
|
2671
|
+
title: prompt.title.startsWith("[REPEAT]") ? prompt.title : `[REPEAT] ${prompt.title}`,
|
|
2672
|
+
reason: stop + prompt.reason,
|
|
2673
|
+
actions: [action, ...prompt.actions ?? []]
|
|
2674
|
+
};
|
|
2675
|
+
}
|
|
2676
|
+
//#endregion
|
|
2677
|
+
//#region src/tracking/one-shot-store.ts
|
|
2678
|
+
/** A fresh, empty state — always spread (`{ ...EMPTY }`) so the const is never shared. */
|
|
2679
|
+
const EMPTY = {
|
|
2680
|
+
gates: {},
|
|
2681
|
+
firstTry: 0,
|
|
2682
|
+
corrected: 0,
|
|
2683
|
+
pending: {},
|
|
2684
|
+
updatedAt: 0
|
|
2685
|
+
};
|
|
2686
|
+
/**
|
|
2687
|
+
* Drop stale data: whole-state idle reset past the window, else per-entry prune of
|
|
2688
|
+
* gates/pending older than `windowMs`. Keeps the "7d" window honest, bounds size.
|
|
2689
|
+
*/
|
|
2690
|
+
function pruneState(s, now, windowMs) {
|
|
2691
|
+
if (now - s.updatedAt >= windowMs) return { ...EMPTY };
|
|
2692
|
+
const gates = {};
|
|
2693
|
+
for (const [k, g] of Object.entries(s.gates)) if (now - g.lastTs < windowMs) gates[k] = g;
|
|
2694
|
+
const pending = {};
|
|
2695
|
+
for (const [k, p] of Object.entries(s.pending)) if (now - p.ts < windowMs) pending[k] = p;
|
|
2696
|
+
return {
|
|
2697
|
+
...s,
|
|
2698
|
+
gates,
|
|
2699
|
+
pending
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
/**
|
|
2703
|
+
* Record a deny for gate `title` on operation `op` (content-free tool identity):
|
|
2704
|
+
* bump the gate's deny count and mark `op` pending for a later fix.
|
|
2705
|
+
*/
|
|
2706
|
+
function applyDeny(s, title, op, now) {
|
|
2707
|
+
const g = s.gates[title] ?? {
|
|
2708
|
+
denies: 0,
|
|
2709
|
+
corrected: 0,
|
|
2710
|
+
lastTs: 0
|
|
2711
|
+
};
|
|
2712
|
+
return {
|
|
2713
|
+
...s,
|
|
2714
|
+
gates: {
|
|
2715
|
+
...s.gates,
|
|
2716
|
+
[title]: {
|
|
2717
|
+
denies: g.denies + 1,
|
|
2718
|
+
corrected: g.corrected,
|
|
2719
|
+
lastTs: now
|
|
2720
|
+
}
|
|
2721
|
+
},
|
|
2722
|
+
pending: {
|
|
2723
|
+
...s.pending,
|
|
2724
|
+
[op]: {
|
|
2725
|
+
title,
|
|
2726
|
+
ts: now
|
|
2727
|
+
}
|
|
2728
|
+
},
|
|
2729
|
+
updatedAt: now
|
|
2730
|
+
};
|
|
2731
|
+
}
|
|
2732
|
+
/**
|
|
2733
|
+
* Record an allow for a gateable `op`. A non-gateable allow (Read/Task/MCP) leaves
|
|
2734
|
+
* state untouched — it never counts and never clears a pending deny. Otherwise: a
|
|
2735
|
+
* pending deny → `corrected` (a fix, credited to the blocking gate); no pending →
|
|
2736
|
+
* `firstTry` (one-shot).
|
|
2737
|
+
*/
|
|
2738
|
+
function applyAllow(s, op, now, gateable) {
|
|
2739
|
+
if (!gateable) return s;
|
|
2740
|
+
const pend = s.pending[op];
|
|
2741
|
+
if (pend) {
|
|
2742
|
+
const g = s.gates[pend.title] ?? {
|
|
2743
|
+
denies: 0,
|
|
2744
|
+
corrected: 0,
|
|
2745
|
+
lastTs: 0
|
|
2746
|
+
};
|
|
2747
|
+
const { [op]: _drop, ...pending } = s.pending;
|
|
2748
|
+
return {
|
|
2749
|
+
...s,
|
|
2750
|
+
gates: {
|
|
2751
|
+
...s.gates,
|
|
2752
|
+
[pend.title]: {
|
|
2753
|
+
...g,
|
|
2754
|
+
corrected: g.corrected + 1,
|
|
2755
|
+
lastTs: now
|
|
2756
|
+
}
|
|
2757
|
+
},
|
|
2758
|
+
corrected: s.corrected + 1,
|
|
2759
|
+
pending,
|
|
2760
|
+
updatedAt: now
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
return gateable ? {
|
|
2764
|
+
...s,
|
|
2765
|
+
firstTry: s.firstTry + 1,
|
|
2766
|
+
updatedAt: now
|
|
2767
|
+
} : s;
|
|
2768
|
+
}
|
|
2769
|
+
/**
|
|
2770
|
+
* Compact injectable summary (one line); "" when there is nothing to report.
|
|
2771
|
+
* @returns e.g. `gates 7d: 88% one-shot (44/50 clean); SOLID file-size limit 4den/3fix`.
|
|
2772
|
+
*/
|
|
2773
|
+
function formatSummary(s) {
|
|
2774
|
+
const keys = Object.keys(s.gates);
|
|
2775
|
+
const total = s.firstTry + s.corrected;
|
|
2776
|
+
if (keys.length === 0 && total === 0) return "";
|
|
2777
|
+
const head = total > 0 ? `${Math.round(s.firstTry / total * 100)}% one-shot (${s.firstTry}/${total} clean)` : "no clean pass yet";
|
|
2778
|
+
const parts = keys.map((k) => ({
|
|
2779
|
+
k,
|
|
2780
|
+
g: s.gates[k]
|
|
2781
|
+
})).sort((a, b) => b.g.denies - a.g.denies).map(({ k, g }) => `${k} ${g.denies}den/${g.corrected}fix`);
|
|
2782
|
+
return `gates 7d: ${head}${parts.length ? `; ${parts.join("; ")}` : ""}`;
|
|
2783
|
+
}
|
|
2784
|
+
//#endregion
|
|
2785
|
+
//#region src/tracking/one-shot.ts
|
|
2786
|
+
/**
|
|
2787
|
+
* @module one-shot
|
|
2788
|
+
* Sidecar store + gate wiring for the per-gate one-shot metric.
|
|
2789
|
+
*
|
|
2790
|
+
* STATE — a standalone sidecar (`one-shot.json`) in the same per-project state dir
|
|
2791
|
+
* as the session track, mirroring {@link module:deny-loop-store} (atomicWrite,
|
|
2792
|
+
* prune-by-window, fail-safe). A write error NEVER changes a gate decision nor its
|
|
2793
|
+
* prompt — metrics are pure observation.
|
|
2794
|
+
*
|
|
2795
|
+
* KEY — the operation identity is content-FREE (`tool + filePath/command`): a fix
|
|
2796
|
+
* changes the content, so a content hash would make every retry a new op and hide
|
|
2797
|
+
* the deny→allow transition this metric exists to see. The pure model lives in
|
|
2798
|
+
* {@link module:one-shot-store}; this file is the only IO surface.
|
|
2799
|
+
* @packageDocumentation
|
|
2800
|
+
*/
|
|
2801
|
+
/** Sidecar basename under the per-project state dir. */
|
|
2802
|
+
const SIDECAR$1 = "one-shot.json";
|
|
2803
|
+
/** Retention window: 7 days. Aggregates and pending denies older than this are pruned. */
|
|
2804
|
+
const WINDOW_MS = 10080 * 60 * 1e3;
|
|
2805
|
+
/** Load the state, or a fresh copy when missing/corrupt. */
|
|
2806
|
+
function loadState(path) {
|
|
2807
|
+
try {
|
|
2808
|
+
if (!existsSync(path)) return { ...EMPTY };
|
|
2809
|
+
const d = JSON.parse(readFileSync(path, "utf8"));
|
|
2810
|
+
return d && typeof d === "object" && !Array.isArray(d) ? {
|
|
2811
|
+
...EMPTY,
|
|
2812
|
+
...d
|
|
2813
|
+
} : { ...EMPTY };
|
|
2814
|
+
} catch {
|
|
2815
|
+
return { ...EMPTY };
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
/**
|
|
2819
|
+
* Record a gate outcome: a `block` is a deny for its gate title; a `null` allow is
|
|
2820
|
+
* a fix (if the op was pending) or a one-shot (if gateable). `ask`/`inform` are
|
|
2821
|
+
* neither and are skipped. Fails silently — a metric write NEVER affects a decision.
|
|
2822
|
+
*
|
|
2823
|
+
* The op key is tool-INDEPENDENT (`filePath`/`command` only, constant `"op"` tool):
|
|
2824
|
+
* a deny (a `Write`) and its fix (an `Edit`) on the same file must link.
|
|
2825
|
+
* @param prompt - The gate's outcome (block, allow=null, or ask/inform).
|
|
2826
|
+
* @param input - Identifying tool input (content decides gateability only).
|
|
2827
|
+
* @param opts - Clock + state dir.
|
|
2828
|
+
*/
|
|
2829
|
+
function recordOneShot(prompt, input, opts) {
|
|
2830
|
+
try {
|
|
2831
|
+
if (prompt && prompt.kind !== "block") return;
|
|
2832
|
+
const path = join(opts.dir, SIDECAR$1);
|
|
2833
|
+
let s = pruneState(loadState(path), opts.now, WINDOW_MS);
|
|
2834
|
+
const op = denyHash("op", {
|
|
2835
|
+
filePath: input.filePath,
|
|
2836
|
+
command: input.command
|
|
2837
|
+
});
|
|
2838
|
+
s = prompt ? applyDeny(s, prompt.title, op, opts.now) : applyAllow(s, op, opts.now, input.content != null || input.command != null);
|
|
2839
|
+
atomicWrite(path, JSON.stringify(s));
|
|
2840
|
+
} catch {}
|
|
2841
|
+
}
|
|
2842
|
+
/**
|
|
2843
|
+
* Compact, injection-ready one-shot summary for the project rooted at `cwd`. The
|
|
2844
|
+
* state dir is derived EXACTLY like the runtime writer ({@link defaultStateDir},
|
|
2845
|
+
* mirroring `handle.ts` `trackFile(sid, defaultStateDir(cwd))`), so the file read
|
|
2846
|
+
* here is the same one {@link recordOneShot} wrote. "" when no data or read error.
|
|
2847
|
+
* @param cwd - The project working directory (Claude `cwd`), NOT the state dir.
|
|
2848
|
+
* @returns One line, e.g. `gates 7d: 88% one-shot (44/50 clean); ...`, or "".
|
|
2849
|
+
*/
|
|
2850
|
+
function oneShotSummary(cwd) {
|
|
2851
|
+
try {
|
|
2852
|
+
return formatSummary(pruneState(loadState(join(defaultStateDir(cwd), SIDECAR$1)), Date.now(), WINDOW_MS));
|
|
2853
|
+
} catch {
|
|
2854
|
+
return "";
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
//#endregion
|
|
2858
|
+
//#region src/runtime/lifecycle/snapshot/git.ts
|
|
2859
|
+
/**
|
|
2860
|
+
* Run a git subcommand at `root` with a short timeout, returning trimmed stdout.
|
|
2861
|
+
* Uses `node:child_process` (the Bun shell can hang on some git plumbing) and
|
|
2862
|
+
* swallows every failure — a non-repo, missing git, or timeout yields `""` so
|
|
2863
|
+
* the caller omits the section instead of throwing inside the hook.
|
|
2864
|
+
* @param root - Directory to run git in.
|
|
2865
|
+
* @param args - The git args (e.g. `"log --oneline -3"`).
|
|
2866
|
+
* @returns Trimmed stdout, or `""` on any error.
|
|
2867
|
+
*/
|
|
2868
|
+
function git(root, args) {
|
|
2869
|
+
try {
|
|
2870
|
+
return execSync(`git ${args}`, {
|
|
2871
|
+
cwd: root,
|
|
2872
|
+
encoding: "utf8",
|
|
2873
|
+
timeout: 150,
|
|
2874
|
+
stdio: [
|
|
2875
|
+
"ignore",
|
|
2876
|
+
"pipe",
|
|
2877
|
+
"ignore"
|
|
2878
|
+
]
|
|
2879
|
+
}).trim();
|
|
2880
|
+
} catch {
|
|
2881
|
+
return "";
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
/** Count staged/unstaged/untracked files from porcelain v1 output (skips the `##` branch line). */
|
|
2885
|
+
function countWip(porcelain) {
|
|
2886
|
+
const w = {
|
|
2887
|
+
staged: 0,
|
|
2888
|
+
unstaged: 0,
|
|
2889
|
+
untracked: 0
|
|
2890
|
+
};
|
|
2891
|
+
for (const line of porcelain.split("\n")) {
|
|
2892
|
+
if (!line || line.startsWith("#")) continue;
|
|
2893
|
+
if (line.startsWith("??")) {
|
|
2894
|
+
w.untracked++;
|
|
2895
|
+
continue;
|
|
2896
|
+
}
|
|
2897
|
+
const x = line[0], y = line[1];
|
|
2898
|
+
if (x && x !== " " && x !== "?") w.staged++;
|
|
2899
|
+
if (y === "M" || y === "D") w.unstaged++;
|
|
2900
|
+
}
|
|
2901
|
+
return w;
|
|
2902
|
+
}
|
|
2903
|
+
/** Parse the current branch from the leading `## branch...upstream` porcelain line. */
|
|
2904
|
+
function parseBranch(porcelain) {
|
|
2905
|
+
const head = porcelain.split("\n")[0] ?? "";
|
|
2906
|
+
if (!head.startsWith("## ")) return "";
|
|
2907
|
+
const rest = head.slice(3);
|
|
2908
|
+
const dots = rest.indexOf("...");
|
|
2909
|
+
return (dots >= 0 ? rest.slice(0, dots) : rest).split(" ")[0] ?? "";
|
|
2910
|
+
}
|
|
2911
|
+
/**
|
|
2912
|
+
* Collect a compact git reconciliation section for `root`: current branch, the
|
|
2913
|
+
* last three commits (oneline), and staged/unstaged/untracked WIP counts. When
|
|
2914
|
+
* `root` is not a git repo (status fails) the whole section is omitted (`""`).
|
|
2915
|
+
* @param root - The project/repo root.
|
|
2916
|
+
* @returns The rendered git section body, or `""` when not a repo.
|
|
2917
|
+
*/
|
|
2918
|
+
function collectGit(root) {
|
|
2919
|
+
const status = git(root, "status --porcelain=v1 --branch");
|
|
2920
|
+
if (!status) return "";
|
|
2921
|
+
const branch = parseBranch(status) || "(unknown)";
|
|
2922
|
+
const w = countWip(status);
|
|
2923
|
+
const log = git(root, "log --oneline -3");
|
|
2924
|
+
const lines = [`- branch: ${branch}`];
|
|
2925
|
+
if (log) lines.push("- recent:", ...log.split("\n").map((l) => ` ${l}`));
|
|
2926
|
+
lines.push(`- WIP: ${w.staged} staged, ${w.unstaged} unstaged, ${w.untracked} untracked`);
|
|
2927
|
+
return lines.join("\n");
|
|
2928
|
+
}
|
|
2929
|
+
//#endregion
|
|
2930
|
+
//#region src/cli/doctor.ts
|
|
2931
|
+
/**
|
|
2932
|
+
* `harness doctor` — diagnose which `@fusengine/harness` is actually running.
|
|
2933
|
+
*
|
|
2934
|
+
* A confirmed, still-open bun bug (oven-sh/bun #5791; scoped-pkg behaviour
|
|
2935
|
+
* reinforced by #32019/#32150) makes `bunx <pkg>` (unpinned) prefer a stale
|
|
2936
|
+
* GLOBAL install over npm-latest, so a consumer can silently run an old harness
|
|
2937
|
+
* after a publish. This command surfaces the truth: the resolved version +
|
|
2938
|
+
* package path of the code executing right now, the runtime binary, and the
|
|
2939
|
+
* latest version published on npm. It queries the registry over HTTP (not
|
|
2940
|
+
* `npm view`, whose exit code is 0 even on an empty result — npm/cli#6408) and
|
|
2941
|
+
* never throws: an offline environment yields `latest: null`, never a crash.
|
|
2942
|
+
*/
|
|
2943
|
+
const PKG = "@fusengine/harness";
|
|
2944
|
+
/** Walk up from `startDir` for the `@fusengine/harness` `package.json`. */
|
|
2945
|
+
function findPackage(startDir) {
|
|
2946
|
+
let dir = startDir;
|
|
2947
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
2948
|
+
try {
|
|
2949
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
2950
|
+
if (pkg.name === PKG) return {
|
|
2951
|
+
version: pkg.version ?? "unknown",
|
|
2952
|
+
path: dir
|
|
2953
|
+
};
|
|
2954
|
+
} catch {}
|
|
2955
|
+
const parent = dirname(dir);
|
|
2956
|
+
if (parent === dir) break;
|
|
2957
|
+
dir = parent;
|
|
2958
|
+
}
|
|
2959
|
+
return null;
|
|
2960
|
+
}
|
|
2961
|
+
/** Resolve the running version + package path (no network), from a module URL. */
|
|
2962
|
+
function runningVersion(moduleUrl) {
|
|
2963
|
+
const found = findPackage(dirname(fileURLToPath(moduleUrl)));
|
|
2964
|
+
return {
|
|
2965
|
+
version: found?.version ?? "unknown",
|
|
2966
|
+
path: found?.path ?? "unknown"
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
/** One-line `pkg vX.Y.Z` banner (stderr, only on explicit `--version`/`doctor` commands — never on `hook`, to avoid spamming automated invocations). */
|
|
2970
|
+
function versionBanner(moduleUrl) {
|
|
2971
|
+
return `${PKG} v${runningVersion(moduleUrl).version}`;
|
|
2972
|
+
}
|
|
2973
|
+
/** Latest published version via the npm registry HTTP API. `null` on any failure. */
|
|
2974
|
+
async function npmLatest() {
|
|
2975
|
+
try {
|
|
2976
|
+
const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(8e3) });
|
|
2977
|
+
if (!res.ok) return null;
|
|
2978
|
+
return (await res.json()).version ?? null;
|
|
2979
|
+
} catch {
|
|
2980
|
+
return null;
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
/** Build the full diagnostic report for the module at `moduleUrl`. */
|
|
2984
|
+
async function buildDoctorReport(moduleUrl) {
|
|
2985
|
+
const { version, path } = runningVersion(moduleUrl);
|
|
2986
|
+
const latest = await npmLatest();
|
|
2987
|
+
return {
|
|
2988
|
+
running: version,
|
|
2989
|
+
packagePath: path,
|
|
2990
|
+
runtime: process.execPath,
|
|
2991
|
+
latest,
|
|
2992
|
+
stale: latest !== null && latest !== version
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
/** Render a {@link DoctorReport} as human-readable stdout text. */
|
|
2996
|
+
function formatDoctor(r) {
|
|
2997
|
+
const lines = [
|
|
2998
|
+
`${PKG} doctor`,
|
|
2999
|
+
` running: ${r.running}`,
|
|
3000
|
+
` package: ${r.packagePath}`,
|
|
3001
|
+
` runtime: ${r.runtime}`,
|
|
3002
|
+
` npm latest: ${r.latest ?? "(unavailable — offline or unreachable)"}`
|
|
3003
|
+
];
|
|
3004
|
+
if (r.stale) lines.push(` ! stale — npm serves ${r.latest}. Pin "@fusengine/harness@${r.latest}" in hooks.json (see README).`);
|
|
3005
|
+
else if (r.latest !== null) lines.push(` ok — running the latest published version.`);
|
|
3006
|
+
return lines.join("\n");
|
|
3007
|
+
}
|
|
3008
|
+
/** Run `harness doctor`: print the diagnostic to stdout. Always resolves 0 (pure info). */
|
|
3009
|
+
async function runDoctor(moduleUrl) {
|
|
3010
|
+
process.stdout.write(formatDoctor(await buildDoctorReport(moduleUrl)) + "\n");
|
|
3011
|
+
return 0;
|
|
3012
|
+
}
|
|
3013
|
+
//#endregion
|
|
3014
|
+
//#region src/runtime/lifecycle/snapshot/version.ts
|
|
3015
|
+
/** Read the `version` field of `<root>/package.json`, or `""` if absent/unreadable. */
|
|
3016
|
+
function pkgVersion(root) {
|
|
3017
|
+
try {
|
|
3018
|
+
return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version ?? "";
|
|
3019
|
+
} catch {
|
|
3020
|
+
return "";
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
/**
|
|
3024
|
+
* Collect the version reconciliation section: the harness version actually
|
|
3025
|
+
* running (resolved from {@link runningVersion}, no network) and, when `root`
|
|
3026
|
+
* carries its own `package.json`, whether that project's version has drifted
|
|
3027
|
+
* from the running harness.
|
|
3028
|
+
* @param root - The project root (cwd repo).
|
|
3029
|
+
* @param moduleUrl - `import.meta.url` of the calling module (locates the running package.json).
|
|
3030
|
+
* @returns The rendered version section body (never `""`).
|
|
3031
|
+
*/
|
|
3032
|
+
function collectVersion(root, moduleUrl) {
|
|
3033
|
+
const running = runningVersion(moduleUrl).version;
|
|
3034
|
+
const lines = [`- harness running: v${running}`];
|
|
3035
|
+
const project = pkgVersion(root);
|
|
3036
|
+
if (project && project !== running) lines.push(`- project package.json: v${project} (DRIFT — running harness differs)`);
|
|
3037
|
+
else if (project) lines.push(`- project package.json: v${project} (in sync)`);
|
|
3038
|
+
return lines.join("\n");
|
|
3039
|
+
}
|
|
3040
|
+
//#endregion
|
|
3041
|
+
//#region src/runtime/lifecycle/snapshot/board.ts
|
|
3042
|
+
/** Max board characters injected — a persistent board should stay small; over-long boards are truncated. */
|
|
3043
|
+
const MAX_BOARD = 4e3;
|
|
3044
|
+
/**
|
|
3045
|
+
* Collect the persistent task board: the contents of `<root>/.claude/BOARD.md`
|
|
3046
|
+
* (truncated to {@link MAX_BOARD}) plus an instruction to keep it current. The
|
|
3047
|
+
* board lives on disk so it survives context purges — rehydrated every session.
|
|
3048
|
+
* Missing/empty/unreadable board → `""` (section omitted).
|
|
3049
|
+
* @param root - The project root.
|
|
3050
|
+
* @returns The rendered board section body, or `""` when there is no board.
|
|
3051
|
+
*/
|
|
3052
|
+
function collectBoard(root) {
|
|
3053
|
+
const path = join(root, ".claude", "BOARD.md");
|
|
3054
|
+
try {
|
|
3055
|
+
if (!existsSync(path)) return "";
|
|
3056
|
+
let body = readFileSync(path, "utf8").trim();
|
|
3057
|
+
if (!body) return "";
|
|
3058
|
+
if (body.length > MAX_BOARD) body = `${body.slice(0, MAX_BOARD)}\n… (truncated)`;
|
|
3059
|
+
return `- .claude/BOARD.md (keep current — Write to it as tasks start/finish):\n\n${body}`;
|
|
3060
|
+
} catch {
|
|
3061
|
+
return "";
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
//#endregion
|
|
3065
|
+
//#region src/runtime/lifecycle/snapshot/format.ts
|
|
3066
|
+
/**
|
|
3067
|
+
* Render the non-empty `sections` under one reconciliation heading. Empty
|
|
3068
|
+
* sections are dropped; when every section is empty the whole snapshot is `""`.
|
|
3069
|
+
* @param sections - The collected sections in display order.
|
|
3070
|
+
* @returns The assembled markdown block, or `""` when nothing to report.
|
|
3071
|
+
*/
|
|
3072
|
+
function renderSections(sections) {
|
|
3073
|
+
const parts = sections.filter((s) => s.body.trim()).map((s) => `### ${s.title}\n${s.body.trim()}`);
|
|
3074
|
+
if (!parts.length) return "";
|
|
3075
|
+
return `# Reconciliation snapshot
|
|
3076
|
+
Real state of the world at session start — reconcile against this instead of re-discovering it.\n\n${parts.join("\n\n")}`;
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Concatenate `snapshot` onto an existing SessionStart stdout's
|
|
3080
|
+
* `additionalContext` — it never replaces prior injected context (CLAUDE.md,
|
|
3081
|
+
* dev-context). When `stdout` is empty a fresh {@link contextResponse} is made;
|
|
3082
|
+
* a non-empty but unparseable `stdout` is returned UNCHANGED (the snapshot is
|
|
3083
|
+
* dropped) — fabricating a fresh response there would discard the very CLAUDE.md
|
|
3084
|
+
* injection the invariant protects, so preserving prior context always wins.
|
|
3085
|
+
* @param stdout - The core SessionStart JSON stdout (may be `""`).
|
|
3086
|
+
* @param snapshot - The snapshot markdown to append (no-op when `""`).
|
|
3087
|
+
* @returns The merged hook stdout JSON.
|
|
3088
|
+
*/
|
|
3089
|
+
function attachSnapshot(stdout, snapshot) {
|
|
3090
|
+
if (!snapshot) return stdout;
|
|
3091
|
+
if (!stdout) return contextResponse("SessionStart", snapshot);
|
|
3092
|
+
try {
|
|
3093
|
+
const parsed = JSON.parse(stdout);
|
|
3094
|
+
const prev = parsed.hookSpecificOutput?.additionalContext ?? "";
|
|
3095
|
+
const merged = prev ? `${prev}\n\n${snapshot}` : snapshot;
|
|
3096
|
+
return JSON.stringify({
|
|
3097
|
+
...parsed,
|
|
3098
|
+
hookSpecificOutput: {
|
|
3099
|
+
...parsed.hookSpecificOutput,
|
|
3100
|
+
hookEventName: "SessionStart",
|
|
3101
|
+
additionalContext: merged
|
|
3102
|
+
}
|
|
3103
|
+
});
|
|
3104
|
+
} catch {
|
|
3105
|
+
return stdout;
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
//#endregion
|
|
3109
|
+
//#region src/runtime/lifecycle/snapshot/index.ts
|
|
3110
|
+
/** Run `fn`, swallowing any throw into `""` so no single collector can break the hook. */
|
|
3111
|
+
function safe(fn) {
|
|
3112
|
+
try {
|
|
3113
|
+
return fn();
|
|
3114
|
+
} catch {
|
|
3115
|
+
return "";
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
/**
|
|
3119
|
+
* Build the reconciliation snapshot markdown for `cwd`: git state, running
|
|
3120
|
+
* harness version + drift, the persistent board, and one-shot gate status. Each
|
|
3121
|
+
* collector is isolated by {@link safe}; an all-empty result yields `""`.
|
|
3122
|
+
* @param cwd - The session working directory.
|
|
3123
|
+
* @param moduleUrl - `import.meta.url` of the caller (locates the running package).
|
|
3124
|
+
* @returns The snapshot markdown, or `""` when nothing to report.
|
|
3125
|
+
*/
|
|
3126
|
+
function renderSnapshot(cwd, moduleUrl) {
|
|
3127
|
+
const root = projectRootOrNull(cwd) ?? cwd;
|
|
3128
|
+
return renderSections([
|
|
3129
|
+
{
|
|
3130
|
+
title: "Git",
|
|
3131
|
+
body: safe(() => collectGit(root))
|
|
3132
|
+
},
|
|
3133
|
+
{
|
|
3134
|
+
title: "Version",
|
|
3135
|
+
body: safe(() => collectVersion(root, moduleUrl))
|
|
3136
|
+
},
|
|
3137
|
+
{
|
|
3138
|
+
title: "Board",
|
|
3139
|
+
body: safe(() => collectBoard(root))
|
|
3140
|
+
},
|
|
3141
|
+
{
|
|
3142
|
+
title: "One-shot gates",
|
|
3143
|
+
body: safe(() => oneShotSummary(cwd))
|
|
3144
|
+
}
|
|
3145
|
+
]);
|
|
3146
|
+
}
|
|
3147
|
+
/**
|
|
3148
|
+
* Concatenate the reconciliation snapshot onto a core SessionStart stdout. Fully
|
|
3149
|
+
* fail-safe: any error returns `stdout` unchanged so the hook never breaks.
|
|
3150
|
+
* @param stdout - The core SessionStart JSON stdout (may be `""`).
|
|
3151
|
+
* @param cwd - The session working directory.
|
|
3152
|
+
* @param moduleUrl - `import.meta.url` of the caller.
|
|
3153
|
+
* @returns The merged hook stdout.
|
|
3154
|
+
*/
|
|
3155
|
+
function withSnapshot(stdout, cwd, moduleUrl) {
|
|
3156
|
+
try {
|
|
3157
|
+
return attachSnapshot(stdout, renderSnapshot(cwd, moduleUrl));
|
|
3158
|
+
} catch {
|
|
3159
|
+
return stdout;
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
//#endregion
|
|
2340
3163
|
//#region src/runtime/lifecycle/aipilot/inject-apex.ts
|
|
2341
3164
|
/**
|
|
2342
3165
|
* SubagentStart (matcher "") for the ai-pilot scope: inject APEX AGENTS.md +
|
|
@@ -3596,7 +4419,8 @@ function sessionStart(input) {
|
|
|
3596
4419
|
if (input.scope === "rules") return injectRules(process.env.CLAUDE_PLUGIN_ROOT ?? input.cwd);
|
|
3597
4420
|
if (input.scope === "carto") return cartoSessionStart(input.cwd, input.now);
|
|
3598
4421
|
if (input.scope === "lessons") return dispatchLessons("SessionStart", input.payload, input.cwd, input.now);
|
|
3599
|
-
|
|
4422
|
+
const core = sessionStartCore(input.cwd, void 0, input.now);
|
|
4423
|
+
return input.scope === "core" ? withSnapshot(core, input.cwd, import.meta.url) : core;
|
|
3600
4424
|
}
|
|
3601
4425
|
/**
|
|
3602
4426
|
* Route a lifecycle/session/context hook event to its ported handler. Returns
|
|
@@ -4984,73 +5808,6 @@ async function apexScopedGate(input, track, window) {
|
|
|
4984
5808
|
}
|
|
4985
5809
|
}
|
|
4986
5810
|
//#endregion
|
|
4987
|
-
//#region src/policy/deny-loop.ts
|
|
4988
|
-
/**
|
|
4989
|
-
* @module deny-loop
|
|
4990
|
-
* Pure anti-loop logic: hash a tool-call, decide if it repeats a prior deny, and
|
|
4991
|
-
* enrich the repeated block's message.
|
|
4992
|
-
*
|
|
4993
|
-
* The proprietary rule "NEVER propose the same fix twice" is prose a model under
|
|
4994
|
-
* pressure ignores. This makes it machine-enforced: when a call whose
|
|
4995
|
-
* `(tool + normalized input)` hash was ALREADY denied in-window is retried, the
|
|
4996
|
-
* harness keeps the deny but rewrites the message — `[REPEAT]` title, STOP
|
|
4997
|
-
* prefix, forced `research-expert` action. State + wiring live in the sidecar
|
|
4998
|
-
* store ({@link module:deny-loop-store}); this file is IO-free and pure.
|
|
4999
|
-
* @packageDocumentation
|
|
5000
|
-
*/
|
|
5001
|
-
/** Stable JSON: keys sorted at every depth so `{a,b}` and `{b,a}` hash identically. */
|
|
5002
|
-
function stableStringify(v) {
|
|
5003
|
-
if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
|
|
5004
|
-
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
|
|
5005
|
-
const o = v;
|
|
5006
|
-
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(",")}}`;
|
|
5007
|
-
}
|
|
5008
|
-
/**
|
|
5009
|
-
* Stable identity hash of a tool-call = tool name + normalized (key-sorted) input,
|
|
5010
|
-
* so re-ordered keys never mask a repeat.
|
|
5011
|
-
* @param tool - Tool name (e.g. "Write", "Bash").
|
|
5012
|
-
* @param input - Identifying tool input (filePath/content/command...).
|
|
5013
|
-
* @returns 8-char hex hash.
|
|
5014
|
-
*/
|
|
5015
|
-
function denyHash(tool, input) {
|
|
5016
|
-
return hashText(`${tool}\n${stableStringify(input)}`);
|
|
5017
|
-
}
|
|
5018
|
-
/**
|
|
5019
|
-
* Pure loop check: given the already-pruned in-window map, compute the running
|
|
5020
|
-
* count for `hash` and whether it repeats (count > 1). No IO — the caller persists.
|
|
5021
|
-
* @param hash - {@link denyHash} of the current call.
|
|
5022
|
-
* @param priorDenies - The `{ hash -> DenyEntry }` map, pruned to `now`/`windowMs`.
|
|
5023
|
-
* @param opts - Clock + window.
|
|
5024
|
-
* @returns `{ isRepeat, count, hash }`.
|
|
5025
|
-
*/
|
|
5026
|
-
function denyLoopCheck(hash, priorDenies, opts) {
|
|
5027
|
-
const prev = priorDenies[hash];
|
|
5028
|
-
const count = (prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs ? prev.count : 0) + 1;
|
|
5029
|
-
return {
|
|
5030
|
-
isRepeat: count > 1,
|
|
5031
|
-
count,
|
|
5032
|
-
hash
|
|
5033
|
-
};
|
|
5034
|
-
}
|
|
5035
|
-
/**
|
|
5036
|
-
* Enrich a REPEATED block prompt — a NEW object, never a mutation (the input may
|
|
5037
|
-
* be a shared const like FAIL_CLOSED). The decision stays `block`; only the
|
|
5038
|
-
* message changes, so every harness renders it through the same adapter.
|
|
5039
|
-
* @param prompt - The original block prompt.
|
|
5040
|
-
* @param count - The running identical-deny count (n).
|
|
5041
|
-
* @returns A block prompt with `[REPEAT]` title, STOP-prefixed reason, forced research action.
|
|
5042
|
-
*/
|
|
5043
|
-
function enrichRepeatDeny(prompt, count) {
|
|
5044
|
-
const stop = `Tentative identique n°${count} déjà refusée pour la même raison. STOP: ne retente pas ce même appel. `;
|
|
5045
|
-
const action = "Launch fuse-ai-pilot:research-expert to find a DIFFERENT approach";
|
|
5046
|
-
return {
|
|
5047
|
-
...prompt,
|
|
5048
|
-
title: prompt.title.startsWith("[REPEAT]") ? prompt.title : `[REPEAT] ${prompt.title}`,
|
|
5049
|
-
reason: stop + prompt.reason,
|
|
5050
|
-
actions: [action, ...prompt.actions ?? []]
|
|
5051
|
-
};
|
|
5052
|
-
}
|
|
5053
|
-
//#endregion
|
|
5054
5811
|
//#region src/runtime/deny-loop-store.ts
|
|
5055
5812
|
/**
|
|
5056
5813
|
* @module deny-loop-store
|
|
@@ -5138,18 +5895,25 @@ const DEFAULT_WINDOW_MS = 12e4;
|
|
|
5138
5895
|
/** Trivial edits allowed within the window before the full APEX gates apply. */
|
|
5139
5896
|
const TRIVIAL_BUDGET = 4;
|
|
5140
5897
|
/**
|
|
5141
|
-
* Full gate: {@link runGates} yields the first blocking prompt (or null); the
|
|
5142
|
-
*
|
|
5143
|
-
*
|
|
5898
|
+
* Full gate: {@link runGates} yields the first blocking prompt (or null); the tail
|
|
5899
|
+
* records the one-shot metric ({@link recordOneShot}, observation-only) then lets
|
|
5900
|
+
* {@link withDenyLoop} rewrite an identical retried deny (decision unchanged).
|
|
5144
5901
|
*/
|
|
5145
5902
|
async function gate(input) {
|
|
5146
|
-
|
|
5903
|
+
const prompt = await runGates(input);
|
|
5904
|
+
const op = {
|
|
5147
5905
|
filePath: input.filePath,
|
|
5148
5906
|
content: input.content,
|
|
5149
5907
|
command: input.command
|
|
5150
|
-
}
|
|
5908
|
+
};
|
|
5909
|
+
const dir = dirname(input.trackFile);
|
|
5910
|
+
recordOneShot(prompt, op, {
|
|
5911
|
+
now: input.now,
|
|
5912
|
+
dir
|
|
5913
|
+
});
|
|
5914
|
+
return withDenyLoop(prompt, input.tool, op, {
|
|
5151
5915
|
now: input.now,
|
|
5152
|
-
dir
|
|
5916
|
+
dir,
|
|
5153
5917
|
windowMs: input.windowMs ?? 12e4
|
|
5154
5918
|
});
|
|
5155
5919
|
}
|
|
@@ -5276,7 +6040,7 @@ function docSourceOf$1(tool) {
|
|
|
5276
6040
|
function cacheHitText(web, hit) {
|
|
5277
6041
|
const kb = Math.floor(hit.body.length / 1024) + 1;
|
|
5278
6042
|
const hours = Math.floor(hit.ageMs / 36e5);
|
|
5279
|
-
return web ? `CACHE HIT WebFetch (~${kb}KB
|
|
6043
|
+
return web ? `CACHE HIT WebFetch (~${kb}KB saved, cached ${hours}h ago):\n\n${hit.body}\n\nModify the URL or query to force a fresh fetch.` : `CACHE HIT (~${kb}KB saved, cached ${hours}h ago): ${hit.body}\n\nRephrase to force a re-call.`;
|
|
5280
6044
|
}
|
|
5281
6045
|
/**
|
|
5282
6046
|
* Pre-event MCP interception: serve a fresh cache hit (deny + cached content),
|
|
@@ -6420,4 +7184,4 @@ async function handleHook(id, payload, opts) {
|
|
|
6420
7184
|
});
|
|
6421
7185
|
}
|
|
6422
7186
|
//#endregion
|
|
6423
|
-
export {
|
|
7187
|
+
export { postEditTypescript as $, trackSkillRead as A, trackFile as At, lessonsFileFor as B, seoPostToolUse as C, gitContext as Ct, postTrackingSideEffects as D, taskContext as Dt, securityAdvisory as E, promptSubmitContext as Et, runDoctor as F, securityStateDir as Ft, generateProjectMap as G, cartoSessionStart as H, runningVersion as I, securityStatePath as It, loadEnriched as J, isProject as K, versionBanner as L, todayUtc as Lt, dispatchLifecycle as M, isoUtc as Mt, aipilotPostToolUse as N, loadSecurityState as Nt, trackWatchResearch as O, defaultStateDir as Ot, dispatchAipilot as P, saveSecurityState as Pt, listChildren as Q, dispatchLessons as R, postEditContext as S, devContext as St, dispatchMemory as T, claudeMdKey as Tt, generateEcosystemMap as U, lessonsStateFileFor as V, writePluginMap as W, countFiles as X, mergeLines as Y, getFileDesc as Z, preCommitGate as _, sessionStartCore as _t, recordActivity as a, validateTeammateOutput as at, extractSymbols as b, removeOldFiles as bt, MCP_TTL_MS as c, validateTailwind as ct, isMcpTool as d, countLoc as dt, trackSessionChanges as et, queryOf as f, detectSolidProfile as ft, gate as g, runSessionStartCleanups as gt, TRIVIAL_BUDGET as h, readRules as ht, respond as i, logToolFailure as it, trackEnrichment as j, normalizeEvent as jt, trackMcpResearch as k, projectHash$1 as kt, WEBFETCH_TTL_MS as l, validateSolidGate as lt, REQUIRED_AGENTS as m, injectRules as mt, activityFor as n, cleanupSession as nt, mcpPostStore as o, trackAgentMemory as ot, DEFAULT_WINDOW_MS as p, solidDetectStart as pt, writeTree as q, handlePre as r, saveApexState as rt, mcpPreIntercept as s, subagentCacheContext as st, handleHook as t, validateRulesLoaded as tt, cacheQueryOf as u, checkFileSize as ut, detectDuplication as v, pruneEmptyDirs as vt, seoPostToolUseResponse as w, projectContext as wt, lifecycleStdout as x, trimLogFile as xt, dryGate as y, purgeTtlTree as yt, lessonsArchiveFileFor as z };
|