@ijfw/memory-server 1.4.1 → 1.4.4
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/package.json +1 -1
- package/src/active-extension-writer.js +284 -4
- package/src/cross-orchestrator.js +164 -2
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client-planning.html +273 -0
- package/src/dashboard-client.html +213 -1
- package/src/dashboard-server.js +186 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +40 -0
- package/src/dispatch/quota-cli.js +42 -0
- package/src/dispatch/registry-cli.js +339 -0
- package/src/dispatch/signer-cli.js +311 -0
- package/src/dispatch/wave-cli.js +128 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +61 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +819 -149
- package/src/extension-signer.js +105 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/orchestrator/review.js +101 -0
- package/src/orchestrator/status-protocol.js +168 -0
- package/src/orchestrator/verification-gate.js +97 -0
- package/src/orchestrator/wave-state.js +255 -0
- package/src/runtime-mediator.js +31 -0
- package/src/server.js +180 -18
- package/src/swarm-config.js +32 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ijfw/memory-server",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.4",
|
|
4
4
|
"description": "Cross-platform persistent memory server for IJFW. 10 MCP tools (memory + admin/update). Works with 13 MCP-using platforms (Claude Code, Codex, Gemini CLI, Cursor, Windsurf, Copilot, Hermes, Wayland, OpenCode, QwenCode, Cline, KimiCode, OpenClaw) plus Aider via rules-only tier.",
|
|
5
5
|
"author": "Sean Donahoe",
|
|
6
6
|
"license": "MIT",
|
|
@@ -7,24 +7,40 @@
|
|
|
7
7
|
* - installExtension when opts.activate is set
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
|
10
|
+
import { readFile, writeFile, unlink, mkdir, readdir, stat } from 'node:fs/promises';
|
|
11
11
|
import { join, dirname } from 'node:path';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { randomBytes } from 'node:crypto';
|
|
14
14
|
|
|
15
|
+
import { resetExtensionQuotas } from './extension-quota-tracker.js';
|
|
16
|
+
|
|
15
17
|
const STATE_PATH_REL = ['.ijfw', 'state', 'active-extension.json'];
|
|
16
18
|
|
|
19
|
+
// B18 — stale last-seen-by-<ide>.json files older than this get cleaned on read.
|
|
20
|
+
const LAST_SEEN_STALE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
// Valid IDE id pattern (matches ide-detect.js). Keep in sync.
|
|
23
|
+
const IDE_ID_PATTERN = /^[a-z0-9-]+$/;
|
|
24
|
+
|
|
17
25
|
function statePath(home) {
|
|
18
26
|
return join(home || homedir(), ...STATE_PATH_REL);
|
|
19
27
|
}
|
|
20
28
|
|
|
29
|
+
function lastSeenPath(home, ideId) {
|
|
30
|
+
return join(home || homedir(), '.ijfw', 'state', `last-seen-by-${ideId}.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stateDir(home) {
|
|
34
|
+
return join(home || homedir(), '.ijfw', 'state');
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
/**
|
|
22
38
|
* Write the active-extension state file from a manifest + scope.
|
|
23
39
|
* Validates required fields before write. Atomic write via tmp+rename.
|
|
24
40
|
*
|
|
25
41
|
* @param {{ name: string, permissions: { reads: string[], writes: string[] } }} manifest
|
|
26
42
|
* @param {'project'|'org'|'user'} scope
|
|
27
|
-
* @param {{ homeDir?: string }} [opts]
|
|
43
|
+
* @param {{ homeDir?: string, ideId?: string|null }} [opts]
|
|
28
44
|
* @returns {Promise<{ ok: boolean, path?: string, error?: string }>}
|
|
29
45
|
*/
|
|
30
46
|
export async function writeActiveExtension(manifest, scope, opts = {}) {
|
|
@@ -42,12 +58,48 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
|
|
|
42
58
|
}
|
|
43
59
|
const reads = Array.isArray(manifest.permissions.reads) ? manifest.permissions.reads : [];
|
|
44
60
|
const writes = Array.isArray(manifest.permissions.writes) ? manifest.permissions.writes : [];
|
|
61
|
+
const activatedAt = new Date().toISOString();
|
|
62
|
+
// B18: stamp activated_by_ide + activated_by_pid when ideId is provided.
|
|
63
|
+
// Caller (CLI) is responsible for calling detectIde() and threading the
|
|
64
|
+
// value in. When opts.ideId is null/undefined, fields are omitted (so the
|
|
65
|
+
// file stays back-compatible with v1.4.1 readers).
|
|
66
|
+
const ideId = (typeof opts.ideId === 'string' && IDE_ID_PATTERN.test(opts.ideId))
|
|
67
|
+
? opts.ideId
|
|
68
|
+
: null;
|
|
45
69
|
const out = {
|
|
46
70
|
name: manifest.name,
|
|
47
71
|
scope,
|
|
48
72
|
permissions: { reads, writes },
|
|
49
|
-
activated_at:
|
|
73
|
+
activated_at: activatedAt,
|
|
50
74
|
};
|
|
75
|
+
if (ideId) {
|
|
76
|
+
out.activated_by_ide = ideId;
|
|
77
|
+
out.activated_by_pid = process.pid;
|
|
78
|
+
}
|
|
79
|
+
// R12-H-01: persist manifest.quotas so the tier-2 hook
|
|
80
|
+
// (extension-permission-check.mjs) can enforce quotas on Edit/Write/Bash
|
|
81
|
+
// dispatch. Without this the tier-2 hook reads `active.quotas` as undefined
|
|
82
|
+
// and silently bypasses the v1.4.3 quota gate that the server-side
|
|
83
|
+
// gatePermissionAndQuota path enforces. Schema (extension-manifest-schema.js):
|
|
84
|
+
// optional object whose values are positive integers — currently
|
|
85
|
+
// max_files_written / max_bytes_written / max_wall_clock_ms (forward-compat:
|
|
86
|
+
// unknown dimensions are kept as-is — schema rejects unknowns at install).
|
|
87
|
+
if (
|
|
88
|
+
manifest.quotas !== undefined &&
|
|
89
|
+
manifest.quotas !== null &&
|
|
90
|
+
typeof manifest.quotas === 'object' &&
|
|
91
|
+
!Array.isArray(manifest.quotas)
|
|
92
|
+
) {
|
|
93
|
+
const cleanQuotas = {};
|
|
94
|
+
let copied = 0;
|
|
95
|
+
for (const [k, v] of Object.entries(manifest.quotas)) {
|
|
96
|
+
if (typeof v === 'number' && Number.isFinite(v) && Number.isInteger(v) && v > 0) {
|
|
97
|
+
cleanQuotas[k] = v;
|
|
98
|
+
copied++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (copied > 0) out.quotas = cleanQuotas;
|
|
102
|
+
}
|
|
51
103
|
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
52
104
|
const path = statePath(home);
|
|
53
105
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -55,6 +107,14 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
|
|
|
55
107
|
await writeFile(tmp, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
|
56
108
|
const { rename } = await import('node:fs/promises');
|
|
57
109
|
await rename(tmp, path);
|
|
110
|
+
// B16/SEC-M-02: reset quota counters on activate; stamp activated_at so
|
|
111
|
+
// wall_clock_ms can be computed against this activation window.
|
|
112
|
+
try {
|
|
113
|
+
await resetExtensionQuotas(manifest.name, { homeDir: home, activated_at: activatedAt });
|
|
114
|
+
} catch {
|
|
115
|
+
// Quota reset failure must not block activation. Counters will self-heal
|
|
116
|
+
// on next deactivate or the next activate of the same name.
|
|
117
|
+
}
|
|
58
118
|
return { ok: true, path };
|
|
59
119
|
}
|
|
60
120
|
|
|
@@ -66,11 +126,38 @@ export async function writeActiveExtension(manifest, scope, opts = {}) {
|
|
|
66
126
|
*/
|
|
67
127
|
export async function clearActiveExtension(opts = {}) {
|
|
68
128
|
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
129
|
+
// B16/SEC-M-02: read the active extension name BEFORE unlinking so we can
|
|
130
|
+
// clear its quota counters. Best-effort: if the file is missing or
|
|
131
|
+
// malformed, deactivate still succeeds.
|
|
132
|
+
let extName = null;
|
|
133
|
+
try {
|
|
134
|
+
const raw = await readFile(statePath(home), 'utf8');
|
|
135
|
+
const parsed = JSON.parse(raw);
|
|
136
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.name === 'string') {
|
|
137
|
+
extName = parsed.name;
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// ignore — extName stays null
|
|
141
|
+
}
|
|
69
142
|
try {
|
|
70
143
|
await unlink(statePath(home));
|
|
144
|
+
if (extName) {
|
|
145
|
+
try {
|
|
146
|
+
await resetExtensionQuotas(extName, { homeDir: home });
|
|
147
|
+
} catch {
|
|
148
|
+
// best-effort
|
|
149
|
+
}
|
|
150
|
+
}
|
|
71
151
|
return { ok: true, removed: true };
|
|
72
152
|
} catch (err) {
|
|
73
|
-
if (err && err.code === 'ENOENT')
|
|
153
|
+
if (err && err.code === 'ENOENT') {
|
|
154
|
+
if (extName) {
|
|
155
|
+
try {
|
|
156
|
+
await resetExtensionQuotas(extName, { homeDir: home });
|
|
157
|
+
} catch { /* best-effort */ }
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, removed: false };
|
|
160
|
+
}
|
|
74
161
|
return { ok: false, removed: false };
|
|
75
162
|
}
|
|
76
163
|
}
|
|
@@ -140,3 +227,196 @@ export async function findInstalledManifest(name, projectRoot, opts = {}) {
|
|
|
140
227
|
|
|
141
228
|
return { ok: true, manifest: winner.manifest, scope: winner.scope, path: winner.path };
|
|
142
229
|
}
|
|
230
|
+
|
|
231
|
+
// ============================================================================
|
|
232
|
+
// B18 — Cross-IDE Conflict Detection
|
|
233
|
+
// ============================================================================
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Write the current IDE's last-seen marker. Best-effort: never throws.
|
|
237
|
+
* Atomic via tmp+rename.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} ideId
|
|
240
|
+
* @param {{ homeDir?: string }} [opts]
|
|
241
|
+
*/
|
|
242
|
+
async function writeLastSeen(ideId, opts = {}) {
|
|
243
|
+
if (!ideId || typeof ideId !== 'string' || !IDE_ID_PATTERN.test(ideId)) return;
|
|
244
|
+
if (ideId === 'unknown') return;
|
|
245
|
+
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
246
|
+
const path = lastSeenPath(home, ideId);
|
|
247
|
+
try {
|
|
248
|
+
await mkdir(dirname(path), { recursive: true });
|
|
249
|
+
const body = JSON.stringify({ ide: ideId, last_seen_at: new Date().toISOString() }, null, 2) + '\n';
|
|
250
|
+
const tmp = `${path}.tmp.${randomBytes(4).toString('hex')}`;
|
|
251
|
+
await writeFile(tmp, body, 'utf8');
|
|
252
|
+
const { rename } = await import('node:fs/promises');
|
|
253
|
+
await rename(tmp, path);
|
|
254
|
+
} catch {
|
|
255
|
+
// best-effort
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Read another IDE's last-seen marker. Returns null on any read error or
|
|
261
|
+
* missing file.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} ideId
|
|
264
|
+
* @param {{ homeDir?: string }} [opts]
|
|
265
|
+
* @returns {Promise<{ ide: string, last_seen_at: string }|null>}
|
|
266
|
+
*/
|
|
267
|
+
async function readLastSeen(ideId, opts = {}) {
|
|
268
|
+
if (!ideId || !IDE_ID_PATTERN.test(ideId)) return null;
|
|
269
|
+
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
270
|
+
try {
|
|
271
|
+
const raw = await readFile(lastSeenPath(home, ideId), 'utf8');
|
|
272
|
+
const parsed = JSON.parse(raw);
|
|
273
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
274
|
+
if (typeof parsed.last_seen_at !== 'string') return null;
|
|
275
|
+
return parsed;
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Scan ~/.ijfw/state/ for last-seen-by-<ide>.json files older than 30 days
|
|
283
|
+
* and unlink them. Best-effort: never throws. Returns the number removed.
|
|
284
|
+
*
|
|
285
|
+
* @param {{ homeDir?: string, now?: number }} [opts]
|
|
286
|
+
* @returns {Promise<number>}
|
|
287
|
+
*/
|
|
288
|
+
async function cleanupStaleLastSeen(opts = {}) {
|
|
289
|
+
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
290
|
+
const now = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
291
|
+
let removed = 0;
|
|
292
|
+
let entries;
|
|
293
|
+
try {
|
|
294
|
+
entries = await readdir(stateDir(home));
|
|
295
|
+
} catch {
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
if (!entry.startsWith('last-seen-by-') || !entry.endsWith('.json')) continue;
|
|
300
|
+
const full = join(stateDir(home), entry);
|
|
301
|
+
try {
|
|
302
|
+
const st = await stat(full);
|
|
303
|
+
if (now - st.mtimeMs > LAST_SEEN_STALE_MS) {
|
|
304
|
+
await unlink(full);
|
|
305
|
+
removed++;
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
// best-effort
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return removed;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Detect divergence between the IDE that wrote active.json and the current IDE.
|
|
316
|
+
*
|
|
317
|
+
* Semantics:
|
|
318
|
+
* - active.json missing OR no activated_by_ide field (pre-v1.4.3) → not divergent
|
|
319
|
+
* - active.activated_by_ide === current_ide → not divergent
|
|
320
|
+
* - active.activated_by_ide !== current_ide AND current_ide has a last-seen
|
|
321
|
+
* file AND active.activated_at is OLDER than current_ide's last_seen_at →
|
|
322
|
+
* divergent (this IDE was here before; a different IDE has since taken over
|
|
323
|
+
* stale state)
|
|
324
|
+
* - otherwise → not divergent (legitimate cross-IDE hand-off)
|
|
325
|
+
*
|
|
326
|
+
* Side effects:
|
|
327
|
+
* - writes current_ide's last-seen marker
|
|
328
|
+
* - cleans up stale last-seen files (>30 days)
|
|
329
|
+
*
|
|
330
|
+
* @param {{ homeDir?: string, currentIde?: string, now?: number }} [opts]
|
|
331
|
+
* @returns {Promise<{ divergent: boolean, last_writer: string|null, current_ide: string, age_seconds: number|null, reason?: string }>}
|
|
332
|
+
*/
|
|
333
|
+
export async function detectCrossIdeDivergence(opts = {}) {
|
|
334
|
+
const home = opts && opts.homeDir ? opts.homeDir : (process.env.HOME || homedir());
|
|
335
|
+
const now = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
336
|
+
// Lazy import to avoid a hard module dependency cycle in test scaffolds.
|
|
337
|
+
let currentIde = opts.currentIde;
|
|
338
|
+
if (!currentIde) {
|
|
339
|
+
try {
|
|
340
|
+
const { detectIde } = await import('./ide-detect.js');
|
|
341
|
+
currentIde = detectIde();
|
|
342
|
+
} catch {
|
|
343
|
+
currentIde = 'unknown';
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Best-effort stale cleanup on every divergence check.
|
|
348
|
+
await cleanupStaleLastSeen({ homeDir: home, now });
|
|
349
|
+
|
|
350
|
+
// Read active.json. Missing or unreadable → not divergent.
|
|
351
|
+
let active;
|
|
352
|
+
try {
|
|
353
|
+
const raw = await readFile(statePath(home), 'utf8');
|
|
354
|
+
active = JSON.parse(raw);
|
|
355
|
+
} catch {
|
|
356
|
+
return { divergent: false, last_writer: null, current_ide: currentIde, age_seconds: null, reason: 'no active extension' };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Pre-v1.4.3 active.json — silently no-divergence.
|
|
360
|
+
if (!active || typeof active !== 'object' || typeof active.activated_by_ide !== 'string') {
|
|
361
|
+
return { divergent: false, last_writer: null, current_ide: currentIde, age_seconds: null, reason: 'pre-v1.4.3 active.json' };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const lastWriter = active.activated_by_ide;
|
|
365
|
+
const activatedAtMs = typeof active.activated_at === 'string' ? Date.parse(active.activated_at) : NaN;
|
|
366
|
+
const ageSeconds = Number.isFinite(activatedAtMs) ? Math.max(0, Math.floor((now - activatedAtMs) / 1000)) : null;
|
|
367
|
+
|
|
368
|
+
// CRITICAL ORDERING: read prior last-seen BEFORE overwriting it. Otherwise
|
|
369
|
+
// the divergence comparison degrades to "now vs activated_at" which is
|
|
370
|
+
// always non-divergent.
|
|
371
|
+
let priorSeen = null;
|
|
372
|
+
if (lastWriter !== currentIde && currentIde !== 'unknown') {
|
|
373
|
+
priorSeen = await readLastSeen(currentIde, { homeDir: home });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Now write our own last-seen marker (best-effort; we ARE the current IDE).
|
|
377
|
+
if (currentIde !== 'unknown') {
|
|
378
|
+
await writeLastSeen(currentIde, { homeDir: home });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (lastWriter === currentIde) {
|
|
382
|
+
return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'same ide' };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// If current_ide is 'unknown', divergence detection is disabled.
|
|
386
|
+
if (currentIde === 'unknown') {
|
|
387
|
+
return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'detection disabled' };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!priorSeen) {
|
|
391
|
+
// Current IDE has no prior history → legitimate first-time hand-off.
|
|
392
|
+
return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'first-time current ide' };
|
|
393
|
+
}
|
|
394
|
+
const seenMs = Date.parse(priorSeen.last_seen_at);
|
|
395
|
+
if (!Number.isFinite(seenMs) || !Number.isFinite(activatedAtMs)) {
|
|
396
|
+
return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'unparseable timestamps' };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Design rule (per B18 spec):
|
|
400
|
+
// divergent iff active.activated_by_ide != currentIde
|
|
401
|
+
// AND active.activated_at < currentIde.last_seen
|
|
402
|
+
//
|
|
403
|
+
// Reading: the slot says some other IDE wrote it, but the current IDE has
|
|
404
|
+
// a more recent last-seen — i.e., the current IDE has been touching state
|
|
405
|
+
// more recently than the write, yet a different IDE's name is on it. Stale
|
|
406
|
+
// cross-IDE state divergence.
|
|
407
|
+
if (activatedAtMs < seenMs) {
|
|
408
|
+
return { divergent: true, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'stale active.json: current ide last-seen is more recent than active.activated_at' };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// active was written AFTER current ide's last_seen — legitimate hand-off
|
|
412
|
+
// (another IDE took over while current was away).
|
|
413
|
+
return { divergent: false, last_writer: lastWriter, current_ide: currentIde, age_seconds: ageSeconds, reason: 'legitimate handoff: foreign ide wrote after current ide was last here' };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Internal helpers exported for tests only.
|
|
417
|
+
export const __testing = Object.freeze({
|
|
418
|
+
writeLastSeen,
|
|
419
|
+
readLastSeen,
|
|
420
|
+
cleanupStaleLastSeen,
|
|
421
|
+
LAST_SEEN_STALE_MS,
|
|
422
|
+
});
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
|
|
16
16
|
import { spawn } from 'node:child_process';
|
|
17
17
|
import * as readline from 'node:readline';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
18
|
+
import { readdirSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import { join, dirname } from 'node:path';
|
|
20
|
+
import { pickAuditors, isReachable, ROSTER } from './audit-roster.js';
|
|
21
|
+
import { loadSwarmConfig, DEFAULT_AUDITORS } from './swarm-config.js';
|
|
20
22
|
import { buildRequest, parseResponse, mergeResponses, checkBudget } from './cross-dispatcher.js';
|
|
21
23
|
import { writeReceipt, readReceipts } from './receipts.js';
|
|
22
24
|
import { runViaApi } from './api-client.js';
|
|
@@ -381,6 +383,158 @@ function countItems(p) {
|
|
|
381
383
|
return 0;
|
|
382
384
|
}
|
|
383
385
|
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// phase-e-auto helpers (v1.4.4 N10)
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
// Resolve the next CROSS-AUDIT-r<N>.md path under .planning/<phase>/.
|
|
391
|
+
// Scans for existing r<N> files and increments the highest N found.
|
|
392
|
+
function resolveAuditOutputPath(projectDir, phase) {
|
|
393
|
+
const planningDir = join(projectDir, '.planning', phase);
|
|
394
|
+
let maxN = 0;
|
|
395
|
+
if (existsSync(planningDir)) {
|
|
396
|
+
try {
|
|
397
|
+
const files = readdirSync(planningDir);
|
|
398
|
+
for (const f of files) {
|
|
399
|
+
const m = f.match(/^CROSS-AUDIT-r(\d+)\.md$/);
|
|
400
|
+
if (m) {
|
|
401
|
+
const n = parseInt(m[1], 10);
|
|
402
|
+
if (n > maxN) maxN = n;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
} catch { /* non-fatal */ }
|
|
406
|
+
}
|
|
407
|
+
return join(planningDir, `CROSS-AUDIT-r${maxN + 1}.md`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Classify a merged audit result into PASS / CONDITIONAL / FAIL.
|
|
411
|
+
// HIGH severity finding → FAIL; any finding → CONDITIONAL; none → PASS.
|
|
412
|
+
function classifyVerdict(items) {
|
|
413
|
+
if (!Array.isArray(items) || items.length === 0) return 'PASS';
|
|
414
|
+
const hasHigh = items.some(item => {
|
|
415
|
+
const sev = (item.severity || item.level || '').toString().toUpperCase();
|
|
416
|
+
return sev === 'HIGH' || sev === 'CRITICAL';
|
|
417
|
+
});
|
|
418
|
+
return hasHigh ? 'FAIL' : 'CONDITIONAL';
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Pick the auditor roster for phase-e-auto from swarm.json (or defaults).
|
|
422
|
+
// Filters to entries that are reachable (CLI or API); missing CLI AND no
|
|
423
|
+
// apiFallback → skipped with a NOTE entry in the return value.
|
|
424
|
+
function resolvePhaseEAuditors(swarmConfig, env) {
|
|
425
|
+
const requestedIds = (Array.isArray(swarmConfig.auditors) && swarmConfig.auditors.length > 0)
|
|
426
|
+
? swarmConfig.auditors
|
|
427
|
+
: [...DEFAULT_AUDITORS];
|
|
428
|
+
|
|
429
|
+
const picks = [];
|
|
430
|
+
const skipped = [];
|
|
431
|
+
|
|
432
|
+
for (const id of requestedIds) {
|
|
433
|
+
const entry = ROSTER.find(e => e.id === id);
|
|
434
|
+
if (!entry) {
|
|
435
|
+
skipped.push({ id, reason: 'not in roster' });
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const reach = isReachable(id, env);
|
|
439
|
+
if (!reach.any) {
|
|
440
|
+
// CLI missing AND no apiFallback (or key not set) → skip with NOTE
|
|
441
|
+
skipped.push({ id, reason: 'CLI missing and no apiFallback configured' });
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
// Annotate API-only picks
|
|
445
|
+
const pick = (!reach.cli && reach.api) ? { ...entry, preferredSource: 'api' } : { ...entry };
|
|
446
|
+
picks.push(pick);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return { picks, skipped };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Run the phase-e-auto branch. Does NOT use process.exit / uxGate / budget
|
|
453
|
+
// guard — it is a programmatic call from the orchestrator, not a CLI call.
|
|
454
|
+
async function runPhaseEAuto({ projectDir, phase, target, env, quiet }) {
|
|
455
|
+
const swarmConfig = loadSwarmConfig(projectDir);
|
|
456
|
+
const { picks, skipped } = resolvePhaseEAuditors(swarmConfig, env);
|
|
457
|
+
|
|
458
|
+
const notes = skipped.map(s => `NOTE: skipped auditor '${s.id}' — ${s.reason}`);
|
|
459
|
+
|
|
460
|
+
if (!quiet && notes.length > 0) {
|
|
461
|
+
process.stderr.write(notes.join('\n') + '\n');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (picks.length === 0) {
|
|
465
|
+
const outputPath = resolveAuditOutputPath(projectDir, phase);
|
|
466
|
+
const content = `# Cross-Audit Phase E\n\nNo auditors available.\n\n${notes.map(n => `- ${n}`).join('\n')}\n`;
|
|
467
|
+
const dir = dirname(outputPath);
|
|
468
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
469
|
+
writeFileSync(outputPath, content, 'utf8');
|
|
470
|
+
return { verdict: 'CONDITIONAL', findings: [], outputPath, notes };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const auditTarget = target || 'HEAD~1..HEAD';
|
|
474
|
+
const resolvedTimeoutSec = null;
|
|
475
|
+
|
|
476
|
+
const requests = picks.map(pick => ({
|
|
477
|
+
pick,
|
|
478
|
+
payload: buildRequest('audit', auditTarget, pick.id, 'general', null),
|
|
479
|
+
}));
|
|
480
|
+
|
|
481
|
+
const tasks = requests.map(({ pick, payload }) => () =>
|
|
482
|
+
fireExternal(pick, payload, timeoutForPick(pick, resolvedTimeoutSec), env)
|
|
483
|
+
);
|
|
484
|
+
const rawResults = await fanOut(tasks, 3);
|
|
485
|
+
|
|
486
|
+
const auditorResults = rawResults.map((raw, i) => {
|
|
487
|
+
const pick = picks[i];
|
|
488
|
+
if (raw === null) {
|
|
489
|
+
return { status: 'failed', parsed: { items: [], prose: `[${pick.id}: spawn failed]` } };
|
|
490
|
+
}
|
|
491
|
+
const { stdout, exitCode, status: rawStatus } = raw;
|
|
492
|
+
if (rawStatus === 'timeout') return { status: 'timeout', parsed: { items: [], prose: `[${pick.id}: timeout]` } };
|
|
493
|
+
if (rawStatus === 'failed') return { status: 'failed', parsed: { items: [], prose: `[${pick.id}: failed]` } };
|
|
494
|
+
if (rawStatus === 'aborted') return { status: 'aborted', parsed: { items: [], prose: `[${pick.id}: aborted]` } };
|
|
495
|
+
if (rawStatus === 'fallback-used') {
|
|
496
|
+
const p = parseResponse('audit', stdout);
|
|
497
|
+
return { status: 'fallback-used', parsed: p };
|
|
498
|
+
}
|
|
499
|
+
if (exitCode !== 0) return { status: 'failed', parsed: { items: [], prose: `[${pick.id}: exited ${exitCode}]` } };
|
|
500
|
+
const p = parseResponse('audit', stdout);
|
|
501
|
+
return { status: 'ok', parsed: p };
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const parsed = auditorResults.map(r => r.parsed);
|
|
505
|
+
const merged = mergeResponses('audit', parsed);
|
|
506
|
+
const items = Array.isArray(merged) ? merged : [];
|
|
507
|
+
const verdict = classifyVerdict(items);
|
|
508
|
+
|
|
509
|
+
// Write synthesis to .planning/<phase>/CROSS-AUDIT-r<N>.md
|
|
510
|
+
const outputPath = resolveAuditOutputPath(projectDir, phase);
|
|
511
|
+
const dir = dirname(outputPath);
|
|
512
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
513
|
+
|
|
514
|
+
const auditorSummary = picks.map((p, i) => `- ${p.id}: ${auditorResults[i].status}`).join('\n');
|
|
515
|
+
const findingsSection = items.length > 0
|
|
516
|
+
? items.map(item => `- [${(item.severity || item.level || 'INFO').toUpperCase()}] ${item.text || item.description || JSON.stringify(item)}`).join('\n')
|
|
517
|
+
: '_(none)_';
|
|
518
|
+
const content = [
|
|
519
|
+
`# Cross-Audit Phase E — ${phase}`,
|
|
520
|
+
'',
|
|
521
|
+
`**Verdict:** ${verdict}`,
|
|
522
|
+
`**Auditors:** ${picks.map(p => p.id).join(', ')}`,
|
|
523
|
+
'',
|
|
524
|
+
'## Auditor Status',
|
|
525
|
+
auditorSummary,
|
|
526
|
+
'',
|
|
527
|
+
'## Findings',
|
|
528
|
+
findingsSection,
|
|
529
|
+
'',
|
|
530
|
+
...(notes.length > 0 ? ['## Notes', ...notes, ''] : []),
|
|
531
|
+
].join('\n');
|
|
532
|
+
|
|
533
|
+
writeFileSync(outputPath, content, 'utf8');
|
|
534
|
+
|
|
535
|
+
return { verdict, findings: items, outputPath, notes };
|
|
536
|
+
}
|
|
537
|
+
|
|
384
538
|
export async function runCrossOp({
|
|
385
539
|
mode,
|
|
386
540
|
target,
|
|
@@ -400,6 +554,14 @@ export async function runCrossOp({
|
|
|
400
554
|
runStamp = runStamp ?? new Date().toISOString();
|
|
401
555
|
env = env ?? process.env;
|
|
402
556
|
|
|
557
|
+
// v1.4.4 N10: phase-e-auto branch — programmatic orchestrator call.
|
|
558
|
+
// Reads .ijfw/swarm.json for auditor roster; graceful CLI-missing skip;
|
|
559
|
+
// writes .planning/<phase>/CROSS-AUDIT-r<N>.md; returns {verdict, findings, outputPath}.
|
|
560
|
+
if (mode === 'phase-e-auto') {
|
|
561
|
+
const phase = target || 'current';
|
|
562
|
+
return runPhaseEAuto({ projectDir, phase, target, env, quiet });
|
|
563
|
+
}
|
|
564
|
+
|
|
403
565
|
const start = Date.now();
|
|
404
566
|
|
|
405
567
|
// Shared abort controller for this run -- used by minResponsesFanOut to kill stragglers.
|