@ijfw/memory-server 1.5.0 → 1.5.1

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.
@@ -11,13 +11,16 @@
11
11
  * straight off that contract.
12
12
  *
13
13
  * ───────────────────────────────────────────────────────────────────────────
14
- * SCOPE BOUNDARY — T2 builds the verb core; three later tasks wrap it:
14
+ * SCOPE BOUNDARY — T2 built the verb core; three later tasks wrapped it.
15
+ * All three (T3/T4/T5) are now REALIZED — this section is kept as the design
16
+ * record, but the seams below are live, not stubs:
15
17
  *
16
- * T3 (lock hierarchy) — wraps `_withLocks()`. Today it is a pass-through;
17
- * T3 swaps in `withFsLock` acquisition in the §3
18
- * canonical acquire-order. Handlers already declare
19
- * the ordered lock-target list they touch, so T3
20
- * only has to make `_withLocks` honor it.
18
+ * T3 (lock hierarchy) — `_withLocks()` is NOT a pass-through. It acquires
19
+ * real filesystem locks via `withFsLock`, ordering
20
+ * each verb's declared lock-target list through
21
+ * `canonicalLockOrder` (the §3 canonical acquire
22
+ * order) to prevent deadlock, then runs `fn` while
23
+ * the locks are held and releases on completion.
21
24
  * T4 (intent/commit) — wraps `_journalBegin()` / `_journalCommit()`.
22
25
  * Today they are no-ops; T4 makes them write the
23
26
  * write-ahead `intent-journal.jsonl` records and
@@ -40,6 +43,7 @@ import { join, isAbsolute, dirname, basename } from 'node:path';
40
43
  import { homedir } from 'node:os';
41
44
  import { randomUUID, createHash } from 'node:crypto';
42
45
  import { gunzipSync } from 'node:zlib';
46
+ import { execFileSync } from 'node:child_process';
43
47
 
44
48
  import { writeAtomic, readSafe } from '../lib/atomic-io.js';
45
49
  import { rotateJsonlIfNeeded } from '../lib/jsonl-rotation.js';
@@ -58,6 +62,17 @@ import {
58
62
  appendUnderHeldLock as appendEventUnderHeldLock,
59
63
  resolveEventLogPath,
60
64
  } from './state-events.js';
65
+ // v1.5.1 cleanup C1: S08 incident-driven worktree safety guards. Previously
66
+ // orphan (only importer was the unwired runtime-loop.js). Wired here as
67
+ // preconditions of the LIVE `subagent.dispatch` verb — the genuinely-reachable
68
+ // worktree-isolated spawn path (ijfw_state MCP tool → query → subagent.dispatch).
69
+ // worktree-guards.js has no state-sdk dependency, so a static import is safe.
70
+ import {
71
+ captureSpawnToplevel,
72
+ assertPathWithinToplevel,
73
+ assertNoCwdDrift,
74
+ assertNotProtectedRef,
75
+ } from '../lib/worktree-guards.js';
61
76
 
62
77
  // ---------------------------------------------------------------------------
63
78
  // Gate-function indirection (T15 — Model 4 testability seam)
