@fusengine/harness 0.1.54 → 0.1.55
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,7 +4,7 @@ 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 { F as runDoctor, I as runningVersion, L as versionBanner, Lt as todayUtc, t as handleHook } from "../handle-
|
|
7
|
+
import { F as runDoctor, I as runningVersion, L as versionBanner, Lt as todayUtc, t as handleHook } from "../handle-L4ZNmpwN.mjs";
|
|
8
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";
|
|
@@ -1292,6 +1292,28 @@ function validateRulesLoaded(data, home = homedir()) {
|
|
|
1292
1292
|
} catch {}
|
|
1293
1293
|
}
|
|
1294
1294
|
//#endregion
|
|
1295
|
+
//#region src/runtime/burst-window.ts
|
|
1296
|
+
/**
|
|
1297
|
+
* @module burst-window
|
|
1298
|
+
* Single source of truth for the multi-plugin hook fan-out window.
|
|
1299
|
+
*
|
|
1300
|
+
* Every DEPLOYED plugin registers its OWN PreToolUse/PostToolUse hook, so ONE
|
|
1301
|
+
* Claude tool event spawns ~11 sibling harness processes that each record the
|
|
1302
|
+
* same deny / one-shot / sniper reminder within milliseconds. Left unchecked
|
|
1303
|
+
* the deny-loop counter jumped by ~11 per real attempt ([REPEAT] "#9" on the
|
|
1304
|
+
* FIRST try), the one-shot metric inflated ~11×, and the sniper reminder was
|
|
1305
|
+
* injected ~11× (token noise).
|
|
1306
|
+
*
|
|
1307
|
+
* A record landing within this window after an identical prior one (same
|
|
1308
|
+
* operation hash + same `session_id`) is treated as the SAME event and folded
|
|
1309
|
+
* into it instead of re-counted. Two REAL agent retries are always spaced
|
|
1310
|
+
* further apart than the burst, so this never masks a genuine loop. No env var:
|
|
1311
|
+
* the fan-out is a physical property of the installed plugin set, not policy.
|
|
1312
|
+
* @packageDocumentation
|
|
1313
|
+
*/
|
|
1314
|
+
/** Fan-out dedup window (ms). The ~11 sibling hooks for one event land in <2s. */
|
|
1315
|
+
const BURST_DEDUP_MS = 2e3;
|
|
1316
|
+
//#endregion
|
|
1295
1317
|
//#region src/runtime/lifecycle/track-changes.ts
|
|
1296
1318
|
/** Code-file extensions tracked for sniper (mirrors track-session-changes.py). */
|
|
1297
1319
|
const CODE_EXT$1 = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|vue|svelte|astro)$/;
|
|
@@ -1326,6 +1348,10 @@ function trackSessionChanges(sessionIdRaw, filePath, home = homedir(), now = Dat
|
|
|
1326
1348
|
lastCheck: new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z")
|
|
1327
1349
|
};
|
|
1328
1350
|
saveSessionState(sid, state, home);
|
|
1351
|
+
if (!oncePerWindow(`sniper:${sid}:${filePath}`, 2e3, {
|
|
1352
|
+
now,
|
|
1353
|
+
dir: sessionsDir(home)
|
|
1354
|
+
})) return "";
|
|
1329
1355
|
return contextResponse("PostToolUse", `SNIPER VALIDATION REQUIRED: Code file '${basename(filePath)}' was modified. You MUST now run the sniper agent (fuse-ai-pilot:sniper) to validate this modification before continuing. This is mandatory per CLAUDE.md rules.`);
|
|
1330
1356
|
}
|
|
1331
1357
|
//#endregion
|
|
@@ -2334,11 +2360,26 @@ function bodyText(block) {
|
|
|
2334
2360
|
function firstSentence(s) {
|
|
2335
2361
|
return (s.split(/(?<=\.)\s/)[0] ?? s).trim();
|
|
2336
2362
|
}
|
|
2337
|
-
/**
|
|
2363
|
+
/**
|
|
2364
|
+
* Distil the actionable rule from a bullet body. With no "→" the whole bullet is
|
|
2365
|
+
* the rule → its first sentence. Otherwise the rule is everything after the FIRST
|
|
2366
|
+
* "→"; among its arrow-delimited segments (trimmed, empty dropped) take the first
|
|
2367
|
+
* sentence of the LONGEST — the information-dense clause, not whichever short
|
|
2368
|
+
* aside the author appended last. When that clause is under {@link MIN_RULE}
|
|
2369
|
+
* chars, fall back to the first sentence of the WHOLE rule part (never the
|
|
2370
|
+
* narrative), so a short trailing segment never yields an illegible stub yet a
|
|
2371
|
+
* legitimately terse rule is still shown intact.
|
|
2372
|
+
*/
|
|
2373
|
+
function distillRule(text) {
|
|
2374
|
+
const arrow = text.indexOf("→");
|
|
2375
|
+
if (arrow < 0) return firstSentence(text);
|
|
2376
|
+
const rulePart = text.slice(arrow + 1);
|
|
2377
|
+
const rule = firstSentence(rulePart.split("→").map((s) => s.trim()).filter(Boolean).reduce((a, b) => b.length > a.length ? b : a, ""));
|
|
2378
|
+
return rule.length >= 40 ? rule : firstSentence(rulePart);
|
|
2379
|
+
}
|
|
2380
|
+
/** Collapse one older bullet to `- [date] <rule>`: {@link distillRule}, capped. */
|
|
2338
2381
|
function compressBullet(block) {
|
|
2339
|
-
|
|
2340
|
-
const arrow = text.lastIndexOf("→");
|
|
2341
|
-
let rule = firstSentence((arrow >= 0 ? text.slice(arrow + 1) : text).trim());
|
|
2382
|
+
let rule = distillRule(bodyText(block));
|
|
2342
2383
|
if (rule.length > 200) rule = `${rule.slice(0, 199).trimEnd()}…`;
|
|
2343
2384
|
const date = stamp(block);
|
|
2344
2385
|
return `- ${date ? `[${date}] ` : ""}${rule}`;
|
|
@@ -2641,14 +2682,32 @@ function denyHash(tool, input) {
|
|
|
2641
2682
|
/**
|
|
2642
2683
|
* Pure loop check: given the already-pruned in-window map, compute the running
|
|
2643
2684
|
* count for `hash` and whether it repeats (count > 1). No IO — the caller persists.
|
|
2644
|
-
*
|
|
2685
|
+
*
|
|
2686
|
+
* When `dedupMs` is set (>0) and an identical prior deny landed within that
|
|
2687
|
+
* window, the current call is a sibling hook echoing the SAME event (see
|
|
2688
|
+
* {@link module:burst-window}): it returns the prior verdict VERBATIM with
|
|
2689
|
+
* `deduped:true` and does NOT bump the count, so all N fan-out processes agree
|
|
2690
|
+
* on one number instead of counting to N. Absent `dedupMs` (mono-process
|
|
2691
|
+
* callers / unit tests) the historical increment-every-time behaviour holds.
|
|
2692
|
+
* @param hash - {@link denyHash}-derived map key of the current call.
|
|
2645
2693
|
* @param priorDenies - The `{ hash -> DenyEntry }` map, pruned to `now`/`windowMs`.
|
|
2646
|
-
* @param opts - Clock + window.
|
|
2647
|
-
* @returns `{ isRepeat, count, hash }`.
|
|
2694
|
+
* @param opts - Clock + window, plus an optional burst-dedup window.
|
|
2695
|
+
* @returns `{ isRepeat, count, hash, deduped? }`.
|
|
2648
2696
|
*/
|
|
2649
2697
|
function denyLoopCheck(hash, priorDenies, opts) {
|
|
2650
2698
|
const prev = priorDenies[hash];
|
|
2651
|
-
|
|
2699
|
+
if (!(prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs)) return {
|
|
2700
|
+
isRepeat: false,
|
|
2701
|
+
count: 1,
|
|
2702
|
+
hash
|
|
2703
|
+
};
|
|
2704
|
+
if ((opts.dedupMs ?? 0) > 0 && opts.now - prev.lastTs < (opts.dedupMs ?? 0)) return {
|
|
2705
|
+
isRepeat: prev.count > 1,
|
|
2706
|
+
count: prev.count,
|
|
2707
|
+
hash,
|
|
2708
|
+
deduped: true
|
|
2709
|
+
};
|
|
2710
|
+
const count = prev.count + 1;
|
|
2652
2711
|
return {
|
|
2653
2712
|
isRepeat: count > 1,
|
|
2654
2713
|
count,
|
|
@@ -2782,6 +2841,39 @@ function formatSummary(s) {
|
|
|
2782
2841
|
return `gates 7d: ${head}${parts.length ? `; ${parts.join("; ")}` : ""}`;
|
|
2783
2842
|
}
|
|
2784
2843
|
//#endregion
|
|
2844
|
+
//#region src/tracking/one-shot-dedup.ts
|
|
2845
|
+
/**
|
|
2846
|
+
* @module one-shot-dedup
|
|
2847
|
+
* Burst-dedup guard for the one-shot metric ({@link module:one-shot}).
|
|
2848
|
+
*
|
|
2849
|
+
* ONE Claude tool event fans out to ~11 sibling plugin-hook processes, each
|
|
2850
|
+
* calling {@link recordOneShot}; without this the metric would count a single
|
|
2851
|
+
* deny/allow ~11×. Reuses the proven {@link oncePerWindow} cooldown sidecar:
|
|
2852
|
+
* the FIRST process in the {@link module:burst-window} window mutates the
|
|
2853
|
+
* metric, the rest skip. The dedup key includes the outcome KIND (deny-title vs
|
|
2854
|
+
* allow) so a deny and its later fix — different kinds — are never folded into
|
|
2855
|
+
* each other. No `sessionId` → always the first (mono-process + unit-test
|
|
2856
|
+
* parity; a burst can only exist when a real session drives the fan-out).
|
|
2857
|
+
* @packageDocumentation
|
|
2858
|
+
*/
|
|
2859
|
+
/**
|
|
2860
|
+
* True when this `(op, kind)` is the FIRST of its burst for the session — the
|
|
2861
|
+
* process that should actually mutate the metric. Sibling processes firing the
|
|
2862
|
+
* SAME event within {@link BURST_DEDUP_MS} return false and skip the write.
|
|
2863
|
+
* @param op - Content-free operation key ({@link denyHash}("op", …)).
|
|
2864
|
+
* @param kind - Outcome discriminator (`deny:<title>` or `allow`).
|
|
2865
|
+
* @param opts - Clock + state dir + optional session id.
|
|
2866
|
+
* @returns `true` to apply the record, `false` to skip (already counted).
|
|
2867
|
+
*/
|
|
2868
|
+
function burstFirst(op, kind, opts) {
|
|
2869
|
+
const sid = opts.sessionId?.trim();
|
|
2870
|
+
if (!sid) return true;
|
|
2871
|
+
return oncePerWindow(`oneshot:${sid}:${op}:${kind}`, BURST_DEDUP_MS, {
|
|
2872
|
+
now: opts.now,
|
|
2873
|
+
dir: opts.dir
|
|
2874
|
+
});
|
|
2875
|
+
}
|
|
2876
|
+
//#endregion
|
|
2785
2877
|
//#region src/tracking/one-shot.ts
|
|
2786
2878
|
/**
|
|
2787
2879
|
* @module one-shot
|
|
@@ -2829,12 +2921,13 @@ function loadState(path) {
|
|
|
2829
2921
|
function recordOneShot(prompt, input, opts) {
|
|
2830
2922
|
try {
|
|
2831
2923
|
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
2924
|
const op = denyHash("op", {
|
|
2835
2925
|
filePath: input.filePath,
|
|
2836
2926
|
command: input.command
|
|
2837
2927
|
});
|
|
2928
|
+
if (!burstFirst(op, prompt ? `deny:${prompt.title}` : "allow", opts)) return;
|
|
2929
|
+
const path = join(opts.dir, SIDECAR$1);
|
|
2930
|
+
let s = pruneState(loadState(path), opts.now, WINDOW_MS);
|
|
2838
2931
|
s = prompt ? applyDeny(s, prompt.title, op, opts.now) : applyAllow(s, op, opts.now, input.content != null || input.command != null);
|
|
2839
2932
|
atomicWrite(path, JSON.stringify(s));
|
|
2840
2933
|
} catch {}
|
|
@@ -5854,17 +5947,28 @@ function prune(map, now, windowMs) {
|
|
|
5854
5947
|
*/
|
|
5855
5948
|
function recordDeny(tool, input, opts) {
|
|
5856
5949
|
const hash = denyHash(tool, input);
|
|
5950
|
+
const sid = opts.sessionId?.trim();
|
|
5951
|
+
const key = sid ? `${hash}::${sid}` : hash;
|
|
5857
5952
|
const path = join(opts.dir, SIDECAR);
|
|
5858
5953
|
const map = prune(loadMap(path), opts.now, opts.windowMs);
|
|
5859
|
-
const res = denyLoopCheck(
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
5954
|
+
const res = denyLoopCheck(key, map, {
|
|
5955
|
+
now: opts.now,
|
|
5956
|
+
windowMs: opts.windowMs,
|
|
5957
|
+
dedupMs: sid ? BURST_DEDUP_MS : 0
|
|
5958
|
+
});
|
|
5959
|
+
if (!res.deduped) {
|
|
5960
|
+
map[key] = {
|
|
5961
|
+
count: res.count,
|
|
5962
|
+
lastTs: opts.now
|
|
5963
|
+
};
|
|
5964
|
+
try {
|
|
5965
|
+
atomicWrite(path, JSON.stringify(map));
|
|
5966
|
+
} catch {}
|
|
5967
|
+
}
|
|
5968
|
+
return {
|
|
5969
|
+
...res,
|
|
5970
|
+
hash
|
|
5863
5971
|
};
|
|
5864
|
-
try {
|
|
5865
|
-
atomicWrite(path, JSON.stringify(map));
|
|
5866
|
-
} catch {}
|
|
5867
|
-
return res;
|
|
5868
5972
|
}
|
|
5869
5973
|
/**
|
|
5870
5974
|
* Gate tail: record every block deny; on a repeat, return the enriched prompt.
|
|
@@ -5909,12 +6013,14 @@ async function gate(input) {
|
|
|
5909
6013
|
const dir = dirname(input.trackFile);
|
|
5910
6014
|
recordOneShot(prompt, op, {
|
|
5911
6015
|
now: input.now,
|
|
5912
|
-
dir
|
|
6016
|
+
dir,
|
|
6017
|
+
sessionId: input.sessionId
|
|
5913
6018
|
});
|
|
5914
6019
|
return withDenyLoop(prompt, input.tool, op, {
|
|
5915
6020
|
now: input.now,
|
|
5916
6021
|
dir,
|
|
5917
|
-
windowMs: input.windowMs ?? 12e4
|
|
6022
|
+
windowMs: input.windowMs ?? 12e4,
|
|
6023
|
+
sessionId: input.sessionId
|
|
5918
6024
|
});
|
|
5919
6025
|
}
|
|
5920
6026
|
/** Stateless guards, then the trivial fast path, then the stateful APEX gates. */
|
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 postEditTypescript, A as trackSkillRead, At as trackFile, B as lessonsFileFor, C as seoPostToolUse, Ct as gitContext, D as postTrackingSideEffects, Dt as taskContext, E as securityAdvisory, Et as promptSubmitContext, Ft as securityStateDir, G as generateProjectMap, H as cartoSessionStart, It as securityStatePath, J as loadEnriched, K as isProject, Lt as todayUtc, M as dispatchLifecycle, Mt as isoUtc, N as aipilotPostToolUse, Nt as loadSecurityState, O as trackWatchResearch, Ot as defaultStateDir, P as dispatchAipilot, Pt as saveSecurityState, Q as listChildren, R as dispatchLessons, S as postEditContext, St as devContext, T as dispatchMemory, Tt as claudeMdKey, U as generateEcosystemMap, V as lessonsStateFileFor, W as writePluginMap, X as countFiles, Y as mergeLines, Z as getFileDesc, _ as preCommitGate, _t as sessionStartCore, a as recordActivity, at as validateTeammateOutput, b as extractSymbols, bt as removeOldFiles, c as MCP_TTL_MS, ct as validateTailwind, d as isMcpTool, dt as countLoc, et as trackSessionChanges, f as queryOf, ft as detectSolidProfile, g as gate, gt as runSessionStartCleanups, h as TRIVIAL_BUDGET, ht as readRules, i as respond, it as logToolFailure, j as trackEnrichment, jt as normalizeEvent, k as trackMcpResearch, kt as projectHash, l as WEBFETCH_TTL_MS, lt as validateSolidGate, m as REQUIRED_AGENTS, mt as injectRules, n as activityFor, nt as cleanupSession, o as mcpPostStore, ot as trackAgentMemory, p as DEFAULT_WINDOW_MS, pt as solidDetectStart, q as writeTree, r as handlePre, rt as saveApexState, s as mcpPreIntercept, st as subagentCacheContext, t as handleHook, tt as validateRulesLoaded, u as cacheQueryOf, ut as checkFileSize, v as detectDuplication, vt as pruneEmptyDirs, w as seoPostToolUseResponse, wt as projectContext, x as lifecycleStdout, xt as trimLogFile, y as dryGate, yt as purgeTtlTree, z as lessonsArchiveFileFor } from "../handle-
|
|
3
|
+
import { $ as postEditTypescript, A as trackSkillRead, At as trackFile, B as lessonsFileFor, C as seoPostToolUse, Ct as gitContext, D as postTrackingSideEffects, Dt as taskContext, E as securityAdvisory, Et as promptSubmitContext, Ft as securityStateDir, G as generateProjectMap, H as cartoSessionStart, It as securityStatePath, J as loadEnriched, K as isProject, Lt as todayUtc, M as dispatchLifecycle, Mt as isoUtc, N as aipilotPostToolUse, Nt as loadSecurityState, O as trackWatchResearch, Ot as defaultStateDir, P as dispatchAipilot, Pt as saveSecurityState, Q as listChildren, R as dispatchLessons, S as postEditContext, St as devContext, T as dispatchMemory, Tt as claudeMdKey, U as generateEcosystemMap, V as lessonsStateFileFor, W as writePluginMap, X as countFiles, Y as mergeLines, Z as getFileDesc, _ as preCommitGate, _t as sessionStartCore, a as recordActivity, at as validateTeammateOutput, b as extractSymbols, bt as removeOldFiles, c as MCP_TTL_MS, ct as validateTailwind, d as isMcpTool, dt as countLoc, et as trackSessionChanges, f as queryOf, ft as detectSolidProfile, g as gate, gt as runSessionStartCleanups, h as TRIVIAL_BUDGET, ht as readRules, i as respond, it as logToolFailure, j as trackEnrichment, jt as normalizeEvent, k as trackMcpResearch, kt as projectHash, l as WEBFETCH_TTL_MS, lt as validateSolidGate, m as REQUIRED_AGENTS, mt as injectRules, n as activityFor, nt as cleanupSession, o as mcpPostStore, ot as trackAgentMemory, p as DEFAULT_WINDOW_MS, pt as solidDetectStart, q as writeTree, r as handlePre, rt as saveApexState, s as mcpPreIntercept, st as subagentCacheContext, t as handleHook, tt as validateRulesLoaded, u as cacheQueryOf, ut as checkFileSize, v as detectDuplication, vt as pruneEmptyDirs, w as seoPostToolUseResponse, wt as projectContext, x as lifecycleStdout, xt as trimLogFile, y as dryGate, yt as purgeTtlTree, z as lessonsArchiveFileFor } from "../handle-L4ZNmpwN.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.55",
|
|
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",
|