@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.
- package/bin/ijfw-memorize +14 -7
- package/fixtures/team/book.json +6 -6
- package/fixtures/team/business.json +146 -20
- package/fixtures/team/content.json +6 -6
- package/fixtures/team/design.json +148 -20
- package/fixtures/team/mixed.json +206 -27
- package/fixtures/team/research.json +146 -20
- package/fixtures/team/software.json +148 -20
- package/package.json +6 -3
- package/src/cross-orchestrator-cli.js +204 -145
- package/src/cross-orchestrator.js +50 -1
- package/src/dispatch/extension.js +1 -1
- package/src/hardware-signer.js +4 -2
- package/src/lib/ui-review-runner.js +48 -7
- package/src/memory/auto-linker.js +116 -1
- package/src/memory/migration-runner.js +6 -1
- package/src/memory/migrations/009-obsidian-backfill.js +50 -0
- package/src/memory/obsidian-parser.js +62 -1
- package/src/memory/search.js +46 -25
- package/src/orchestrator/debug-trident-trigger.js +374 -0
- package/src/orchestrator/post-done-runner.js +36 -8
- package/src/orchestrator/state-sdk.js +174 -6
- package/src/orchestrator/subagent-telemetry.js +19 -0
- package/src/override-resolver.js +5 -3
- package/src/recovery/code-fixer.js +310 -5
- package/src/runtime-mediator.js +0 -1
- package/src/server.js +198 -59
- package/src/swarm-config.js +30 -22
- package/src/team/domain-templates/business.json +4 -1
- package/src/team/domain-templates/research.json +4 -1
- package/src/team/generator.js +162 -0
- package/src/update-apply.js +1 -1
- package/src/dashboard-charts.js +0 -239
- package/src/orchestrator/runtime-loop.js +0 -430
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
* straight off that contract.
|
|
12
12
|
*
|
|
13
13
|
* ───────────────────────────────────────────────────────────────────────────
|
|
14
|
-
* SCOPE BOUNDARY — T2
|
|
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) —
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* the
|
|
20
|
-
*
|
|
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(
|
package/src/override-resolver.js
CHANGED
|
@@ -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
|
-
*
|
|
69
|
-
* installer/src/install-helpers.js once that module exposes a
|
|
70
|
-
* platform-list getter. Until then this on-disk probe is the
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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 ────────────────────────────── */
|
package/src/runtime-mediator.js
CHANGED
|
@@ -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' };
|