@@ -704,6 +719,88 @@ function requireStr(value, field) {
704
719
 
705
720
  function nowIso() { return new Date().toISOString(); }
706
721
 
722
+ /**
723
+ * v1.5.1 cleanup C1 — S08 worktree-guard preconditions for `subagent.dispatch`.
724
+ *
725
+ * Runs the three incident-driven guards (worktree-guards.js) against the
726
+ * project root a worktree-isolated subagent is about to be dispatched into:
727
+ * #3099 — assertPathWithinToplevel (abs-path / symlink escape)
728
+ * #3097 — assertNoCwdDrift (cwd drifted off the toplevel)
729
+ * #2924 — assertNotProtectedRef (HEAD on main/master/develop/…)
730
+ *
731
+ * Semantics — `subagent.dispatch` is a brief-COMPOSITION verb (it produces a
732
+ * deterministic dispatch brief; the real spawn happens platform-side). The
733
+ * guards are therefore PRECONDITIONS surfaced on the result, not a hard abort
734
+ * of brief composition:
735
+ * - Project root IS a git repo → all 3 guards run. A failure is reported on
736
+ * `guard.violations[]` and `guard.ok=false`. With IJFW_WORKTREE_GUARD_STRICT=1
737
+ * a violation throws (aborts the dispatch before `_withLocks`).
738
+ * - Project root is NOT a git repo (or guards can't run) → `guard.ok` stays
739
+ * true with `guard.skipped` set; brief composition proceeds. This keeps the
740
+ * verb usable from non-git temp dirs (tests, scratch projects).
741
+ *
742
+ * Only `'worktree'` isolation is guarded — `'shared'` dispatch spawns no
743
+ * separate worktree so containment/drift/protected-ref don't apply.
744
+ *
745
+ * @param {string} projectRoot absolute path the subagent dispatches into
746
+ * @param {'shared'|'worktree'} isolation
747
+ * @returns {{ok:boolean, skipped?:string, violations:string[], branch?:string}}
748
+ * @throws {Error} only when IJFW_WORKTREE_GUARD_STRICT=1 and a guard fails
749
+ */
750
+ function runWorktreeGuards(projectRoot, isolation) {
751
+ if (isolation !== 'worktree') {
752
+ return { ok: true, skipped: 'shared-isolation', violations: [] };
753
+ }
754
+ // Quiet git-repo pre-check — captureSpawnToplevel would otherwise let git's
755
+ // own "fatal: not a git repository" hit our stderr on non-git roots (common
756
+ // in tests / scratch projects). Suppress git stderr here, then run the real
757
+ // guards only when we know we're inside a work tree.
758
+ try {
759
+ const probe = execFileSync(
760
+ 'git', ['rev-parse', '--is-inside-work-tree'],
761
+ { cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
762
+ ).trim();
763
+ if (probe !== 'true') {
764
+ return { ok: true, skipped: 'not-a-git-repo', violations: [] };
765
+ }
766
+ } catch {
767
+ // Not a git tree — nothing to contain. Brief composition still proceeds.
768
+ return { ok: true, skipped: 'not-a-git-repo', violations: [] };
769
+ }
770
+ let toplevel;
771
+ try {
772
+ toplevel = captureSpawnToplevel(projectRoot);
773
+ } catch {
774
+ return { ok: true, skipped: 'not-a-git-repo', violations: [] };
775
+ }
776
+ const violations = [];
777
+ let branch;
778
+ try {
779
+ assertPathWithinToplevel(projectRoot, toplevel);
780
+ } catch (e) {
781
+ violations.push(`path-escape: ${e.message}`);
782
+ }
783
+ try {
784
+ assertNoCwdDrift(toplevel, projectRoot);
785
+ } catch (e) {
786
+ violations.push(`cwd-drift: ${e.message}`);
787
+ }
788
+ try {
789
+ branch = assertNotProtectedRef(projectRoot);
790
+ } catch (e) {
791
+ violations.push(`protected-ref: ${e.message}`);
792
+ }
793
+ const ok = violations.length === 0;
794
+ if (!ok && process.env.IJFW_WORKTREE_GUARD_STRICT === '1') {
795
+ throw new Error(
796
+ `state-sdk: subagent.dispatch S08 worktree guard failed — ${violations.join('; ')}`,
797
+ );
798
+ }
799
+ return ok
800
+ ? { ok: true, violations: [], branch }
801
+ : { ok: false, violations };
802
+ }
803
+
707
804
  // ---------------------------------------------------------------------------
708
805
  // VERB HANDLERS — one per contract §7 block. Signature: (payload, ctx, env).
709
806
  // `env` carries the per-invocation { verbId } so handlers can stamp records.
@@ -1000,6 +1097,13 @@ const handlers = {
1000
1097
  const isolation = payload?.isolation === 'shared' ? 'shared' : 'worktree';
1001
1098
  const role = typeof payload?.role === 'string' && payload.role.length > 0
1002
1099
  ? payload.role : null;
1100
+ // v1.5.1 cleanup C1 — S08 worktree-guard preconditions. Runs the three
1101
+ // incident-driven guards BEFORE the wave-state mutation when isolation is
1102
+ // 'worktree'. This is the production caller worktree-guards.js was missing
1103
+ // (its only prior importer was the unwired runtime-loop.js). With
1104
+ // IJFW_WORKTREE_GUARD_STRICT=1 a guard violation throws here, aborting the
1105
+ // dispatch before `_withLocks`; otherwise it is surfaced on the result.
1106
+ const worktreeGuard = runWorktreeGuards(root, isolation);
1003
1107
  // Caller-supplied env passthrough (object: name → value). Coerce values to
1004
1108
  // strings (env vars are always strings) and drop nullish entries.
1005
1109
  const callerEnv = (payload?.env && typeof payload.env === 'object'
@@ -1078,6 +1182,11 @@ const handlers = {
1078
1182
  isolation,
1079
1183
  inheritedEnv,
1080
1184
  eventLogPath,
1185
+ // v1.5.1 cleanup C1 — S08 guard outcome. `{ok:true}` when guards passed
1186
+ // or were skipped (non-git root / shared isolation); `{ok:false,
1187
+ // violations[]}` when a containment/drift/protected-ref hazard was
1188
+ // detected (non-strict mode — the orchestrator should not spawn).
1189
+ worktreeGuard,
1081
1190
  };
1082
1191
  }, env);
1083
1192
  },
@@ -1127,12 +1236,69 @@ const handlers = {
1127
1236
  process.stderr.write(`[state-sdk] WARN subagent.post-done gate execution-fail: ${e.message}\n`);
1128
1237
  return { ok: true, advisory: true, gate: 'post-done-self-check', reason: e.message };
1129
1238
  }
1239
+ // v1.5.1 cleanup C1 — T20 truncation classification. When the caller hands
1240
+ // us the subagent's `events` stream and/or intent `journal` on the payload,
1241
+ // run `detectTruncation` so a truncated post-DONE is classified on the LIVE
1242
+ // path (ijfw_state MCP tool → query → subagent.post-done). This is the
1243
+ // production caller recovery/truncation.js was missing — its only prior
1244
+ // importer was the unwired runtime-loop.js. Annotation-only: the classifier
1245
+ // never throws and never alters the self-check verdict.
1246
+ let truncation;
1247
+ if (Array.isArray(payload?.events) || Array.isArray(payload?.journal)) {
1248
+ try {
1249
+ const { detectTruncation } = await import('../recovery/truncation.js');
1250
+ const det = detectTruncation({
1251
+ events: payload.events,
1252
+ journal: payload.journal,
1253
+ expectedTerminalVerb: payload.expectedTerminalVerb,
1254
+ });
1255
+ truncation = {
1256
+ truncated: det.truncated,
1257
+ reason: det.reason,
1258
+ };
1259
+ } catch (e) {
1260
+ process.stderr.write(
1261
+ `[state-sdk] WARN subagent.post-done truncation classify failed: ${e.message}\n`,
1262
+ );
1263
+ }
1264
+ }
1130
1265
  if (selfCheck.verdict !== 'PASSED') {
1131
1266
  const reason = `self-check FAILED — ${selfCheck.files_missing.length} missing file(s), `
1132
1267
  + `${selfCheck.commits_missing.length} missing commit(s)`;
1268
+ // v1.5.1: LIVE wiring of debug-trident (T29) onto the production
1269
+ // gate-failure path. When the post-done self-check FAILS this is
1270
+ // exactly the stalled-investigation moment the Trident debug loop
1271
+ // exists for — dispatch codex+gemini to generate competing root-cause
1272
+ // hypotheses against the gate-failure evidence. FIRE-AND-FORGET:
1273
+ // `maybeFireDebugTrident` returns immediately, the campaign runs in a
1274
+ // detached promise — the verb's return value + timing are UNCHANGED so
1275
+ // STATE-SDK-CONTRACT §8 (subagent.post-done is a fast read verb) holds.
1276
+ // Env-gated (IJFW_DEBUG_TRIDENT) + silent no-op on missing deps; never
1277
+ // throws. Dynamic import avoids a static require cycle. Mirrors the
1278
+ // A-Mem auto-linker fire-and-forget pattern in memory/fts5.js.
1279
+ try {
1280
+ import('./debug-trident-trigger.js')
1281
+ .then(({ maybeFireDebugTrident }) => {
1282
+ maybeFireDebugTrident({
1283
+ projectRoot,
1284
+ subagentId: _subagentId,
1285
+ reason,
1286
+ reportText,
1287
+ selfCheck,
1288
+ });
1289
+ })
1290
+ .catch((e) => {
1291
+ try {
1292
+ process.stderr.write(
1293
+ `[state-sdk] WARN subagent.post-done debug-trident dispatch failed: ${e.message}\n`,
1294
+ );
1295
+ } catch { /* never throw */ }
1296
+ });
1297
+ } catch { /* fire-and-forget — never alters the verb verdict */ }
1133
1298
  if (!GATE_BYPASS) {
1134
1299
  return {
1135
1300
  ok: false, refused: true, gate: 'post-done-self-check', reason,
1301
+ ...(truncation ? { truncation } : {}),
1136
1302
  };
1137
1303
  }
1138
1304
  // Bypass masks a would-be refusal — emit a loud WARN + advisory
@@ -1149,6 +1315,7 @@ const handlers = {
1149
1315
  claimedCommits: selfCheck.commits_claimed,
1150
1316
  verified: false,
1151
1317
  },
1318
+ ...(truncation ? { truncation } : {}),
1152
1319
  };
1153
1320
  }
