@fusengine/harness 0.1.54 → 0.1.56

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-CoyvRORV.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,35 @@ 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
+ * The bullet's `narrative → rule` delimiter: a SPACED arrow only. A GLUED arrow
2365
+ * between tokens (e.g. `120s→300s`, `s→3`) is prose the author wrote, never a
2366
+ * delimiter — matching on `→` alone chopped rules mid-token (bug: `300s) pensant
2367
+ * corriger…`). Also used to split rule-internal clauses.
2368
+ */
2369
+ const RULE_ARROW = /\s+→\s+/;
2370
+ /**
2371
+ * Distil the actionable rule from a bullet body. With no spaced arrow the whole
2372
+ * bullet is the rule → its first sentence. Otherwise the rule is everything after
2373
+ * the FIRST spaced arrow; its spaced-arrow-delimited segments are kept whole
2374
+ * except for TRAILING short asides (< {@link MIN_RULE} chars, e.g. `→ (cf.
2375
+ * lecture).`) which are dropped — so an arrow used as PROSE inside a rule (`maps
2376
+ * X → Y doit…`) is preserved intact rather than chopped at the arrow. When the
2377
+ * kept rule is still under {@link MIN_RULE} chars, fall back to the first sentence
2378
+ * of the WHOLE rule part (never the narrative), avoiding an illegible stub.
2379
+ */
2380
+ function distillRule(text) {
2381
+ const sep = text.search(RULE_ARROW);
2382
+ if (sep < 0) return firstSentence(text);
2383
+ const rulePart = text.slice(sep).replace(RULE_ARROW, "").trim();
2384
+ const segments = rulePart.split(RULE_ARROW).map((s) => s.trim()).filter(Boolean);
2385
+ while (segments.length > 1 && (segments[segments.length - 1]?.length ?? 0) < 40) segments.pop();
2386
+ const rule = firstSentence(segments.join(" → "));
2387
+ return rule.length >= 40 ? rule : firstSentence(rulePart);
2388
+ }
2389
+ /** Collapse one older bullet to `- [date] <rule>`: {@link distillRule}, capped. */
2338
2390
  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());
2391
+ let rule = distillRule(bodyText(block));
2342
2392
  if (rule.length > 200) rule = `${rule.slice(0, 199).trimEnd()}…`;
2343
2393
  const date = stamp(block);
2344
2394
  return `- ${date ? `[${date}] ` : ""}${rule}`;
