@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-CVvp1yuc.mjs";
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
- /** Collapse one older bullet to `- [date] <rule>`: first sentence after last "→", capped. */
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
- const text = bodyText(block);
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
- * @param hash - {@link denyHash} of the current call.
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
- const count = (prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs ? prev.count : 0) + 1;
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(hash, map, opts);
5860
- map[hash] = {
5861
- count: res.count,
5862
- lastTs: opts.now
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. */
@@ -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-CVvp1yuc.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-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.54",
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",