1154
1321
  return {
@@ -1158,6 +1325,7 @@ const handlers = {
1158
1325
  claimedCommits: selfCheck.commits_claimed,
1159
1326
  verified: selfCheck.verdict === 'PASSED',
1160
1327
  },
1328
+ ...(truncation ? { truncation } : {}),
1161
1329
  };
1162
1330
  },
1163
1331
 
@@ -27,6 +27,10 @@ import { query } from './state-sdk.js';
27
27
  import { pollEvents } from './state-events.js';
28
28
  // v1.5.0 N4.obs M1+M2: trace-id + hierarchical observation path.
29
29
  import { getTraceId, composePath } from '../observability/trace-id.js';
30
+ // v1.5.1 W2.F: checkpoint-contract evaluator (N4.obs M3). Validates a final
31
+ // status report carried by a checkpoint envelope against the v1.4.4 N2
32
+ // four-section contract before the envelope is written.
33
+ import { evaluateCheckpointContract } from '../observability/evaluator-checkpoint-contract.js';
30
34
 
31
35
  // ---------------------------------------------------------------------------
32
36
  // Frozen constants — Wave 11-A imports these directly
@@ -123,6 +127,21 @@ export async function recordCheckpoint(waveId, subId, checkpoint, projectRoot) {
123
127
  ...checkpoint,
124
128
  };