@@ -2641,14 +2691,32 @@ function denyHash(tool, input) {
2641
2691
  /**
2642
2692
  * Pure loop check: given the already-pruned in-window map, compute the running
2643
2693
  * count for `hash` and whether it repeats (count > 1). No IO — the caller persists.
2644
- * @param hash - {@link denyHash} of the current call.
2694
+ *
2695
+ * When `dedupMs` is set (>0) and an identical prior deny landed within that
2696
+ * window, the current call is a sibling hook echoing the SAME event (see
2697
+ * {@link module:burst-window}): it returns the prior verdict VERBATIM with
2698
+ * `deduped:true` and does NOT bump the count, so all N fan-out processes agree
2699
+ * on one number instead of counting to N. Absent `dedupMs` (mono-process
2700
+ * callers / unit tests) the historical increment-every-time behaviour holds.
2701
+ * @param hash - {@link denyHash}-derived map key of the current call.
2645
2702
  * @param priorDenies - The `{ hash -> DenyEntry }` map, pruned to `now`/`windowMs`.
2646
- * @param opts - Clock + window.
2647
- * @returns `{ isRepeat, count, hash }`.
2703
+ * @param opts - Clock + window, plus an optional burst-dedup window.
2704
+ * @returns `{ isRepeat, count, hash, deduped? }`.
2648
2705
  */
2649
2706
  function denyLoopCheck(hash, priorDenies, opts) {
2650
2707
  const prev = priorDenies[hash];
2651
- const count = (prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs ? prev.count : 0) + 1;
2708
+ if (!(prev && typeof prev.lastTs === "number" && opts.now - prev.lastTs < opts.windowMs)) return {
2709
+ isRepeat: false,
2710
+ count: 1,
2711
+ hash
2712
+ };
2713
+ if ((opts.dedupMs ?? 0) > 0 && opts.now - prev.lastTs < (opts.dedupMs ?? 0)) return {
2714
+ isRepeat: prev.count > 1,
2715
+ count: prev.count,
2716
+ hash,
2717
+ deduped: true
2718
+ };
2719
+ const count = prev.count + 1;
2652
2720
  return {
2653
2721
  isRepeat: count > 1,
2654
2722
  count,
@@ -2782,6 +2850,39 @@ function formatSummary(s) {
2782
2850
  return `gates 7d: ${head}${parts.length ? `; ${parts.join("; ")}` : ""}`;
2783
2851
  }
2784
2852
  //#endregion
2853
+ //#region src/tracking/one-shot-dedup.ts
2854
+ /**
2855
+ * @module one-shot-dedup
2856
+ * Burst-dedup guard for the one-shot metric ({@link module:one-shot}).
2857
+ *
2858
+ * ONE Claude tool event fans out to ~11 sibling plugin-hook processes, each
2859
+ * calling {@link recordOneShot}; without this the metric would count a single
2860
+ * deny/allow ~11×. Reuses the proven {@link oncePerWindow} cooldown sidecar:
2861
+ * the FIRST process in the {@link module:burst-window} window mutates the
2862
+ * metric, the rest skip. The dedup key includes the outcome KIND (deny-title vs
2863
+ * allow) so a deny and its later fix — different kinds — are never folded into
2864
+ * each other. No `sessionId` → always the first (mono-process + unit-test
2865
+ * parity; a burst can only exist when a real session drives the fan-out).
2866
+ * @packageDocumentation
2867
+ */
2868
+ /**
2869
+ * True when this `(op, kind)` is the FIRST of its burst for the session — the
2870
+ * process that should actually mutate the metric. Sibling processes firing the
2871
+ * SAME event within {@link BURST_DEDUP_MS} return false and skip the write.
2872
+ * @param op - Content-free operation key ({@link denyHash}("op", …)).
2873
+ * @param kind - Outcome discriminator (`deny:<title>` or `allow`).
2874
+ * @param opts - Clock + state dir + optional session id.
2875
+ * @returns `true` to apply the record, `false` to skip (already counted).
2876
+ */
2877
+ function burstFirst(op, kind, opts) {
2878
+ const sid = opts.sessionId?.trim();
2879
+ if (!sid) return true;
2880
+ return oncePerWindow(`oneshot:${sid}:${op}:${kind}`, BURST_DEDUP_MS, {
2881
+ now: opts.now,
2882
+ dir: opts.dir
2883
+ });
2884
+ }
2885
+ //#endregion
2785
2886
  //#region src/tracking/one-shot.ts
2786
2887
  /**
2787
2888
  * @module one-shot
@@ -2829,12 +2930,13 @@ function loadState(path) {
2829
2930
  function recordOneShot(prompt, input, opts) {
2830
2931
  try {
2831
2932
  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
2933
  const op = denyHash("op", {
2835
2934
  filePath: input.filePath,
2836
2935
  command: input.command
2837
2936
  });
2937
+ if (!burstFirst(op, prompt ? `deny:${prompt.title}` : "allow", opts)) return;
2938
+ const path = join(opts.dir, SIDECAR$1);
2939
+ let s = pruneState(loadState(path), opts.now, WINDOW_MS);
2838
2940
  s = prompt ? applyDeny(s, prompt.title, op, opts.now) : applyAllow(s, op, opts.now, input.content != null || input.command != null);
2839
2941
  atomicWrite(path, JSON.stringify(s));
2840
2942
  } catch {}
@@ -5494,6 +5596,50 @@ function frameworkSkillGate(input, refsRead, existingCodeLines) {
5494
5596
  return skillTriggerGate(input.framework, content, refsRead, forced, input.cwd, input.filePath);
5495
5597
  }
5496
5598
  //#endregion
5599
+ //#region src/freshness/ref-evidence.ts
5600
+ /**
5601
+ * Platform-transcript reconciliation for `.md` reference reads — the durable
5602
+ * counterpart to {@link agentsRanFromTranscript} for agent freshness.
5603
+ *
5604
+ * WHY: the session track is persisted by a non-atomic load→mutate→save. Under
5605
+ * the multi-plugin hook fan-out (one hook process per installed plugin, ×N) plus
5606
+ * back-to-back tool events, concurrent writers clobber each other (lost update).
5607
+ * `agents`/`authorizations` self-heal — every explore/research/doc call rewrites
5608
+ * them, so a lost update lands again on the next of hundreds of writes — but a
5609
+ * `refsRead` entry is written ONCE (when the `.md` is Read), so a single lost
5610
+ * update erases it permanently, and the lead has no SubagentStop
5611
+ * {@link harvestAgentEvidence} pass to reconcile it (sub-agents do, which is why
5612
+ * only the LEAD's solidReadGate never credited). The Claude-authored transcript
5613
+ * is append-only and race-immune, so folding its `.md` Reads back into the track
5614
+ * restores the lost evidence; each gate still applies its own TTL/session policy.
5615
+ */
5616
+ /**
5617
+ * Fold every `.md` `Read` in the transcript into `track` as a timestamped ref
5618
+ * read (immutably). PURE reconciliation — the caller owns the track; each read
5619
+ * is stamped with its transcript timestamp (unstamped → `now`, lenient, matching
5620
+ * {@link agentsRanFromTranscript}), and an existing MORE-recent stamp is never
5621
+ * rolled back. Fail-open: an absent/unreadable transcript returns `track`
5622
+ * unchanged (same reference).
5623
+ * @param track - The current (possibly race-damaged) session track.
5624
+ * @param transcriptPath - Claude `transcript_path` for this session.
5625
+ * @param now - Fallback epoch-ms for transcript entries the platform left unstamped.
5626
+ * @returns The track with transcript `.md` reads merged into `refsRead`/`refsReadAt`.
5627
+ */
5628
+ function reconcileRefReadsFromTranscript(track, transcriptPath, now) {
5629
+ const uses = readAgentToolUses(transcriptPath);
5630
+ if (!uses) return track;
5631
+ let next = track;
5632
+ for (const u of uses) {
5633
+ if (u.name !== "Read") continue;
5634
+ const path = String(u.input?.file_path ?? u.input?.path ?? "");
5635
+ if (!path.endsWith(".md")) continue;
5636
+ const ts = u.ts ?? now;
5637
+ const prev = next.refsReadAt?.[path];
5638
+ if (prev === void 0 || prev < ts) next = recordRefRead(next, path, ts);
5639
+ }
5640
+ return next;
5641
+ }
5642
+ //#endregion
5497
5643
  //#region src/policy/shadcn-skill-gate.ts
5498
5644
  /** File extensions the shadcn gate polices (source: `\.(tsx|jsx|css|scss|json)$`). */
5499
5645
  const SHADCN_FILE_RE = /\.(tsx|jsx|css|scss|json)$/;
@@ -5854,17 +6000,28 @@ function prune(map, now, windowMs) {
5854
6000
  */
5855
6001
  function recordDeny(tool, input, opts) {
5856
6002
  const hash = denyHash(tool, input);
6003
+ const sid = opts.sessionId?.trim();
6004
+ const key = sid ? `${hash}::${sid}` : hash;
5857
6005
  const path = join(opts.dir, SIDECAR);
5858
6006
  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
6007
+ const res = denyLoopCheck(key, map, {
6008
+ now: opts.now,
6009
+ windowMs: opts.windowMs,
6010
+ dedupMs: sid ? BURST_DEDUP_MS : 0
6011
+ });
6012
+ if (!res.deduped) {
6013
+ map[key] = {
6014
+ count: res.count,
6015
+ lastTs: opts.now
6016
+ };
6017
+ try {
6018
+ atomicWrite(path, JSON.stringify(map));
6019
+ } catch {}
6020
+ }
6021
+ return {
6022
+ ...res,
6023
+ hash
5863
6024
  };
5864
- try {
5865
- atomicWrite(path, JSON.stringify(map));
5866
- } catch {}
5867
- return res;
5868
6025
  }
5869
6026
  /**
5870
6027
  * Gate tail: record every block deny; on a repeat, return the enriched prompt.
@@ -5909,12 +6066,14 @@ async function gate(input) {
5909
6066
  const dir = dirname(input.trackFile);
5910
6067
  recordOneShot(prompt, op, {
5911
6068
  now: input.now,
5912
- dir
6069
+ dir,
6070
+ sessionId: input.sessionId
5913
6071
  });
5914
6072
  return withDenyLoop(prompt, input.tool, op, {
5915
6073
  now: input.now,
5916
6074
  dir,
5917
- windowMs: input.windowMs ?? 12e4
6075
+ windowMs: input.windowMs ?? 12e4,
6076
+ sessionId: input.sessionId
5918
6077
  });
5919
6078
  }
5920
6079
  /** Stateless guards, then the trivial fast path, then the stateful APEX gates. */
@@ -5942,8 +6101,7 @@ async function runGates(input) {
5942
6101
  if (modular) return modular;
5943
6102
  if (!input.filePath) return null;
5944
6103
  const filePath = input.filePath;
5945
- const window = input.windowMs ?? 12e4;
5946
- const track = await loadTrack(input.trackFile);
6104
+ const track = reconcileRefReadsFromTranscript(await loadTrack(input.trackFile), input.transcriptPath, input.now);
5947
6105
  const solidOrSkill = frameworkSkillGate(input, track.refsRead, existingCodeLines);
5948
6106
  if (solidOrSkill) return solidOrSkill;
5949
6107
  if (isShadcnWrite(input.tool, filePath)) {
@@ -5964,7 +6122,7 @@ async function runGates(input) {
5964
6122
  });
5965
6123
  if (geminiBlock) return geminiBlock;
5966
6124
  if (isApexScoped(input.filePath)) {
5967
- const apex = await apexScopedGate(input, track, window);
6125
+ const apex = await apexScopedGate(input, track, input.windowMs ?? 12e4);
5968
6126
  if (apex) return apex;
5969
6127
  }
5970
6128
  return dryGate(input.tool, input.filePath, input.content, input.cwd);
@@ -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-CoyvRORV.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.56",
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",