@fusengine/harness 0.1.52 → 0.1.53
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/dist/cli/bin.mjs
CHANGED
|
@@ -4,11 +4,10 @@ import { t as detectHarness } from "../harness-Cb9xR8dC.mjs";
|
|
|
4
4
|
import { t as claudeHome } from "../home-state-D0RLWP8J.mjs";
|
|
5
5
|
import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-DZvP_9xB.mjs";
|
|
6
6
|
import { n as writeInitFile, t as initFor } from "../run-Do2JltgU.mjs";
|
|
7
|
-
import {
|
|
8
|
-
import { delimiter,
|
|
7
|
+
import { F as runDoctor, I as runningVersion, It as todayUtc, L as versionBanner, t as handleHook } from "../handle-D93-VIGS.mjs";
|
|
8
|
+
import { delimiter, join } from "node:path";
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
|
-
import { fileURLToPath } from "node:url";
|
|
12
11
|
//#region src/changelog/fetch.ts
|
|
13
12
|
/**
|
|
14
13
|
* Changelog scanner — ports the changelog-watcher plugin's `fetch-changelog`
|
|
@@ -354,90 +353,6 @@ function discoverRefs(home, cwd, marketplaces) {
|
|
|
354
353
|
return [...bySkill.values()].join(delimiter);
|
|
355
354
|
}
|
|
356
355
|
//#endregion
|
|
357
|
-
//#region src/cli/doctor.ts
|
|
358
|
-
/**
|
|
359
|
-
* `harness doctor` — diagnose which `@fusengine/harness` is actually running.
|
|
360
|
-
*
|
|
361
|
-
* A confirmed, still-open bun bug (oven-sh/bun #5791; scoped-pkg behaviour
|
|
362
|
-
* reinforced by #32019/#32150) makes `bunx <pkg>` (unpinned) prefer a stale
|
|
363
|
-
* GLOBAL install over npm-latest, so a consumer can silently run an old harness
|
|
364
|
-
* after a publish. This command surfaces the truth: the resolved version +
|
|
365
|
-
* package path of the code executing right now, the runtime binary, and the
|
|
366
|
-
* latest version published on npm. It queries the registry over HTTP (not
|
|
367
|
-
* `npm view`, whose exit code is 0 even on an empty result — npm/cli#6408) and
|
|
368
|
-
* never throws: an offline environment yields `latest: null`, never a crash.
|
|
369
|
-
*/
|
|
370
|
-
const PKG = "@fusengine/harness";
|
|
371
|
-
/** Walk up from `startDir` for the `@fusengine/harness` `package.json`. */
|
|
372
|
-
function findPackage(startDir) {
|
|
373
|
-
let dir = startDir;
|
|
374
|
-
for (let depth = 0; depth < 6; depth++) {
|
|
375
|
-
try {
|
|
376
|
-
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
377
|
-
if (pkg.name === PKG) return {
|
|
378
|
-
version: pkg.version ?? "unknown",
|
|
379
|
-
path: dir
|
|
380
|
-
};
|
|
381
|
-
} catch {}
|
|
382
|
-
const parent = dirname(dir);
|
|
383
|
-
if (parent === dir) break;
|
|
384
|
-
dir = parent;
|
|
385
|
-
}
|
|
386
|
-
return null;
|
|
387
|
-
}
|
|
388
|
-
/** Resolve the running version + package path (no network), from a module URL. */
|
|
389
|
-
function runningVersion(moduleUrl) {
|
|
390
|
-
const found = findPackage(dirname(fileURLToPath(moduleUrl)));
|
|
391
|
-
return {
|
|
392
|
-
version: found?.version ?? "unknown",
|
|
393
|
-
path: found?.path ?? "unknown"
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
/** One-line `pkg vX.Y.Z` banner (stderr, only on explicit `--version`/`doctor` commands — never on `hook`, to avoid spamming automated invocations). */
|
|
397
|
-
function versionBanner(moduleUrl) {
|
|
398
|
-
return `${PKG} v${runningVersion(moduleUrl).version}`;
|
|
399
|
-
}
|
|
400
|
-
/** Latest published version via the npm registry HTTP API. `null` on any failure. */
|
|
401
|
-
async function npmLatest() {
|
|
402
|
-
try {
|
|
403
|
-
const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(8e3) });
|
|
404
|
-
if (!res.ok) return null;
|
|
405
|
-
return (await res.json()).version ?? null;
|
|
406
|
-
} catch {
|
|
407
|
-
return null;
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
/** Build the full diagnostic report for the module at `moduleUrl`. */
|
|
411
|
-
async function buildDoctorReport(moduleUrl) {
|
|
412
|
-
const { version, path } = runningVersion(moduleUrl);
|
|
413
|
-
const latest = await npmLatest();
|
|
414
|
-
return {
|
|
415
|
-
running: version,
|
|
416
|
-
packagePath: path,
|
|
417
|
-
runtime: process.execPath,
|
|
418
|
-
latest,
|
|
419
|
-
stale: latest !== null && latest !== version
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
/** Render a {@link DoctorReport} as human-readable stdout text. */
|
|
423
|
-
function formatDoctor(r) {
|
|
424
|
-
const lines = [
|
|
425
|
-
`${PKG} doctor`,
|
|
426
|
-
` running: ${r.running}`,
|
|
427
|
-
` package: ${r.packagePath}`,
|
|
428
|
-
` runtime: ${r.runtime}`,
|
|
429
|
-
` npm latest: ${r.latest ?? "(unavailable — offline or unreachable)"}`
|
|
430
|
-
];
|
|
431
|
-
if (r.stale) lines.push(` ! stale — npm serves ${r.latest}. Pin "@fusengine/harness@${r.latest}" in hooks.json (see README).`);
|
|
432
|
-
else if (r.latest !== null) lines.push(` ok — running the latest published version.`);
|
|
433
|
-
return lines.join("\n");
|
|
434
|
-
}
|
|
435
|
-
/** Run `harness doctor`: print the diagnostic to stdout. Always resolves 0 (pure info). */
|
|
436
|
-
async function runDoctor(moduleUrl) {
|
|
437
|
-
process.stdout.write(formatDoctor(await buildDoctorReport(moduleUrl)) + "\n");
|
|
438
|
-
return 0;
|
|
439
|
-
}
|
|
440
|
-
//#endregion
|
|
441
356
|
//#region src/cli/bin.ts
|
|
442
357
|
/**
|
|
443
358
|
* harness — CLI for @fusengine/harness.
|
|
@@ -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
|
}
|
|
@@ -2255,35 +2256,107 @@ function lessonsStateFileFor(root) {
|
|
|
2255
2256
|
return join(root, "MEMORY", "state.json");
|
|
2256
2257
|
}
|
|
2257
2258
|
//#endregion
|
|
2258
|
-
//#region src/
|
|
2259
|
-
/**
|
|
2260
|
-
*
|
|
2261
|
-
*
|
|
2262
|
-
*
|
|
2263
|
-
*
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2259
|
+
//#region src/memory/session-roots.ts
|
|
2260
|
+
/**
|
|
2261
|
+
* Session-scoped lessons roots registry. The flat {@link module:memory/registry}
|
|
2262
|
+
* keeps ONE global list of pending roots — correct mono-session, but wrong with
|
|
2263
|
+
* several concurrent Claude Code sessions: at Stop, one session lists (and, by
|
|
2264
|
+
* bumping the throttle, STEALS) another session's pending lesson on a project it
|
|
2265
|
+
* never touched. This registry keys "which project got code edits, and was its
|
|
2266
|
+
* Stop reminder already fired" by `session_id`, so each Stop sees and consumes
|
|
2267
|
+
* ONLY its own roots. Stored at `$HOME/.fuse-harness/cache/lessons/session-roots.json`;
|
|
2268
|
+
* non-fatal on any I/O failure (a missed reminder never blocks a session).
|
|
2269
|
+
*/
|
|
2270
|
+
/** Registry path (rel. home) + stale-bucket purge horizon (bounds growth). */
|
|
2271
|
+
const SUBPATH = ".fuse-harness/cache/lessons/session-roots.json";
|
|
2272
|
+
const PURGE_MS = 10080 * 60 * 1e3;
|
|
2273
|
+
/** Absolute registry path, or null when home is unusable. */
|
|
2274
|
+
function file(home) {
|
|
2275
|
+
const h = home?.trim();
|
|
2276
|
+
return h && h.startsWith("/") ? `${h}/${SUBPATH}` : null;
|
|
2277
|
+
}
|
|
2278
|
+
/** Read the registry; missing/corrupt/legacy (array) shapes collapse to `{}`. */
|
|
2279
|
+
function read(home) {
|
|
2280
|
+
const f = file(home);
|
|
2281
|
+
if (!f) return {};
|
|
2271
2282
|
try {
|
|
2272
|
-
|
|
2283
|
+
const parsed = JSON.parse(readFileSync(f, "utf8"));
|
|
2284
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
2273
2285
|
} catch {
|
|
2274
|
-
return
|
|
2286
|
+
return {};
|
|
2275
2287
|
}
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2288
|
+
}
|
|
2289
|
+
/** Purge stale buckets, then atomically persist (unique tmp + rename). Non-throwing. */
|
|
2290
|
+
function write(home, reg, now) {
|
|
2291
|
+
const f = file(home);
|
|
2292
|
+
if (!f) return;
|
|
2293
|
+
for (const [sid, entry] of Object.entries(reg)) if (!entry || now - (entry.updatedAt ?? 0) > PURGE_MS) delete reg[sid];
|
|
2294
|
+
try {
|
|
2295
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
2296
|
+
atomicWrite(f, JSON.stringify(reg));
|
|
2281
2297
|
} 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
2298
|
}
|
|
2285
|
-
/**
|
|
2286
|
-
function
|
|
2299
|
+
/** Record `field` for `(sid, root)`, refreshing the purge cursor. `home` defaults to `$HOME`. */
|
|
2300
|
+
function markSessionRoot(sid, root, field, value, home = process.env.HOME) {
|
|
2301
|
+
const reg = read(home);
|
|
2302
|
+
const prev = reg[sid];
|
|
2303
|
+
const entry = prev && typeof prev.roots === "object" && prev.roots !== null ? prev : {
|
|
2304
|
+
updatedAt: value,
|
|
2305
|
+
roots: {}
|
|
2306
|
+
};
|
|
2307
|
+
const mark = entry.roots[root] ?? {
|
|
2308
|
+
editedAt: 0,
|
|
2309
|
+
remindedAt: 0
|
|
2310
|
+
};
|
|
2311
|
+
entry.roots[root] = {
|
|
2312
|
+
...mark,
|
|
2313
|
+
[field]: value
|
|
2314
|
+
};
|
|
2315
|
+
entry.updatedAt = value;
|
|
2316
|
+
reg[sid] = entry;
|
|
2317
|
+
write(home, reg, value);
|
|
2318
|
+
}
|
|
2319
|
+
/**
|
|
2320
|
+
* Roots of `sid` with an unsaved code edit past the `window`; each returned
|
|
2321
|
+
* root's `remindedAt` is bumped to `now` so the reminder fires at most once per
|
|
2322
|
+
* window and is consumed ONLY by this session. `home` defaults to `$HOME`.
|
|
2323
|
+
*/
|
|
2324
|
+
function collectSessionPending(sid, now, window, home = process.env.HOME) {
|
|
2325
|
+
const reg = read(home);
|
|
2326
|
+
const entry = reg[sid];
|
|
2327
|
+
if (!entry || typeof entry.roots !== "object" || entry.roots === null) return [];
|
|
2328
|
+
const pending = [];
|
|
2329
|
+
for (const [root, mark] of Object.entries(entry.roots)) {
|
|
2330
|
+
if (mark.editedAt <= mark.remindedAt) continue;
|
|
2331
|
+
if (now - mark.remindedAt < window) continue;
|
|
2332
|
+
pending.push(root);
|
|
2333
|
+
entry.roots[root] = {
|
|
2334
|
+
...mark,
|
|
2335
|
+
remindedAt: now
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
if (pending.length > 0) write(home, reg, now);
|
|
2339
|
+
return pending;
|
|
2340
|
+
}
|
|
2341
|
+
//#endregion
|
|
2342
|
+
//#region src/runtime/lifecycle/lessons/reminder.ts
|
|
2343
|
+
/**
|
|
2344
|
+
* fuse-lessons write-mark + Stop-reminder, scoped by `session_id` when present.
|
|
2345
|
+
*
|
|
2346
|
+
* WITH a session id (normal Claude Code): each `(session, root)` pair carries
|
|
2347
|
+
* its own edit/reminder throttle in {@link module:memory/session-roots}, so a
|
|
2348
|
+
* Stop lists and silences ONLY the roots THAT session edited — concurrent
|
|
2349
|
+
* sessions on different projects never cross-remind nor steal each other's
|
|
2350
|
+
* throttle. WITHOUT a usable session id (a harness that omits it, or the legacy
|
|
2351
|
+
* on-disk state) it falls back to the original mono-session behavior: the global
|
|
2352
|
+
* flat root registry + the per-project `MEMORY/state.json` throttle.
|
|
2353
|
+
*/
|
|
2354
|
+
/** Sanitized session id from a raw hook payload, or null (→ legacy fallback). */
|
|
2355
|
+
function sessionOf(payload) {
|
|
2356
|
+
return sanitizeSessionId(payload.session_id);
|
|
2357
|
+
}
|
|
2358
|
+
/** Legacy (no session id): pending roots across the global flat registry. */
|
|
2359
|
+
function collectLegacyPending(now, window) {
|
|
2287
2360
|
const pending = [];
|
|
2288
2361
|
for (const root of readRoots()) {
|
|
2289
2362
|
const stateFile = lessonsStateFileFor(root);
|
|
@@ -2295,26 +2368,77 @@ function collectPending(now, window) {
|
|
|
2295
2368
|
}
|
|
2296
2369
|
return pending;
|
|
2297
2370
|
}
|
|
2298
|
-
/** Stop
|
|
2299
|
-
function
|
|
2300
|
-
|
|
2371
|
+
/** Stop reminder body listing each pending project's lessons file. */
|
|
2372
|
+
function reminderText(pending) {
|
|
2373
|
+
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")}`;
|
|
2374
|
+
}
|
|
2375
|
+
/**
|
|
2376
|
+
* Stop: emit one reminder covering the stopping session's pending projects.
|
|
2377
|
+
* @param payload - Raw hook payload (`session_id` selects the scoped path).
|
|
2378
|
+
* @param now - Clock.
|
|
2379
|
+
* @returns Native Stop stdout, or "" when nothing is pending.
|
|
2380
|
+
*/
|
|
2381
|
+
function remindWrite(payload, now) {
|
|
2382
|
+
const window = throttleMs();
|
|
2383
|
+
const sid = sessionOf(payload);
|
|
2384
|
+
const pending = sid ? collectSessionPending(sid, now, window) : collectLegacyPending(now, window);
|
|
2301
2385
|
if (pending.length === 0) return "";
|
|
2302
|
-
return contextResponse("Stop",
|
|
2386
|
+
return contextResponse("Stop", reminderText(pending));
|
|
2303
2387
|
}
|
|
2304
|
-
/**
|
|
2388
|
+
/**
|
|
2389
|
+
* PostToolUse: record the edit against the throttle. A code file arms the
|
|
2390
|
+
* reminder; writing `MEMORY/LESSON.md` silences it (the lesson was just saved).
|
|
2391
|
+
* Session-scoped when `session_id` is present, else the legacy global path.
|
|
2392
|
+
* @param payload - Raw hook payload (`tool_input.file_path`, `session_id`).
|
|
2393
|
+
* @param now - Clock.
|
|
2394
|
+
*/
|
|
2305
2395
|
function markWrite(payload, now) {
|
|
2306
2396
|
const input = payload.tool_input;
|
|
2307
2397
|
if (!input?.file_path) return;
|
|
2308
2398
|
const abs = resolve(input.file_path);
|
|
2309
2399
|
const root = projectRootOrNull(dirname(abs));
|
|
2310
2400
|
if (!root) return;
|
|
2311
|
-
const
|
|
2312
|
-
if (
|
|
2313
|
-
|
|
2314
|
-
|
|
2401
|
+
const isLesson = abs === resolve(root, "MEMORY", "LESSON.md");
|
|
2402
|
+
if (!isLesson && !isCodeFile(abs)) return;
|
|
2403
|
+
const sid = sessionOf(payload);
|
|
2404
|
+
if (sid) markSessionRoot(sid, root, isLesson ? "remindedAt" : "editedAt", now);
|
|
2405
|
+
else if (isLesson) setStateField(lessonsStateFileFor(root), "lastRemindedAt", now);
|
|
2406
|
+
else {
|
|
2407
|
+
setStateField(lessonsStateFileFor(root), "lastCodeEditAt", now);
|
|
2315
2408
|
addRoot(root);
|
|
2316
2409
|
}
|
|
2317
2410
|
}
|
|
2411
|
+
//#endregion
|
|
2412
|
+
//#region src/runtime/lifecycle/lessons/dispatch.ts
|
|
2413
|
+
/**
|
|
2414
|
+
* fuse-lessons scope dispatch (TS port of the 4 handler scripts). Routes by
|
|
2415
|
+
* event: SessionStart/SubagentStart inject `MEMORY/LESSON.md`; Stop reminds the
|
|
2416
|
+
* stopping session about ITS OWN projects with unsaved code edits; PostToolUse
|
|
2417
|
+
* marks the write to arm/silence the throttle. The reminder + mark logic (incl.
|
|
2418
|
+
* the per-`session_id` scoping that fixes the multi-session misdirection) lives
|
|
2419
|
+
* in {@link module:runtime/lifecycle/lessons/reminder}; this module keeps the
|
|
2420
|
+
* event router + lesson-file injection. Non-fatal by design.
|
|
2421
|
+
*/
|
|
2422
|
+
/** Inject `MEMORY/LESSON.md` for `event`, after mechanical curation (a strict dedup rewrites the file in place; any report surfaces to the user via systemMessage). */
|
|
2423
|
+
function injectMemory(cwd, event, now) {
|
|
2424
|
+
const root = projectRoot(cwd);
|
|
2425
|
+
const file = lessonsFileFor(root);
|
|
2426
|
+
if (!existsSync(file)) return "";
|
|
2427
|
+
let content = "";
|
|
2428
|
+
try {
|
|
2429
|
+
content = readFileSync(file, "utf-8").trim();
|
|
2430
|
+
} catch {
|
|
2431
|
+
return "";
|
|
2432
|
+
}
|
|
2433
|
+
if (!content) return "";
|
|
2434
|
+
const { content: curated, report } = curateLessons(content, now, root);
|
|
2435
|
+
if (curated !== content) try {
|
|
2436
|
+
atomicWrite(file, curated);
|
|
2437
|
+
content = curated;
|
|
2438
|
+
} catch {}
|
|
2439
|
+
const ctx = `Project lessons — never reproduce these:\n${content}\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.`;
|
|
2440
|
+
return report ? attachSystemMessage(contextResponse(event, ctx), `LESSON.md curation:\n${report}`) : contextResponse(event, ctx);
|
|
2441
|
+
}
|
|
2318
2442
|
/**
|
|
2319
2443
|
* Route a fuse-lessons event to its handler. Returns the native stdout for
|
|
2320
2444
|
* context-injecting events (SessionStart/SubagentStart/Stop) or "" for the
|
|
@@ -2329,7 +2453,7 @@ function dispatchLessons(event, payload, cwd, now) {
|
|
|
2329
2453
|
switch (event) {
|
|
2330
2454
|
case "SessionStart":
|
|
2331
2455
|
case "SubagentStart": return injectMemory(cwd, event, now);
|
|
2332
|
-
case "Stop": return remindWrite(now);
|
|
2456
|
+
case "Stop": return remindWrite(payload, now);
|
|
2333
2457
|
case "PostToolUse":
|
|
2334
2458
|
markWrite(payload, now);
|
|
2335
2459
|
return "";
|
|
@@ -2337,6 +2461,559 @@ function dispatchLessons(event, payload, cwd, now) {
|
|
|
2337
2461
|
}
|
|
2338
2462
|
}
|
|
2339
2463
|
//#endregion
|
|
2464
|
+
//#region src/policy/deny-loop.ts
|
|
2465
|
+
/**
|
|
2466
|
+
* @module deny-loop
|
|
2467
|
+
* Pure anti-loop logic: hash a tool-call, decide if it repeats a prior deny, and
|
|
2468
|
+
* enrich the repeated block's message.
|
|
2469
|
+
*
|
|
2470
|
+
* The proprietary rule "NEVER propose the same fix twice" is prose a model under
|
|
2471
|
+
* pressure ignores. This makes it machine-enforced: when a call whose
|
|
2472
|
+
* `(tool + normalized input)` hash was ALREADY denied in-window is retried, the
|
|
2473
|
+
* harness keeps the deny but rewrites the message — `[REPEAT]` title, STOP
|
|
2474
|
+
* prefix, forced `research-expert` action. State + wiring live in the sidecar
|
|
2475
|
+
* store ({@link module:deny-loop-store}); this file is IO-free and pure.
|
|
2476
|
+
* @packageDocumentation
|
|
2477
|
+
*/
|
|
2478
|
+
/** Stable JSON: keys sorted at every depth so `{a,b}` and `{b,a}` hash identically. */
|
|
2479
|
+
function stableStringify(v) {
|
|
2480
|
+
if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
|
|
2481
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
|
|
2482
|
+
const o = v;
|
|
2483
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(",")}}`;
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* Stable identity hash of a tool-call = tool name + normalized (key-sorted) input,
|
|
2487
|
+
* so re-ordered keys never mask a repeat.
|
|
2488
|
+
* @param tool - Tool name (e.g. "Write", "Bash").
|
|
2489
|
+
* @param input - Identifying tool input (filePath/content/command...).
|
|
2490
|
+
* @returns 8-char hex hash.
|
|
2491
|
+
*/
|
|
2492
|
+
function denyHash(tool, input) {
|
|
2493
|
+
return hashText(`${tool}\n${stableStringify(input)}`);
|
|
2494
|
+
}
|
|
2495
|
+
/**
|
|
2496
|
+
* Pure loop check: given the already-pruned in-window map, compute the running
|
|
2497
|
+
* count for `hash` and whether it repeats (count > 1). No IO — the caller persists.
|
|
2498
|
+
* @param hash - {@link denyHash} of the current call.
|
|
2499
|
+
* @param priorDenies - The `{ hash -> DenyEntry }` map, pruned to `now`/`windowMs`.
|
|
2500
|
+
* @param opts - Clock + window.
|
|
2501
|
+
* @returns `{ isRepeat, count, hash }`.
|
|
2502
|
+
*/
|
|
2503
|
+
function denyLoopCheck(hash, priorDenies, opts) {
|
|
2504
|
+
const prev = priorDenies[hash];
|
|
2505
|
+
const count = (prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs ? prev.count : 0) + 1;
|
|
2506
|
+
return {
|
|
2507
|
+
isRepeat: count > 1,
|
|
2508
|
+
count,
|
|
2509
|
+
hash
|
|
2510
|
+
};
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Enrich a REPEATED block prompt — a NEW object, never a mutation (the input may
|
|
2514
|
+
* be a shared const like FAIL_CLOSED). The decision stays `block`; only the
|
|
2515
|
+
* message changes, so every harness renders it through the same adapter.
|
|
2516
|
+
* @param prompt - The original block prompt.
|
|
2517
|
+
* @param count - The running identical-deny count (n).
|
|
2518
|
+
* @returns A block prompt with `[REPEAT]` title, STOP-prefixed reason, forced research action.
|
|
2519
|
+
*/
|
|
2520
|
+
function enrichRepeatDeny(prompt, count) {
|
|
2521
|
+
const stop = `Tentative identique n°${count} déjà refusée pour la même raison. STOP: ne retente pas ce même appel. `;
|
|
2522
|
+
const action = "Launch fuse-ai-pilot:research-expert to find a DIFFERENT approach";
|
|
2523
|
+
return {
|
|
2524
|
+
...prompt,
|
|
2525
|
+
title: prompt.title.startsWith("[REPEAT]") ? prompt.title : `[REPEAT] ${prompt.title}`,
|
|
2526
|
+
reason: stop + prompt.reason,
|
|
2527
|
+
actions: [action, ...prompt.actions ?? []]
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
//#endregion
|
|
2531
|
+
//#region src/tracking/one-shot-store.ts
|
|
2532
|
+
/** A fresh, empty state — always spread (`{ ...EMPTY }`) so the const is never shared. */
|
|
2533
|
+
const EMPTY = {
|
|
2534
|
+
gates: {},
|
|
2535
|
+
firstTry: 0,
|
|
2536
|
+
corrected: 0,
|
|
2537
|
+
pending: {},
|
|
2538
|
+
updatedAt: 0
|
|
2539
|
+
};
|
|
2540
|
+
/**
|
|
2541
|
+
* Drop stale data: whole-state idle reset past the window, else per-entry prune of
|
|
2542
|
+
* gates/pending older than `windowMs`. Keeps the "7d" window honest, bounds size.
|
|
2543
|
+
*/
|
|
2544
|
+
function pruneState(s, now, windowMs) {
|
|
2545
|
+
if (now - s.updatedAt >= windowMs) return { ...EMPTY };
|
|
2546
|
+
const gates = {};
|
|
2547
|
+
for (const [k, g] of Object.entries(s.gates)) if (now - g.lastTs < windowMs) gates[k] = g;
|
|
2548
|
+
const pending = {};
|
|
2549
|
+
for (const [k, p] of Object.entries(s.pending)) if (now - p.ts < windowMs) pending[k] = p;
|
|
2550
|
+
return {
|
|
2551
|
+
...s,
|
|
2552
|
+
gates,
|
|
2553
|
+
pending
|
|
2554
|
+
};
|
|
2555
|
+
}
|
|
2556
|
+
/**
|
|
2557
|
+
* Record a deny for gate `title` on operation `op` (content-free tool identity):
|
|
2558
|
+
* bump the gate's deny count and mark `op` pending for a later fix.
|
|
2559
|
+
*/
|
|
2560
|
+
function applyDeny(s, title, op, now) {
|
|
2561
|
+
const g = s.gates[title] ?? {
|
|
2562
|
+
denies: 0,
|
|
2563
|
+
corrected: 0,
|
|
2564
|
+
lastTs: 0
|
|
2565
|
+
};
|
|
2566
|
+
return {
|
|
2567
|
+
...s,
|
|
2568
|
+
gates: {
|
|
2569
|
+
...s.gates,
|
|
2570
|
+
[title]: {
|
|
2571
|
+
denies: g.denies + 1,
|
|
2572
|
+
corrected: g.corrected,
|
|
2573
|
+
lastTs: now
|
|
2574
|
+
}
|
|
2575
|
+
},
|
|
2576
|
+
pending: {
|
|
2577
|
+
...s.pending,
|
|
2578
|
+
[op]: {
|
|
2579
|
+
title,
|
|
2580
|
+
ts: now
|
|
2581
|
+
}
|
|
2582
|
+
},
|
|
2583
|
+
updatedAt: now
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* Record an allow for a gateable `op`. A non-gateable allow (Read/Task/MCP) leaves
|
|
2588
|
+
* state untouched — it never counts and never clears a pending deny. Otherwise: a
|
|
2589
|
+
* pending deny → `corrected` (a fix, credited to the blocking gate); no pending →
|
|
2590
|
+
* `firstTry` (one-shot).
|
|
2591
|
+
*/
|
|
2592
|
+
function applyAllow(s, op, now, gateable) {
|
|
2593
|
+
if (!gateable) return s;
|
|
2594
|
+
const pend = s.pending[op];
|
|
2595
|
+
if (pend) {
|
|
2596
|
+
const g = s.gates[pend.title] ?? {
|
|
2597
|
+
denies: 0,
|
|
2598
|
+
corrected: 0,
|
|
2599
|
+
lastTs: 0
|
|
2600
|
+
};
|
|
2601
|
+
const { [op]: _drop, ...pending } = s.pending;
|
|
2602
|
+
return {
|
|
2603
|
+
...s,
|
|
2604
|
+
gates: {
|
|
2605
|
+
...s.gates,
|
|
2606
|
+
[pend.title]: {
|
|
2607
|
+
...g,
|
|
2608
|
+
corrected: g.corrected + 1,
|
|
2609
|
+
lastTs: now
|
|
2610
|
+
}
|
|
2611
|
+
},
|
|
2612
|
+
corrected: s.corrected + 1,
|
|
2613
|
+
pending,
|
|
2614
|
+
updatedAt: now
|
|
2615
|
+
};
|
|
2616
|
+
}
|
|
2617
|
+
return gateable ? {
|
|
2618
|
+
...s,
|
|
2619
|
+
firstTry: s.firstTry + 1,
|
|
2620
|
+
updatedAt: now
|
|
2621
|
+
} : s;
|
|
2622
|
+
}
|
|
2623
|
+
/**
|
|
2624
|
+
* Compact injectable summary (one line); "" when there is nothing to report.
|
|
2625
|
+
* @returns e.g. `gates 7d: 88% one-shot (44/50 clean); SOLID file-size limit 4den/3fix`.
|
|
2626
|
+
*/
|
|
2627
|
+
function formatSummary(s) {
|
|
2628
|
+
const keys = Object.keys(s.gates);
|
|
2629
|
+
const total = s.firstTry + s.corrected;
|
|
2630
|
+
if (keys.length === 0 && total === 0) return "";
|
|
2631
|
+
const head = total > 0 ? `${Math.round(s.firstTry / total * 100)}% one-shot (${s.firstTry}/${total} clean)` : "no clean pass yet";
|
|
2632
|
+
const parts = keys.map((k) => ({
|
|
2633
|
+
k,
|
|
2634
|
+
g: s.gates[k]
|
|
2635
|
+
})).sort((a, b) => b.g.denies - a.g.denies).map(({ k, g }) => `${k} ${g.denies}den/${g.corrected}fix`);
|
|
2636
|
+
return `gates 7d: ${head}${parts.length ? `; ${parts.join("; ")}` : ""}`;
|
|
2637
|
+
}
|
|
2638
|
+
//#endregion
|
|
2639
|
+
//#region src/tracking/one-shot.ts
|
|
2640
|
+
/**
|
|
2641
|
+
* @module one-shot
|
|
2642
|
+
* Sidecar store + gate wiring for the per-gate one-shot metric.
|
|
2643
|
+
*
|
|
2644
|
+
* STATE — a standalone sidecar (`one-shot.json`) in the same per-project state dir
|
|
2645
|
+
* as the session track, mirroring {@link module:deny-loop-store} (atomicWrite,
|
|
2646
|
+
* prune-by-window, fail-safe). A write error NEVER changes a gate decision nor its
|
|
2647
|
+
* prompt — metrics are pure observation.
|
|
2648
|
+
*
|
|
2649
|
+
* KEY — the operation identity is content-FREE (`tool + filePath/command`): a fix
|
|
2650
|
+
* changes the content, so a content hash would make every retry a new op and hide
|
|
2651
|
+
* the deny→allow transition this metric exists to see. The pure model lives in
|
|
2652
|
+
* {@link module:one-shot-store}; this file is the only IO surface.
|
|
2653
|
+
* @packageDocumentation
|
|
2654
|
+
*/
|
|
2655
|
+
/** Sidecar basename under the per-project state dir. */
|
|
2656
|
+
const SIDECAR$1 = "one-shot.json";
|
|
2657
|
+
/** Retention window: 7 days. Aggregates and pending denies older than this are pruned. */
|
|
2658
|
+
const WINDOW_MS = 10080 * 60 * 1e3;
|
|
2659
|
+
/** Load the state, or a fresh copy when missing/corrupt. */
|
|
2660
|
+
function loadState(path) {
|
|
2661
|
+
try {
|
|
2662
|
+
if (!existsSync(path)) return { ...EMPTY };
|
|
2663
|
+
const d = JSON.parse(readFileSync(path, "utf8"));
|
|
2664
|
+
return d && typeof d === "object" && !Array.isArray(d) ? {
|
|
2665
|
+
...EMPTY,
|
|
2666
|
+
...d
|
|
2667
|
+
} : { ...EMPTY };
|
|
2668
|
+
} catch {
|
|
2669
|
+
return { ...EMPTY };
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
/**
|
|
2673
|
+
* Record a gate outcome: a `block` is a deny for its gate title; a `null` allow is
|
|
2674
|
+
* a fix (if the op was pending) or a one-shot (if gateable). `ask`/`inform` are
|
|
2675
|
+
* neither and are skipped. Fails silently — a metric write NEVER affects a decision.
|
|
2676
|
+
*
|
|
2677
|
+
* The op key is tool-INDEPENDENT (`filePath`/`command` only, constant `"op"` tool):
|
|
2678
|
+
* a deny (a `Write`) and its fix (an `Edit`) on the same file must link.
|
|
2679
|
+
* @param prompt - The gate's outcome (block, allow=null, or ask/inform).
|
|
2680
|
+
* @param input - Identifying tool input (content decides gateability only).
|
|
2681
|
+
* @param opts - Clock + state dir.
|
|
2682
|
+
*/
|
|
2683
|
+
function recordOneShot(prompt, input, opts) {
|
|
2684
|
+
try {
|
|
2685
|
+
if (prompt && prompt.kind !== "block") return;
|
|
2686
|
+
const path = join(opts.dir, SIDECAR$1);
|
|
2687
|
+
let s = pruneState(loadState(path), opts.now, WINDOW_MS);
|
|
2688
|
+
const op = denyHash("op", {
|
|
2689
|
+
filePath: input.filePath,
|
|
2690
|
+
command: input.command
|
|
2691
|
+
});
|
|
2692
|
+
s = prompt ? applyDeny(s, prompt.title, op, opts.now) : applyAllow(s, op, opts.now, input.content != null || input.command != null);
|
|
2693
|
+
atomicWrite(path, JSON.stringify(s));
|
|
2694
|
+
} catch {}
|
|
2695
|
+
}
|
|
2696
|
+
/**
|
|
2697
|
+
* Compact, injection-ready one-shot summary for the project rooted at `cwd`. The
|
|
2698
|
+
* state dir is derived EXACTLY like the runtime writer ({@link defaultStateDir},
|
|
2699
|
+
* mirroring `handle.ts` `trackFile(sid, defaultStateDir(cwd))`), so the file read
|
|
2700
|
+
* here is the same one {@link recordOneShot} wrote. "" when no data or read error.
|
|
2701
|
+
* @param cwd - The project working directory (Claude `cwd`), NOT the state dir.
|
|
2702
|
+
* @returns One line, e.g. `gates 7d: 88% one-shot (44/50 clean); ...`, or "".
|
|
2703
|
+
*/
|
|
2704
|
+
function oneShotSummary(cwd) {
|
|
2705
|
+
try {
|
|
2706
|
+
return formatSummary(pruneState(loadState(join(defaultStateDir(cwd), SIDECAR$1)), Date.now(), WINDOW_MS));
|
|
2707
|
+
} catch {
|
|
2708
|
+
return "";
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
//#endregion
|
|
2712
|
+
//#region src/runtime/lifecycle/snapshot/git.ts
|
|
2713
|
+
/**
|
|
2714
|
+
* Run a git subcommand at `root` with a short timeout, returning trimmed stdout.
|
|
2715
|
+
* Uses `node:child_process` (the Bun shell can hang on some git plumbing) and
|
|
2716
|
+
* swallows every failure — a non-repo, missing git, or timeout yields `""` so
|
|
2717
|
+
* the caller omits the section instead of throwing inside the hook.
|
|
2718
|
+
* @param root - Directory to run git in.
|
|
2719
|
+
* @param args - The git args (e.g. `"log --oneline -3"`).
|
|
2720
|
+
* @returns Trimmed stdout, or `""` on any error.
|
|
2721
|
+
*/
|
|
2722
|
+
function git(root, args) {
|
|
2723
|
+
try {
|
|
2724
|
+
return execSync(`git ${args}`, {
|
|
2725
|
+
cwd: root,
|
|
2726
|
+
encoding: "utf8",
|
|
2727
|
+
timeout: 150,
|
|
2728
|
+
stdio: [
|
|
2729
|
+
"ignore",
|
|
2730
|
+
"pipe",
|
|
2731
|
+
"ignore"
|
|
2732
|
+
]
|
|
2733
|
+
}).trim();
|
|
2734
|
+
} catch {
|
|
2735
|
+
return "";
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
/** Count staged/unstaged/untracked files from porcelain v1 output (skips the `##` branch line). */
|
|
2739
|
+
function countWip(porcelain) {
|
|
2740
|
+
const w = {
|
|
2741
|
+
staged: 0,
|
|
2742
|
+
unstaged: 0,
|
|
2743
|
+
untracked: 0
|
|
2744
|
+
};
|
|
2745
|
+
for (const line of porcelain.split("\n")) {
|
|
2746
|
+
if (!line || line.startsWith("#")) continue;
|
|
2747
|
+
if (line.startsWith("??")) {
|
|
2748
|
+
w.untracked++;
|
|
2749
|
+
continue;
|
|
2750
|
+
}
|
|
2751
|
+
const x = line[0], y = line[1];
|
|
2752
|
+
if (x && x !== " " && x !== "?") w.staged++;
|
|
2753
|
+
if (y === "M" || y === "D") w.unstaged++;
|
|
2754
|
+
}
|
|
2755
|
+
return w;
|
|
2756
|
+
}
|
|
2757
|
+
/** Parse the current branch from the leading `## branch...upstream` porcelain line. */
|
|
2758
|
+
function parseBranch(porcelain) {
|
|
2759
|
+
const head = porcelain.split("\n")[0] ?? "";
|
|
2760
|
+
if (!head.startsWith("## ")) return "";
|
|
2761
|
+
const rest = head.slice(3);
|
|
2762
|
+
const dots = rest.indexOf("...");
|
|
2763
|
+
return (dots >= 0 ? rest.slice(0, dots) : rest).split(" ")[0] ?? "";
|
|
2764
|
+
}
|
|
2765
|
+
/**
|
|
2766
|
+
* Collect a compact git reconciliation section for `root`: current branch, the
|
|
2767
|
+
* last three commits (oneline), and staged/unstaged/untracked WIP counts. When
|
|
2768
|
+
* `root` is not a git repo (status fails) the whole section is omitted (`""`).
|
|
2769
|
+
* @param root - The project/repo root.
|
|
2770
|
+
* @returns The rendered git section body, or `""` when not a repo.
|
|
2771
|
+
*/
|
|
2772
|
+
function collectGit(root) {
|
|
2773
|
+
const status = git(root, "status --porcelain=v1 --branch");
|
|
2774
|
+
if (!status) return "";
|
|
2775
|
+
const branch = parseBranch(status) || "(unknown)";
|
|
2776
|
+
const w = countWip(status);
|
|
2777
|
+
const log = git(root, "log --oneline -3");
|
|
2778
|
+
const lines = [`- branch: ${branch}`];
|
|
2779
|
+
if (log) lines.push("- recent:", ...log.split("\n").map((l) => ` ${l}`));
|
|
2780
|
+
lines.push(`- WIP: ${w.staged} staged, ${w.unstaged} unstaged, ${w.untracked} untracked`);
|
|
2781
|
+
return lines.join("\n");
|
|
2782
|
+
}
|
|
2783
|
+
//#endregion
|
|
2784
|
+
//#region src/cli/doctor.ts
|
|
2785
|
+
/**
|
|
2786
|
+
* `harness doctor` — diagnose which `@fusengine/harness` is actually running.
|
|
2787
|
+
*
|
|
2788
|
+
* A confirmed, still-open bun bug (oven-sh/bun #5791; scoped-pkg behaviour
|
|
2789
|
+
* reinforced by #32019/#32150) makes `bunx <pkg>` (unpinned) prefer a stale
|
|
2790
|
+
* GLOBAL install over npm-latest, so a consumer can silently run an old harness
|
|
2791
|
+
* after a publish. This command surfaces the truth: the resolved version +
|
|
2792
|
+
* package path of the code executing right now, the runtime binary, and the
|
|
2793
|
+
* latest version published on npm. It queries the registry over HTTP (not
|
|
2794
|
+
* `npm view`, whose exit code is 0 even on an empty result — npm/cli#6408) and
|
|
2795
|
+
* never throws: an offline environment yields `latest: null`, never a crash.
|
|
2796
|
+
*/
|
|
2797
|
+
const PKG = "@fusengine/harness";
|
|
2798
|
+
/** Walk up from `startDir` for the `@fusengine/harness` `package.json`. */
|
|
2799
|
+
function findPackage(startDir) {
|
|
2800
|
+
let dir = startDir;
|
|
2801
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
2802
|
+
try {
|
|
2803
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
2804
|
+
if (pkg.name === PKG) return {
|
|
2805
|
+
version: pkg.version ?? "unknown",
|
|
2806
|
+
path: dir
|
|
2807
|
+
};
|
|
2808
|
+
} catch {}
|
|
2809
|
+
const parent = dirname(dir);
|
|
2810
|
+
if (parent === dir) break;
|
|
2811
|
+
dir = parent;
|
|
2812
|
+
}
|
|
2813
|
+
return null;
|
|
2814
|
+
}
|
|
2815
|
+
/** Resolve the running version + package path (no network), from a module URL. */
|
|
2816
|
+
function runningVersion(moduleUrl) {
|
|
2817
|
+
const found = findPackage(dirname(fileURLToPath(moduleUrl)));
|
|
2818
|
+
return {
|
|
2819
|
+
version: found?.version ?? "unknown",
|
|
2820
|
+
path: found?.path ?? "unknown"
|
|
2821
|
+
};
|
|
2822
|
+
}
|
|
2823
|
+
/** One-line `pkg vX.Y.Z` banner (stderr, only on explicit `--version`/`doctor` commands — never on `hook`, to avoid spamming automated invocations). */
|
|
2824
|
+
function versionBanner(moduleUrl) {
|
|
2825
|
+
return `${PKG} v${runningVersion(moduleUrl).version}`;
|
|
2826
|
+
}
|
|
2827
|
+
/** Latest published version via the npm registry HTTP API. `null` on any failure. */
|
|
2828
|
+
async function npmLatest() {
|
|
2829
|
+
try {
|
|
2830
|
+
const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(8e3) });
|
|
2831
|
+
if (!res.ok) return null;
|
|
2832
|
+
return (await res.json()).version ?? null;
|
|
2833
|
+
} catch {
|
|
2834
|
+
return null;
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
/** Build the full diagnostic report for the module at `moduleUrl`. */
|
|
2838
|
+
async function buildDoctorReport(moduleUrl) {
|
|
2839
|
+
const { version, path } = runningVersion(moduleUrl);
|
|
2840
|
+
const latest = await npmLatest();
|
|
2841
|
+
return {
|
|
2842
|
+
running: version,
|
|
2843
|
+
packagePath: path,
|
|
2844
|
+
runtime: process.execPath,
|
|
2845
|
+
latest,
|
|
2846
|
+
stale: latest !== null && latest !== version
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2849
|
+
/** Render a {@link DoctorReport} as human-readable stdout text. */
|
|
2850
|
+
function formatDoctor(r) {
|
|
2851
|
+
const lines = [
|
|
2852
|
+
`${PKG} doctor`,
|
|
2853
|
+
` running: ${r.running}`,
|
|
2854
|
+
` package: ${r.packagePath}`,
|
|
2855
|
+
` runtime: ${r.runtime}`,
|
|
2856
|
+
` npm latest: ${r.latest ?? "(unavailable — offline or unreachable)"}`
|
|
2857
|
+
];
|
|
2858
|
+
if (r.stale) lines.push(` ! stale — npm serves ${r.latest}. Pin "@fusengine/harness@${r.latest}" in hooks.json (see README).`);
|
|
2859
|
+
else if (r.latest !== null) lines.push(` ok — running the latest published version.`);
|
|
2860
|
+
return lines.join("\n");
|
|
2861
|
+
}
|
|
2862
|
+
/** Run `harness doctor`: print the diagnostic to stdout. Always resolves 0 (pure info). */
|
|
2863
|
+
async function runDoctor(moduleUrl) {
|
|
2864
|
+
process.stdout.write(formatDoctor(await buildDoctorReport(moduleUrl)) + "\n");
|
|
2865
|
+
return 0;
|
|
2866
|
+
}
|
|
2867
|
+
//#endregion
|
|
2868
|
+
//#region src/runtime/lifecycle/snapshot/version.ts
|
|
2869
|
+
/** Read the `version` field of `<root>/package.json`, or `""` if absent/unreadable. */
|
|
2870
|
+
function pkgVersion(root) {
|
|
2871
|
+
try {
|
|
2872
|
+
return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version ?? "";
|
|
2873
|
+
} catch {
|
|
2874
|
+
return "";
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
/**
|
|
2878
|
+
* Collect the version reconciliation section: the harness version actually
|
|
2879
|
+
* running (resolved from {@link runningVersion}, no network) and, when `root`
|
|
2880
|
+
* carries its own `package.json`, whether that project's version has drifted
|
|
2881
|
+
* from the running harness.
|
|
2882
|
+
* @param root - The project root (cwd repo).
|
|
2883
|
+
* @param moduleUrl - `import.meta.url` of the calling module (locates the running package.json).
|
|
2884
|
+
* @returns The rendered version section body (never `""`).
|
|
2885
|
+
*/
|
|
2886
|
+
function collectVersion(root, moduleUrl) {
|
|
2887
|
+
const running = runningVersion(moduleUrl).version;
|
|
2888
|
+
const lines = [`- harness running: v${running}`];
|
|
2889
|
+
const project = pkgVersion(root);
|
|
2890
|
+
if (project && project !== running) lines.push(`- project package.json: v${project} (DRIFT — running harness differs)`);
|
|
2891
|
+
else if (project) lines.push(`- project package.json: v${project} (in sync)`);
|
|
2892
|
+
return lines.join("\n");
|
|
2893
|
+
}
|
|
2894
|
+
//#endregion
|
|
2895
|
+
//#region src/runtime/lifecycle/snapshot/board.ts
|
|
2896
|
+
/** Max board characters injected — a persistent board should stay small; over-long boards are truncated. */
|
|
2897
|
+
const MAX_BOARD = 4e3;
|
|
2898
|
+
/**
|
|
2899
|
+
* Collect the persistent task board: the contents of `<root>/.claude/BOARD.md`
|
|
2900
|
+
* (truncated to {@link MAX_BOARD}) plus an instruction to keep it current. The
|
|
2901
|
+
* board lives on disk so it survives context purges — rehydrated every session.
|
|
2902
|
+
* Missing/empty/unreadable board → `""` (section omitted).
|
|
2903
|
+
* @param root - The project root.
|
|
2904
|
+
* @returns The rendered board section body, or `""` when there is no board.
|
|
2905
|
+
*/
|
|
2906
|
+
function collectBoard(root) {
|
|
2907
|
+
const path = join(root, ".claude", "BOARD.md");
|
|
2908
|
+
try {
|
|
2909
|
+
if (!existsSync(path)) return "";
|
|
2910
|
+
let body = readFileSync(path, "utf8").trim();
|
|
2911
|
+
if (!body) return "";
|
|
2912
|
+
if (body.length > MAX_BOARD) body = `${body.slice(0, MAX_BOARD)}\n… (truncated)`;
|
|
2913
|
+
return `- .claude/BOARD.md (keep current — Write to it as tasks start/finish):\n\n${body}`;
|
|
2914
|
+
} catch {
|
|
2915
|
+
return "";
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
//#endregion
|
|
2919
|
+
//#region src/runtime/lifecycle/snapshot/format.ts
|
|
2920
|
+
/**
|
|
2921
|
+
* Render the non-empty `sections` under one reconciliation heading. Empty
|
|
2922
|
+
* sections are dropped; when every section is empty the whole snapshot is `""`.
|
|
2923
|
+
* @param sections - The collected sections in display order.
|
|
2924
|
+
* @returns The assembled markdown block, or `""` when nothing to report.
|
|
2925
|
+
*/
|
|
2926
|
+
function renderSections(sections) {
|
|
2927
|
+
const parts = sections.filter((s) => s.body.trim()).map((s) => `### ${s.title}\n${s.body.trim()}`);
|
|
2928
|
+
if (!parts.length) return "";
|
|
2929
|
+
return `# Reconciliation snapshot
|
|
2930
|
+
Real state of the world at session start — reconcile against this instead of re-discovering it.\n\n${parts.join("\n\n")}`;
|
|
2931
|
+
}
|
|
2932
|
+
/**
|
|
2933
|
+
* Concatenate `snapshot` onto an existing SessionStart stdout's
|
|
2934
|
+
* `additionalContext` — it never replaces prior injected context (CLAUDE.md,
|
|
2935
|
+
* dev-context). When `stdout` is empty a fresh {@link contextResponse} is made;
|
|
2936
|
+
* a non-empty but unparseable `stdout` is returned UNCHANGED (the snapshot is
|
|
2937
|
+
* dropped) — fabricating a fresh response there would discard the very CLAUDE.md
|
|
2938
|
+
* injection the invariant protects, so preserving prior context always wins.
|
|
2939
|
+
* @param stdout - The core SessionStart JSON stdout (may be `""`).
|
|
2940
|
+
* @param snapshot - The snapshot markdown to append (no-op when `""`).
|
|
2941
|
+
* @returns The merged hook stdout JSON.
|
|
2942
|
+
*/
|
|
2943
|
+
function attachSnapshot(stdout, snapshot) {
|
|
2944
|
+
if (!snapshot) return stdout;
|
|
2945
|
+
if (!stdout) return contextResponse("SessionStart", snapshot);
|
|
2946
|
+
try {
|
|
2947
|
+
const parsed = JSON.parse(stdout);
|
|
2948
|
+
const prev = parsed.hookSpecificOutput?.additionalContext ?? "";
|
|
2949
|
+
const merged = prev ? `${prev}\n\n${snapshot}` : snapshot;
|
|
2950
|
+
return JSON.stringify({
|
|
2951
|
+
...parsed,
|
|
2952
|
+
hookSpecificOutput: {
|
|
2953
|
+
...parsed.hookSpecificOutput,
|
|
2954
|
+
hookEventName: "SessionStart",
|
|
2955
|
+
additionalContext: merged
|
|
2956
|
+
}
|
|
2957
|
+
});
|
|
2958
|
+
} catch {
|
|
2959
|
+
return stdout;
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
//#endregion
|
|
2963
|
+
//#region src/runtime/lifecycle/snapshot/index.ts
|
|
2964
|
+
/** Run `fn`, swallowing any throw into `""` so no single collector can break the hook. */
|
|
2965
|
+
function safe(fn) {
|
|
2966
|
+
try {
|
|
2967
|
+
return fn();
|
|
2968
|
+
} catch {
|
|
2969
|
+
return "";
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
/**
|
|
2973
|
+
* Build the reconciliation snapshot markdown for `cwd`: git state, running
|
|
2974
|
+
* harness version + drift, the persistent board, and one-shot gate status. Each
|
|
2975
|
+
* collector is isolated by {@link safe}; an all-empty result yields `""`.
|
|
2976
|
+
* @param cwd - The session working directory.
|
|
2977
|
+
* @param moduleUrl - `import.meta.url` of the caller (locates the running package).
|
|
2978
|
+
* @returns The snapshot markdown, or `""` when nothing to report.
|
|
2979
|
+
*/
|
|
2980
|
+
function renderSnapshot(cwd, moduleUrl) {
|
|
2981
|
+
const root = projectRootOrNull(cwd) ?? cwd;
|
|
2982
|
+
return renderSections([
|
|
2983
|
+
{
|
|
2984
|
+
title: "Git",
|
|
2985
|
+
body: safe(() => collectGit(root))
|
|
2986
|
+
},
|
|
2987
|
+
{
|
|
2988
|
+
title: "Version",
|
|
2989
|
+
body: safe(() => collectVersion(root, moduleUrl))
|
|
2990
|
+
},
|
|
2991
|
+
{
|
|
2992
|
+
title: "Board",
|
|
2993
|
+
body: safe(() => collectBoard(root))
|
|
2994
|
+
},
|
|
2995
|
+
{
|
|
2996
|
+
title: "One-shot gates",
|
|
2997
|
+
body: safe(() => oneShotSummary(cwd))
|
|
2998
|
+
}
|
|
2999
|
+
]);
|
|
3000
|
+
}
|
|
3001
|
+
/**
|
|
3002
|
+
* Concatenate the reconciliation snapshot onto a core SessionStart stdout. Fully
|
|
3003
|
+
* fail-safe: any error returns `stdout` unchanged so the hook never breaks.
|
|
3004
|
+
* @param stdout - The core SessionStart JSON stdout (may be `""`).
|
|
3005
|
+
* @param cwd - The session working directory.
|
|
3006
|
+
* @param moduleUrl - `import.meta.url` of the caller.
|
|
3007
|
+
* @returns The merged hook stdout.
|
|
3008
|
+
*/
|
|
3009
|
+
function withSnapshot(stdout, cwd, moduleUrl) {
|
|
3010
|
+
try {
|
|
3011
|
+
return attachSnapshot(stdout, renderSnapshot(cwd, moduleUrl));
|
|
3012
|
+
} catch {
|
|
3013
|
+
return stdout;
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
//#endregion
|
|
2340
3017
|
//#region src/runtime/lifecycle/aipilot/inject-apex.ts
|
|
2341
3018
|
/**
|
|
2342
3019
|
* SubagentStart (matcher "") for the ai-pilot scope: inject APEX AGENTS.md +
|
|
@@ -3596,7 +4273,8 @@ function sessionStart(input) {
|
|
|
3596
4273
|
if (input.scope === "rules") return injectRules(process.env.CLAUDE_PLUGIN_ROOT ?? input.cwd);
|
|
3597
4274
|
if (input.scope === "carto") return cartoSessionStart(input.cwd, input.now);
|
|
3598
4275
|
if (input.scope === "lessons") return dispatchLessons("SessionStart", input.payload, input.cwd, input.now);
|
|
3599
|
-
|
|
4276
|
+
const core = sessionStartCore(input.cwd, void 0, input.now);
|
|
4277
|
+
return input.scope === "core" ? withSnapshot(core, input.cwd, import.meta.url) : core;
|
|
3600
4278
|
}
|
|
3601
4279
|
/**
|
|
3602
4280
|
* Route a lifecycle/session/context hook event to its ported handler. Returns
|
|
@@ -4984,73 +5662,6 @@ async function apexScopedGate(input, track, window) {
|
|
|
4984
5662
|
}
|
|
4985
5663
|
}
|
|
4986
5664
|
//#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
5665
|
//#region src/runtime/deny-loop-store.ts
|
|
5055
5666
|
/**
|
|
5056
5667
|
* @module deny-loop-store
|
|
@@ -5138,18 +5749,25 @@ const DEFAULT_WINDOW_MS = 12e4;
|
|
|
5138
5749
|
/** Trivial edits allowed within the window before the full APEX gates apply. */
|
|
5139
5750
|
const TRIVIAL_BUDGET = 4;
|
|
5140
5751
|
/**
|
|
5141
|
-
* Full gate: {@link runGates} yields the first blocking prompt (or null); the
|
|
5142
|
-
*
|
|
5143
|
-
*
|
|
5752
|
+
* Full gate: {@link runGates} yields the first blocking prompt (or null); the tail
|
|
5753
|
+
* records the one-shot metric ({@link recordOneShot}, observation-only) then lets
|
|
5754
|
+
* {@link withDenyLoop} rewrite an identical retried deny (decision unchanged).
|
|
5144
5755
|
*/
|
|
5145
5756
|
async function gate(input) {
|
|
5146
|
-
|
|
5757
|
+
const prompt = await runGates(input);
|
|
5758
|
+
const op = {
|
|
5147
5759
|
filePath: input.filePath,
|
|
5148
5760
|
content: input.content,
|
|
5149
5761
|
command: input.command
|
|
5150
|
-
}
|
|
5762
|
+
};
|
|
5763
|
+
const dir = dirname(input.trackFile);
|
|
5764
|
+
recordOneShot(prompt, op, {
|
|
5765
|
+
now: input.now,
|
|
5766
|
+
dir
|
|
5767
|
+
});
|
|
5768
|
+
return withDenyLoop(prompt, input.tool, op, {
|
|
5151
5769
|
now: input.now,
|
|
5152
|
-
dir
|
|
5770
|
+
dir,
|
|
5153
5771
|
windowMs: input.windowMs ?? 12e4
|
|
5154
5772
|
});
|
|
5155
5773
|
}
|
|
@@ -6420,4 +7038,4 @@ async function handleHook(id, payload, opts) {
|
|
|
6420
7038
|
});
|
|
6421
7039
|
}
|
|
6422
7040
|
//#endregion
|
|
6423
|
-
export {
|
|
7041
|
+
export { trackSessionChanges as $, trackSkillRead as A, normalizeEvent as At, lessonsStateFileFor as B, seoPostToolUse as C, projectContext as Ct, postTrackingSideEffects as D, defaultStateDir as Dt, securityAdvisory as E, taskContext as Et, runDoctor as F, securityStatePath as Ft, isProject as G, generateEcosystemMap as H, runningVersion as I, todayUtc as It, mergeLines as J, writeTree as K, versionBanner as L, dispatchLifecycle as M, loadSecurityState as Mt, aipilotPostToolUse as N, saveSecurityState as Nt, trackWatchResearch as O, projectHash$1 as Ot, dispatchAipilot as P, securityStateDir as Pt, postEditTypescript as Q, dispatchLessons as R, postEditContext as S, gitContext as St, dispatchMemory as T, promptSubmitContext as Tt, writePluginMap as U, cartoSessionStart as V, generateProjectMap as W, getFileDesc as X, countFiles as Y, listChildren as Z, preCommitGate as _, pruneEmptyDirs as _t, recordActivity as a, trackAgentMemory as at, extractSymbols as b, trimLogFile as bt, MCP_TTL_MS as c, validateSolidGate as ct, isMcpTool as d, detectSolidProfile as dt, validateRulesLoaded as et, queryOf as f, solidDetectStart as ft, gate as g, sessionStartCore as gt, TRIVIAL_BUDGET as h, runSessionStartCleanups as ht, respond as i, validateTeammateOutput as it, trackEnrichment as j, isoUtc as jt, trackMcpResearch as k, trackFile as kt, WEBFETCH_TTL_MS as l, checkFileSize as lt, REQUIRED_AGENTS as m, readRules as mt, activityFor as n, saveApexState as nt, mcpPostStore as o, subagentCacheContext as ot, DEFAULT_WINDOW_MS as p, injectRules as pt, loadEnriched as q, handlePre as r, logToolFailure as rt, mcpPreIntercept as s, validateTailwind as st, handleHook as t, cleanupSession as tt, cacheQueryOf as u, countLoc as ut, detectDuplication as v, purgeTtlTree as vt, seoPostToolUseResponse as w, claudeMdKey as wt, lifecycleStdout as x, devContext as xt, dryGate as y, removeOldFiles as yt, lessonsFileFor as z };
|
package/dist/runtime/index.d.mts
CHANGED
|
@@ -116,9 +116,9 @@ declare const DEFAULT_WINDOW_MS = 12e4;
|
|
|
116
116
|
/** Trivial edits allowed within the window before the full APEX gates apply. */
|
|
117
117
|
declare const TRIVIAL_BUDGET = 4;
|
|
118
118
|
/**
|
|
119
|
-
* Full gate: {@link runGates} yields the first blocking prompt (or null); the
|
|
120
|
-
*
|
|
121
|
-
*
|
|
119
|
+
* Full gate: {@link runGates} yields the first blocking prompt (or null); the tail
|
|
120
|
+
* records the one-shot metric ({@link recordOneShot}, observation-only) then lets
|
|
121
|
+
* {@link withDenyLoop} rewrite an identical retried deny (decision unchanged).
|
|
122
122
|
*/
|
|
123
123
|
declare function gate(input: GateInput): Promise<Prompt | null>;
|
|
124
124
|
//#endregion
|
package/dist/runtime/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { r as projectLayout } from "../layout-C0jaaCQC.mjs";
|
|
2
2
|
import { a as sanitizeSessionId, c as sessionsDir, i as loadSessionState, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, s as sessionStatePath, t as claudeHome } from "../home-state-D0RLWP8J.mjs";
|
|
3
|
-
import { $ as
|
|
3
|
+
import { $ as trackSessionChanges, A as trackSkillRead, At as normalizeEvent, B as lessonsStateFileFor, C as seoPostToolUse, Ct as projectContext, D as postTrackingSideEffects, Dt as defaultStateDir, E as securityAdvisory, Et as taskContext, Ft as securityStatePath, G as isProject, H as generateEcosystemMap, It as todayUtc, J as mergeLines, K as writeTree, M as dispatchLifecycle, Mt as loadSecurityState, N as aipilotPostToolUse, Nt as saveSecurityState, O as trackWatchResearch, Ot as projectHash, P as dispatchAipilot, Pt as securityStateDir, Q as postEditTypescript, R as dispatchLessons, S as postEditContext, St as gitContext, T as dispatchMemory, Tt as promptSubmitContext, U as writePluginMap, V as cartoSessionStart, W as generateProjectMap, X as getFileDesc, Y as countFiles, Z as listChildren, _ as preCommitGate, _t as pruneEmptyDirs, a as recordActivity, at as trackAgentMemory, b as extractSymbols, bt as trimLogFile, c as MCP_TTL_MS, ct as validateSolidGate, d as isMcpTool, dt as detectSolidProfile, et as validateRulesLoaded, f as queryOf, ft as solidDetectStart, g as gate, gt as sessionStartCore, h as TRIVIAL_BUDGET, ht as runSessionStartCleanups, i as respond, it as validateTeammateOutput, j as trackEnrichment, jt as isoUtc, k as trackMcpResearch, kt as trackFile, l as WEBFETCH_TTL_MS, lt as checkFileSize, m as REQUIRED_AGENTS, mt as readRules, n as activityFor, nt as saveApexState, o as mcpPostStore, ot as subagentCacheContext, p as DEFAULT_WINDOW_MS, pt as injectRules, q as loadEnriched, r as handlePre, rt as logToolFailure, s as mcpPreIntercept, st as validateTailwind, t as handleHook, tt as cleanupSession, u as cacheQueryOf, ut as countLoc, v as detectDuplication, vt as purgeTtlTree, w as seoPostToolUseResponse, wt as claudeMdKey, x as lifecycleStdout, xt as devContext, y as dryGate, yt as removeOldFiles, z as lessonsFileFor } from "../handle-D93-VIGS.mjs";
|
|
4
4
|
//#region src/runtime/storage.ts
|
|
5
5
|
/**
|
|
6
6
|
* The project's single state dir (`<root>/.harness`) — neutral + harness-agnostic,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusengine/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.53",
|
|
4
4
|
"description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "src/index.ts",
|