125
129
 
130
+ // v1.5.1 W2.F: contract-validate at emission time. When a checkpoint
131
+ // envelope carries the subagent's final status report (`checkpoint.report`),
132
+ // it MUST satisfy the v1.4.4 N2 four-section contract (Status/SHAs/Files/
133
+ // Tests). Schema drift now fails LOUD here at write — not silently at read.
134
+ // Progress-only checkpoints (no `report` field) skip this check unchanged.
135
+ if (typeof checkpoint.report === 'string') {
136
+ const evalResult = evaluateCheckpointContract(checkpoint.report);
137
+ if (!evalResult.valid) {
138
+ throw new Error(
139
+ `subagent-telemetry: checkpoint report for ${waveId}/${subId} `
140
+ + `violates checkpoint-contract — ${evalResult.reason}`,
141
+ );
142
+ }
143
+ }
144
+
126
145
  const serialised = JSON.stringify(envelope);
127
146
  if (serialised.length > MAX_CHECKPOINT_SIZE) {
128
147
  throw new Error(
@@ -65,9 +65,11 @@ import {
65
65
  * projectRoot. Used by deployResolvedSkill to know which platforms to write
66
66
  * the merged body into.
67
67
  *
68
- * TODO(W2b/t11): replace this with an exported helper from
69
- * installer/src/install-helpers.js once that module exposes a canonical
70
- * platform-list getter. Until then this on-disk probe is the contract.
68
+ * DEFERRED (platform-list consolidation): could be replaced with an exported
69
+ * helper from installer/src/install-helpers.js once that module exposes a
70
+ * canonical platform-list getter. Until then this on-disk probe is the
71
+ * contract — and as an on-disk probe it correctly reflects what is actually
72
+ * deployed, so the consolidation is a nice-to-have, not a defect.
71
73
  *
72
74
  * @param {string} projectRoot
73
75
  * @returns {string[]} absolute paths to existing platform skill dirs
@@ -21,12 +21,22 @@
21
21
  * pattern — if the process crashes mid-fix the next run prunes the dangling
22
22
  * worktree and reports the survivor.
23
23
  *
24
+ * WIRE-UP (v1.5.1 C2): the consensus entry point `runConsensusFix` is invoked
25
+ * by `cross-orchestrator.js#runPhaseEConverge` when its caller passes
26
+ * `autoFix: true`. After a non-PASS convergence the orchestrator extracts the
27
+ * HIGH findings that 2+ lenses agreed on (`consensusHighFindings`) and runs
28
+ * the atomic per-finding fix loop over them — the "2+ lenses agree → fixer
29
+ * fires automatically" contract from the T27 brief. This module is therefore
30
+ * a live, reachable production path, not an orphan.
31
+ *
24
32
  * Zero new prod deps. ESM. Node ≥18.
25
33
  */
26
34
 
27
- import { existsSync } from 'node:fs';
35
+ import { existsSync, realpathSync } from 'node:fs';
28
36
  import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises';
29
- import { join, extname, relative, isAbsolute, resolve as resolvePath } from 'node:path';
37
+ import {
38
+ join, extname, relative, isAbsolute, resolve as resolvePath, dirname,
39
+ } from 'node:path';
30
40
  import { tmpdir } from 'node:os';
31
41
  import { execFile, spawnSync } from 'node:child_process';
32
42
  import { promisify } from 'node:util';
@@ -35,6 +45,82 @@ import { withRecoverySentinel } from '../lib/worktree-recovery.js';
35
45
 
36
46
  const execFileAsync = promisify(execFile);
37
47
 
48
+ /* ────────────────────── R5-1.10: auto-fix safety boundary ────────────────── */
49
+
50
+ /**
51
+ * Default ceiling on the number of distinct files a single `runConsensusFix`
52
+ * call may write. Auto-fix is opt-in, but even when enabled it must not be
53
+ * able to mass-rewrite a repository — past this cap the loop stops and
54
+ * reports the remainder rather than continuing to mutate. Callers can lower
55
+ * (never silently raise past sanity) this via `maxAutoFixFiles`.
56
+ */
57
+ export const DEFAULT_MAX_AUTOFIX_FILES = 10;
58
+ // Hard ceiling — a caller cannot pass `maxAutoFixFiles` above this.
59
+ const MAX_AUTOFIX_FILES_CEILING = 50;
60
+
61
+ /**
62
+ * isPathContained(filePath, projectRoot) — true iff `filePath` resolves to a
63
+ * location at or under `projectRoot`. Symlinks are resolved on BOTH sides
64
+ * (realpath) so a symlink inside the repo that points outside it is rejected.
65
+ *
66
+ * Mirrors the containment prior art in cross-project-search.js#isUnder /
67
+ * safeResolveProjectPath: realpath the root, realpath the entry (falling back
68
+ * to the un-resolved absolute when the entry doesn't exist yet — e.g. a fix
69
+ * that would create a file — and in that case realpath the parent dir), then
70
+ * a trailing-separator boundary check so `/repo-evil` is NOT inside `/repo`.
71
+ *
72
+ * Returns { ok, reason, canonical? }.
73
+ */
74
+ export function isPathContained(filePath, projectRoot) {
75
+ if (!filePath || typeof filePath !== 'string') {
76
+ return { ok: false, reason: 'no-file-path' };
77
+ }
78
+ if (!projectRoot || typeof projectRoot !== 'string') {
79
+ return { ok: false, reason: 'no-project-root' };
80
+ }
81
+ // Canonicalise the root. If it doesn't resolve, fall back to absolute.
82
+ let canonRoot;
83
+ try {
84
+ canonRoot = realpathSync(resolvePath(projectRoot));
85
+ } catch {
86
+ canonRoot = resolvePath(projectRoot);
87
+ }
88
+ // Canonicalise the target. The file may not exist yet (a creating fix), so
89
+ // realpath the deepest existing ancestor and re-join the missing tail.
90
+ const absTarget = isAbsolute(filePath)
91
+ ? filePath
92
+ : resolvePath(canonRoot, filePath);
93
+ let canonTarget;
94
+ try {
95
+ canonTarget = realpathSync(absTarget);
96
+ } catch {
97
+ let probe = absTarget;
98
+ const tail = [];
99
+ // Walk up until we hit something that exists (or the fs root).
100
+ while (probe && !existsSync(probe) && dirname(probe) !== probe) {
101
+ tail.unshift(probe.slice(dirname(probe).length).replace(/^[\\/]/, ''));
102
+ probe = dirname(probe);
103
+ }
104
+ let canonProbe;
105
+ try { canonProbe = realpathSync(probe); }
106
+ catch { canonProbe = resolvePath(probe); }
107
+ canonTarget = tail.length ? join(canonProbe, ...tail) : canonProbe;
108
+ }
109
+ // Boundary check with trailing separator so siblings can't impersonate.
110
+ const rel = relative(canonRoot, canonTarget);
111
+ const escapes = rel === '..'
112
+ || rel.startsWith(`..${'/'}`) || rel.startsWith(`..${'\\'}`)
113
+ || isAbsolute(rel);
114
+ if (escapes) {
115
+ return {
116
+ ok: false,
117
+ reason: `path escapes project root (${canonTarget} not under ${canonRoot})`,
118
+ canonical: canonTarget,
119
+ };
120
+ }
121
+ return { ok: true, reason: '', canonical: canonTarget };
122
+ }
123
+
38
124
  /* ────────────────────────────── status codes ────────────────────────────── */
39
125
 
40
126
  export const STATUS = Object.freeze({
@@ -46,6 +132,12 @@ export const STATUS = Object.freeze({
46
132
  FALLBACK_FAIL: 'FALLBACK_FAIL',
47
133
  TRIDENT_FAIL: 'TRIDENT_FAIL',
48
134
  COMMIT_FAIL: 'COMMIT_FAIL',
135
+ // R5-1.10 — finding's target file resolves outside the audited project
136
+ // root. The fixer refuses to touch it (path-containment guard).
137
+ OUT_OF_ROOT: 'OUT_OF_ROOT',
138
+ // R5-1.10 — the per-run change cap was reached; this finding (and any
139
+ // after it) was skipped without being applied.
140
+ CAP_REACHED: 'CAP_REACHED',
49
141
  });
50
142
 
51
143
  /* ────────────────────────────── logic-bug heuristic ─────────────────────── */
@@ -446,9 +538,27 @@ export async function fixFinding({
446
538
  }
447
539
 
448
540
  // 2. confirm target exists + snapshot
541
+ const root = projectRoot || process.cwd();
449
542
  const filePath = isAbsolute(finding.file)
450
543
  ? finding.file
451
- : resolvePath(projectRoot || process.cwd(), finding.file);
544
+ : resolvePath(root, finding.file);
545
+
546
+ // R5-1.10 — PATH CONTAINMENT. Auto-fix mutates the working tree; it must
547
+ // only ever touch files inside the project root being audited. A finding
548
+ // whose `file` resolves outside the root (absolute escape, `../` traversal,
549
+ // or a symlink pointing out) is REFUSED before any read/write happens.
550
+ // This is checked before existsSync so an out-of-root path can't even be
551
+ // probed for existence.
552
+ const contained = isPathContained(filePath, root);
553
+ if (!contained.ok) {
554
+ return {
555
+ ...base,
556
+ status: STATUS.OUT_OF_ROOT,
557
+ tier_reached: 'n/a',
558
+ evidence: `auto-fix refused: ${contained.reason}`,
559
+ };
560
+ }
561
+
452
562
  if (!existsSync(filePath)) {
453
563
  return { ...base, status: STATUS.STALE, tier_reached: 'n/a',
454
564
  evidence: `target file does not exist: ${filePath}` };
@@ -583,9 +693,21 @@ export async function fixFinding({
583
693
  * the Trident step needs a stable commit range. Parallel fixes would shred
584
694
  * the atomicity guarantee.
585
695
  *
696
+ * R5-1.10 — CHANGE CAP. `opts.maxAutoFixFiles` (default
697
+ * DEFAULT_MAX_AUTOFIX_FILES = 10, hard-ceilinged at 50) bounds the number of
698
+ * DISTINCT files this batch may successfully apply a fix to. Once that many
699
+ * files have been touched, every remaining finding that targets a not-yet-
700
+ * seen file is short-circuited with status CAP_REACHED (no read, no write) —
701
+ * the loop stops mutating and reports the remainder instead of mass-
702
+ * rewriting the repo. Findings that re-target an already-fixed file are
703
+ * still allowed through (they don't grow the blast radius). Statuses that
704
+ * don't write a file (DEFERRED / STALE / OUT_OF_ROOT / *_FAIL) never count
705
+ * against the cap.
706
+ *
586
707
  * Returns { results: Array<fixFinding-record>, summary: { verified, deferred,
587
708
  * stale, verify_fail, syntax_fail, fallback_fail, trident_fail,
588
- * commit_fail } }.
709
+ * commit_fail, out_of_root, cap_reached }, capped: boolean,
710
+ * filesTouched: number, maxAutoFixFiles: number }.
589
711
  */
590
712
  export async function fixFindings(findings, opts = {}) {
591
713
  const results = [];
@@ -593,15 +715,198 @@ export async function fixFindings(findings, opts = {}) {
593
715
  verified: 0, deferred: 0, stale: 0,
594
716
  verify_fail: 0, syntax_fail: 0, fallback_fail: 0,
595
717
  trident_fail: 0, commit_fail: 0,
718
+ out_of_root: 0, cap_reached: 0,
596
719
  };
720
+
721
+ // Resolve the per-run change cap. Clamp to [1, MAX_AUTOFIX_FILES_CEILING];
722
+ // a non-positive or non-numeric value falls back to the default.
723
+ const reqCap = Number(opts.maxAutoFixFiles);
724
+ const cap = Number.isFinite(reqCap) && reqCap > 0
725
+ ? Math.min(Math.floor(reqCap), MAX_AUTOFIX_FILES_CEILING)
726
+ : DEFAULT_MAX_AUTOFIX_FILES;
727
+
728
+ // Distinct files this batch has successfully written to. A fix counts as
729
+ // "touching" a file only if it actually applied an edit — VERIFIED or any
730
+ // failure mode that happens AFTER applyEdit (VERIFY_FAIL/SYNTAX_FAIL/
731
+ // FALLBACK_FAIL/TRIDENT_FAIL/COMMIT_FAIL all roll the file back, but the
732
+ // file WAS written then reverted, so they still count toward blast radius).
733
+ const APPLIED = new Set([
734
+ STATUS.VERIFIED, STATUS.VERIFY_FAIL, STATUS.SYNTAX_FAIL,
735
+ STATUS.FALLBACK_FAIL, STATUS.TRIDENT_FAIL, STATUS.COMMIT_FAIL,
736
+ ]);
737
+ const filesTouched = new Set();
738
+ let capped = false;
739
+
597
740
  for (const finding of (findings || [])) {
741
+ const targetFile = finding && typeof finding.file === 'string'
742
+ ? finding.file : null;
743
+ const alreadyTouched = targetFile && filesTouched.has(targetFile);
744
+
745
+ // Cap gate: if we've hit the ceiling AND this finding would touch a NEW
746
+ // file, refuse it without reading/writing anything.
747
+ if (filesTouched.size >= cap && !alreadyTouched) {
748
+ capped = true;
749
+ results.push({
750
+ finding_id: finding?.finding_id || finding?.id || 'unknown',
751
+ file: targetFile,
752
+ status: STATUS.CAP_REACHED,
753
+ tier_reached: 'n/a',
754
+ evidence: `auto-fix change cap reached (${cap} files); skipped without applying`,
755
+ });
756
+ summary.cap_reached += 1;
757
+ continue;
758
+ }
759
+
598
760
  // eslint-disable-next-line no-await-in-loop -- sequential is the contract
599
761
  const r = await fixFinding({ ...opts, finding });
600
762
  results.push(r);
601
763
  const k = String(r.status || '').toLowerCase();
602
764
  if (k in summary) summary[k] += 1;
765
+
766
+ // Record blast radius: any status that got past applyEdit touched a file.
767
+ if (APPLIED.has(r.status) && r.file) {
768
+ filesTouched.add(r.file);
769
+ }
770
+ }
771
+ return {
772
+ results,
773
+ summary,
774
+ capped,
775
+ filesTouched: filesTouched.size,
776
+ maxAutoFixFiles: cap,
777
+ };
778
+ }
779
+
780
+ /* ────────────────────── consensus-HIGH extraction (T27 wire-up) ──────────── */
781
+
782
+ /**
783
+ * Normalise a finding's severity to a lowercase canonical token.
784
+ * Auditors emit `high` / `HIGH` / `High` / sometimes `severity: { level }`.
785
+ */
786
+ function _severityOf(finding) {
787
+ if (!finding || typeof finding !== 'object') return '';
788
+ const raw = finding.severity ?? finding.level ?? '';
789
+ if (raw && typeof raw === 'object') {
790
+ return String(raw.level || raw.severity || '').toLowerCase().trim();
791
+ }
792
+ return String(raw).toLowerCase().trim();
793
+ }
794
+
795
+ /**
796
+ * A stable identity key for cross-lens finding agreement. Two lenses "agree"
797
+ * on the same HIGH when their findings collapse to the same key. We use the
798
+ * file path + a normalised description prefix (whitespace-folded, lowercased,
799
+ * first 80 chars) — precise enough to cluster genuine duplicates, loose enough
800
+ * to survive trivial wording drift between lenses.
801
+ */
802
+ function _consensusKey(finding) {
803
+ const file = String(finding.file || finding.location || finding.path || '').trim();
804
+ const descSource =
805
+ finding.description || finding.issue || finding.text ||
806
+ finding.message || finding.finding || finding.detail || '';
807
+ const desc = String(descSource).toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 80);
808
+ return `${file}::${desc}`;
809
+ }
810
+
811
+ /**
812
+ * consensusHighFindings(perIteration, opts) — given the `perIteration` array
813
+ * that `runPhaseEConverge` returns, extract the HIGH-severity findings on
814
+ * which `minLenses` (default 2) or more lenses agree.
815
+ *
816
+ * This is the bridge T27 was designed for: "when 2+ lenses agree on the same
817
+ * HIGH, the fixer fires automatically." The convergence loop produces
818
+ * `perIteration[*].lensResults[*].findings`; this collapses the final
819
+ * iteration's findings into per-lens-deduped consensus clusters.
820
+ *
821
+ * Returns Array<finding> — each carries `_consensusLenses` (the set of lens
822
+ * ids that flagged it) and `_consensusCount`. Only the LAST iteration is
823
+ * considered: convergence already re-evaluated earlier rounds, so the final
824
+ * round is the swarm's settled position.
825
+ */
826
+ export function consensusHighFindings(perIteration, opts = {}) {
827
+ const minLenses = Number.isInteger(opts.minLenses) && opts.minLenses > 0
828
+ ? opts.minLenses
829
+ : 2;
830
+ if (!Array.isArray(perIteration) || perIteration.length === 0) return [];
831
+ const last = perIteration[perIteration.length - 1];
832
+ if (!last || !Array.isArray(last.lensResults)) return [];
833
+
834
+ // key → { finding, lenses:Set }
835
+ const clusters = new Map();
836
+ for (const lr of last.lensResults) {
837
+ const lens = lr && lr.lens ? lr.lens : 'unknown';
838
+ const findings = Array.isArray(lr && lr.findings) ? lr.findings : [];
839
+ // De-dup within a single lens first so one lens flagging the same issue
840
+ // twice can't manufacture false consensus.
841
+ const seenThisLens = new Set();
842
+ for (const f of findings) {
843
+ if (_severityOf(f) !== 'high' && _severityOf(f) !== 'critical') continue;
844
+ const key = _consensusKey(f);
845
+ if (seenThisLens.has(key)) continue;
846
+ seenThisLens.add(key);
847
+ if (!clusters.has(key)) clusters.set(key, { finding: f, lenses: new Set() });
848
+ clusters.get(key).lenses.add(lens);
849
+ }
603
850
  }
604
- return { results, summary };
851
+
852
+ const out = [];
853
+ for (const { finding, lenses } of clusters.values()) {
854
+ if (lenses.size >= minLenses) {
855
+ out.push({ ...finding, _consensusLenses: [...lenses], _consensusCount: lenses.size });
856
+ }
857
+ }
858
+ return out;
859
+ }
860
+
861
+ /**
862
+ * runConsensusFix({ perIteration, projectRoot, dispatch, ...fixOpts }) —
863
+ * the T27 auto-fix entry point. Extracts consensus HIGHs from a completed
864
+ * `runPhaseEConverge` run and runs the per-finding atomic fixer loop over
865
+ * them.
866
+ *
867
+ * R5-1.10 SAFETY BOUNDARY — auto-fix mutates code, so two hard guards apply
868
+ * (both inherited from `fixFindings` / `fixFinding`, surfaced here):
869
+ * • Path containment — any finding whose target file resolves outside
870
+ * `projectRoot` is REFUSED (status OUT_OF_ROOT); the fixer can never
871
+ * write outside the audited project.
872
+ * • Change cap — `maxAutoFixFiles` (default 10, ceiling 50) bounds the
873
+ * distinct files a single run may touch; beyond it the loop STOPS and
874
+ * reports the remainder (status CAP_REACHED) instead of mass-rewriting.
875
+ * Pass `dryRun: true` for detect-only (reports what it WOULD fix, no edits).
876
+ *
877
+ * Returns:
878
+ * { triggered: false, reason } — nothing to fix
879
+ * { triggered: true, consensusCount, results, summary, capped,
880
+ * filesTouched, maxAutoFixFiles } — fixer ran
881
+ *
882
+ * `dispatch` is required so each fix's Trident re-verify can run.
883
+ */
884
+ export async function runConsensusFix({
885
+ perIteration,
886
+ projectRoot,
887
+ dispatch,
888
+ minLenses = 2,
889
+ ...fixOpts
890
+ } = {}) {
891
+ const consensus = consensusHighFindings(perIteration, { minLenses });
892
+ if (consensus.length === 0) {
893
+ return { triggered: false, reason: 'no consensus HIGH findings' };
894
+ }
895
+ const { results, summary, capped, filesTouched, maxAutoFixFiles } =
896
+ await fixFindings(consensus, {
897
+ ...fixOpts,
898
+ projectRoot,
899
+ dispatch,
900
+ });
901
+ return {
902
+ triggered: true,
903
+ consensusCount: consensus.length,
904
+ results,
905
+ summary,
906
+ capped,
907
+ filesTouched,
908
+ maxAutoFixFiles,
909
+ };
605
910
  }
606
911
 
607
912
  /* ────────────────────────────── test helpers ────────────────────────────── */
@@ -224,7 +224,6 @@ export function toolNameToActionTarget(toolName, args) {
224
224
  return { action: 'write', target: 'memory:write' };
225
225
  case 'ijfw_memory_recall':
226
226
  case 'ijfw_memory_search':
227
- case 'ijfw_memory_status':
228
227
  case 'ijfw_memory_prelude':
229
228
  case 'ijfw_cross_project_search':
230
229
  return { action: 'read', target: 'memory:read' };