@ijfw/memory-server 1.3.0

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.
Files changed (106) hide show
  1. package/bin/ijfw +27 -0
  2. package/bin/ijfw-dashboard +180 -0
  3. package/bin/ijfw-dispatch-plan +41 -0
  4. package/bin/ijfw-memorize +273 -0
  5. package/bin/ijfw-memory +51 -0
  6. package/fixtures/demo-target.js +28 -0
  7. package/package.json +53 -0
  8. package/src/api-client.js +190 -0
  9. package/src/audit-roster.js +315 -0
  10. package/src/caps.js +37 -0
  11. package/src/cold-scan-runner.mjs +37 -0
  12. package/src/compute/edges.js +155 -0
  13. package/src/compute/extract.js +560 -0
  14. package/src/compute/fts5.js +420 -0
  15. package/src/compute/graph-auto-index.js +191 -0
  16. package/src/compute/graph-lock.js +114 -0
  17. package/src/compute/index.js +18 -0
  18. package/src/compute/migration-runner.js +116 -0
  19. package/src/compute/migrations/001-initial.js +23 -0
  20. package/src/compute/migrations/002-porter-stemming-source.js +139 -0
  21. package/src/compute/migrations/003-tier-semantic.js +69 -0
  22. package/src/compute/migrations/004-kg-tables.js +83 -0
  23. package/src/compute/migrations/005-stale-candidate.js +72 -0
  24. package/src/compute/python-resolver.js +106 -0
  25. package/src/compute/runner-vm.js +185 -0
  26. package/src/compute/runner.js +416 -0
  27. package/src/compute/sandbox-detect.js +122 -0
  28. package/src/compute/sandbox-linux.js +164 -0
  29. package/src/compute/sandbox-macos.js +167 -0
  30. package/src/compute/sandbox-windows.js +63 -0
  31. package/src/compute/schema.sql +118 -0
  32. package/src/compute/staleness.js +239 -0
  33. package/src/compute/synonyms.js +367 -0
  34. package/src/compute/traverse.js +180 -0
  35. package/src/cost/aggregator.js +229 -0
  36. package/src/cost/pricing.js +134 -0
  37. package/src/cost/readers/claude.js +179 -0
  38. package/src/cost/readers/codex.js +131 -0
  39. package/src/cost/readers/gemini.js +111 -0
  40. package/src/cost/savings.js +243 -0
  41. package/src/cross-dispatcher.js +437 -0
  42. package/src/cross-orchestrator-cli.js +1885 -0
  43. package/src/cross-orchestrator.js +598 -0
  44. package/src/cross-project-search.js +114 -0
  45. package/src/dashboard-client.html +1180 -0
  46. package/src/dashboard-server.js +895 -0
  47. package/src/design-companion.js +81 -0
  48. package/src/dispatch/colon-syntax.js +732 -0
  49. package/src/dispatch-planner.js +235 -0
  50. package/src/dream/cooldown.js +105 -0
  51. package/src/dream/runner.mjs +373 -0
  52. package/src/dream/staleness-wiring.js +195 -0
  53. package/src/feedback-detector.js +57 -0
  54. package/src/hero-line.js +115 -0
  55. package/src/importers/claude-mem.js +152 -0
  56. package/src/importers/cli.js +311 -0
  57. package/src/importers/common.js +84 -0
  58. package/src/importers/discover.js +235 -0
  59. package/src/importers/rtk.js +107 -0
  60. package/src/intent-router.js +221 -0
  61. package/src/lib/atomic-io.js +201 -0
  62. package/src/lib/cache.js +33 -0
  63. package/src/lib/npm-view.js +104 -0
  64. package/src/lib/status-card.js +95 -0
  65. package/src/lib/token.js +85 -0
  66. package/src/memory/fts5.js +349 -0
  67. package/src/memory/migration-runner.js +116 -0
  68. package/src/memory/migrations/001-fts5-init.js +26 -0
  69. package/src/memory/migrations/002-tier-semantic.js +60 -0
  70. package/src/memory/migrations/003-stale-candidate.js +60 -0
  71. package/src/memory/reader.js +300 -0
  72. package/src/memory/recall-counter.js +76 -0
  73. package/src/memory/schema.sql +79 -0
  74. package/src/memory/search.js +431 -0
  75. package/src/memory/staleness.js +237 -0
  76. package/src/memory/tier-promotion.js +377 -0
  77. package/src/memory/tokenize.js +63 -0
  78. package/src/project-type-detector.js +866 -0
  79. package/src/prompt-check.js +171 -0
  80. package/src/ralph-allowlist.js +88 -0
  81. package/src/receipts.js +129 -0
  82. package/src/redactor.js +107 -0
  83. package/src/sandbox.js +275 -0
  84. package/src/sanitizer.js +69 -0
  85. package/src/scan-resume.js +167 -0
  86. package/src/schema.js +82 -0
  87. package/src/search-bm25.js +108 -0
  88. package/src/server.js +1414 -0
  89. package/src/swarm-config.js +80 -0
  90. package/src/trident/dispatch.js +211 -0
  91. package/src/trident/lens-health.js +253 -0
  92. package/src/update-apply.js +79 -0
  93. package/src/update-check.js +136 -0
  94. package/src/vectors.js +178 -0
  95. package/templates/design/bento-grid.md +84 -0
  96. package/templates/design/brutalist-luxe.md +82 -0
  97. package/templates/design/cinematic-dark.md +82 -0
  98. package/templates/design/data-dense-dashboard.md +88 -0
  99. package/templates/design/editorial-warm.md +81 -0
  100. package/templates/design/glassmorphic.md +84 -0
  101. package/templates/design/magazine-editorial.md +84 -0
  102. package/templates/design/maximalist-vibrant.md +85 -0
  103. package/templates/design/neo-swiss-tech.md +85 -0
  104. package/templates/design/swiss-minimal.md +80 -0
  105. package/templates/design/terminal-native.md +83 -0
  106. package/templates/design/warm-organic.md +84 -0
@@ -0,0 +1,598 @@
1
+ // cross-orchestrator.js -- Trident execution flow.
2
+ //
3
+ // runCrossOp: probe roster → diversity pick → swarm resolve →
4
+ // parallel fire → merge → receipt write → return.
5
+ //
6
+ // Stamp note (U7): buildRequest stamps internally with new Date() per call.
7
+ // The orchestrator's runStamp is used exclusively in the receipt and as the
8
+ // archive identity for this run. We don't patch buildRequest to accept an
9
+ // override -- simpler, and the receipt is the authoritative record.
10
+ //
11
+ // Specialist swarm (U6): isInstalled is cached per-process in audit-roster;
12
+ // pickAuditors already calls it. We do not re-probe here.
13
+ //
14
+ // ESM, zero external deps.
15
+
16
+ import { spawn } from 'node:child_process';
17
+ import * as readline from 'node:readline';
18
+ import { pickAuditors, isReachable } from './audit-roster.js';
19
+ import { loadSwarmConfig } from './swarm-config.js';
20
+ import { buildRequest, parseResponse, mergeResponses, checkBudget } from './cross-dispatcher.js';
21
+ import { writeReceipt, readReceipts } from './receipts.js';
22
+ import { runViaApi } from './api-client.js';
23
+ import { RELEASE_BLOCKER_GATES, DegradedTridentError } from './trident/dispatch.js';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Per-provider timeout defaults (ms). Codex cold-start can take 120s+ (U2).
27
+ // ---------------------------------------------------------------------------
28
+ const PROVIDER_TIMEOUT_MS = {
29
+ codex: 120_000,
30
+ gemini: 45_000,
31
+ anthropic: 60_000,
32
+ 'api-mode': 30_000,
33
+ };
34
+ const DEFAULT_TIMEOUT_MS = 90_000;
35
+
36
+ function timeoutForPick(pick, resolvedTimeoutSec) {
37
+ if (resolvedTimeoutSec) return resolvedTimeoutSec * 1000;
38
+ return PROVIDER_TIMEOUT_MS[pick.id] ?? DEFAULT_TIMEOUT_MS;
39
+ }
40
+
41
+ // parsePosInt -- parse a raw string to a positive integer in [min, max].
42
+ // Returns fallback on non-numeric, NaN, ≤0, or >max.
43
+ function parsePosInt(raw, fallback, min = 1, max = Infinity) {
44
+ if (raw === undefined || raw === null || raw === '') return fallback;
45
+ const n = Number(raw);
46
+ if (!Number.isFinite(n) || n < min || n > max) return fallback;
47
+ return Math.floor(n);
48
+ }
49
+
50
+ // Read one line from stdin. Resolves with trimmed string.
51
+ function readLine(prompt) {
52
+ return new Promise((resolve) => {
53
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
54
+ rl.question(prompt, (answer) => { rl.close(); resolve(answer.trim()); });
55
+ });
56
+ }
57
+
58
+ // Emit pre-fire UX string to stderr; handle --confirm interactive gate.
59
+ // Returns true to proceed, false to cancel.
60
+ async function uxGate(picks, missing, confirm, quiet = false) {
61
+ const ids = picks.map(p => p.id).join(', ');
62
+ const missingFamilies = [...new Set(
63
+ (missing || [])
64
+ .map(m => m.family || m.id)
65
+ .filter(Boolean)
66
+ )];
67
+
68
+ if (confirm) {
69
+ process.stderr.write(`Confirm combo: ${ids}? [y/N] `);
70
+ const answer = await readLine('');
71
+ if (answer.toLowerCase() !== 'y') {
72
+ process.stderr.write('Cancelled.\n');
73
+ return false;
74
+ }
75
+ return true;
76
+ }
77
+
78
+ if (!quiet) {
79
+ if (missingFamilies.length > 0) {
80
+ const missing_label = missingFamilies.join(', ');
81
+ const hint = missingFamilies.map(f => `${f}-family`).join(' or ');
82
+ process.stderr.write(
83
+ `Partial roster: running ${ids}; missing ${missing_label}. Install a ${hint} CLI for full Trident diversity.\n`
84
+ );
85
+ } else {
86
+ process.stderr.write(
87
+ `Auto-proceeding with ${ids}. Pass --confirm to override on next turn.\n`
88
+ );
89
+ }
90
+ }
91
+ return true;
92
+ }
93
+
94
+ // Angle assignments per mode per auditor family/id.
95
+ const AUDIT_ANGLE = () => 'general';
96
+
97
+ const RESEARCH_ANGLE = (id) => {
98
+ if (id === 'codex' || id === 'opencode' || id === 'aider') return 'benchmarks';
99
+ if (id === 'claude') return 'synthesis';
100
+ return 'citations'; // gemini, copilot, default
101
+ };
102
+
103
+ const CRITIQUE_ANGLE = (id) => {
104
+ if (id === 'codex' || id === 'opencode' || id === 'aider') return 'technical';
105
+ if (id === 'gemini' || id === 'copilot') return 'strategic';
106
+ return 'ux'; // claude, default
107
+ };
108
+
109
+ function angleFor(mode, id) {
110
+ if (mode === 'audit') return AUDIT_ANGLE(id);
111
+ if (mode === 'research') return RESEARCH_ANGLE(id);
112
+ if (mode === 'critique') return CRITIQUE_ANGLE(id);
113
+ throw new Error(`Unknown mode: ${mode}`);
114
+ }
115
+
116
+ // buildSpawnEnv -- compose env for a given auditor pick.
117
+ // Issue #9-A: when running the gemini CLI, if GEMINI_API_KEY is present,
118
+ // strip gcloud-related env vars so gemini-cli's own auth precedence does not
119
+ // silently pick up an unrelated gcloud project (cloudaicompanion.googleapis.com
120
+ // billing collisions). Reproduced by Kat in issue #9.
121
+ //
122
+ // Precedence we enforce when GEMINI_API_KEY is set:
123
+ // GEMINI_API_KEY (kept) > GOOGLE_APPLICATION_CREDENTIALS (dropped)
124
+ // > gcloud active-project env (dropped)
125
+ export function buildSpawnEnv(pick, baseEnv) {
126
+ const env = { ...baseEnv };
127
+ if (pick && pick.id === 'gemini' && env.GEMINI_API_KEY) {
128
+ delete env.GOOGLE_APPLICATION_CREDENTIALS;
129
+ delete env.GOOGLE_CLOUD_PROJECT;
130
+ delete env.GCLOUD_PROJECT;
131
+ delete env.CLOUDSDK_CORE_PROJECT;
132
+ }
133
+ return env;
134
+ }
135
+
136
+ // spawnCli -- single-settlement guard + SIGKILL on timeout or abort signal.
137
+ // Returns { stdout, stderr, exitCode, timedOut, aborted } or null on spawn error.
138
+ function spawnCli(pick, request, timeoutMs, signal = null, env = process.env) {
139
+ return new Promise((resolve) => {
140
+ const parts = pick.invoke.trim().split(/\s+/);
141
+ const bin = parts[0];
142
+ const args = parts.slice(1);
143
+
144
+ let settled = false;
145
+ const settle = (val) => { if (settled) return; settled = true; resolve(val); };
146
+
147
+ const killAndAbort = () => {
148
+ if (proc) {
149
+ proc.kill('SIGKILL');
150
+ try { proc.stdout.destroy(); } catch { /* ignore */ }
151
+ try { proc.stderr.destroy(); } catch { /* ignore */ }
152
+ }
153
+ clearTimeout(timer);
154
+ settle({ stdout: '', stderr: 'aborted', exitCode: null, timedOut: false, aborted: true });
155
+ };
156
+
157
+ // Check abort before spawning.
158
+ if (signal?.aborted) { resolve({ stdout: '', stderr: 'aborted', exitCode: null, timedOut: false, aborted: true }); return; }
159
+
160
+ let proc;
161
+ const timer = setTimeout(() => {
162
+ if (proc) {
163
+ proc.kill('SIGKILL');
164
+ // Destroy stdio streams so the event loop isn't kept alive by open pipes.
165
+ try { proc.stdout.destroy(); } catch { /* ignore */ }
166
+ try { proc.stderr.destroy(); } catch { /* ignore */ }
167
+ }
168
+ settle({ stdout: '', stderr: 'timeout', exitCode: null, timedOut: true, aborted: false });
169
+ }, timeoutMs);
170
+
171
+ let stdout = '';
172
+ let stderr = '';
173
+
174
+ try {
175
+ proc = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'], env: buildSpawnEnv(pick, env) });
176
+ } catch {
177
+ clearTimeout(timer);
178
+ settle(null);
179
+ return;
180
+ }
181
+
182
+ // Listen for external abort (runAc).
183
+ if (signal) signal.addEventListener('abort', killAndAbort, { once: true });
184
+
185
+ proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
186
+ proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
187
+ // Single-settlement guard: error + close can both fire on spawn failure.
188
+ proc.on('error', () => { clearTimeout(timer); settle(null); });
189
+ proc.on('close', (code) => {
190
+ clearTimeout(timer);
191
+ if (signal) signal.removeEventListener('abort', killAndAbort);
192
+ settle({ stdout, stderr, exitCode: code, timedOut: false, aborted: false });
193
+ });
194
+
195
+ // 1.2.5 (1.3 audit fix): respect backpressure on the stdin write. For
196
+ // typical 1-50 KB prompts the pipe buffer absorbs the write, but very
197
+ // large requests (long synthesis prompts, big file targets) can return
198
+ // false from .write() and require a 'drain' event before .end() to avoid
199
+ // dropping bytes on certain CLI implementations.
200
+ try {
201
+ const flushed = proc.stdin.write(request);
202
+ if (flushed) {
203
+ proc.stdin.end();
204
+ } else {
205
+ proc.stdin.once('drain', () => { try { proc.stdin.end(); } catch { /* */ } });
206
+ }
207
+ } catch {
208
+ // stdin may already be closed on some CLI tools
209
+ }
210
+ });
211
+ }
212
+
213
+ // fireExternal -- CLI with API-key fallback.
214
+ // Returns { stdout, stderr, exitCode, status, source, elapsedMs }
215
+ // status: 'ok' | 'empty' | 'failed' | 'timeout' | 'fallback-used' | 'aborted' | null (cli normal)
216
+ // source: 'cli' | 'api' | 'none'
217
+ //
218
+ // Timeout → fallback policy: a CLI timeout IS fallback-eligible. A slow CLI
219
+ // gets bypassed by the API when available. API uses its own 30s budget so
220
+ // the overall result is either 'fallback-used' (API succeeded) or the original
221
+ // 'timeout' (both paths exhausted).
222
+ async function fireExternal(pick, request, timeoutMs, env = process.env, signal = null) {
223
+ const t0 = Date.now();
224
+ const elapsed = () => Date.now() - t0;
225
+
226
+ // Helper: extract mode/angle/target from the request payload for API calls.
227
+ function extractApiParams() {
228
+ const modeMatch = request.match(/^Mode:\s+(\S+)/m);
229
+ const angleMatch = request.match(/^Angle:\s+(\S+)/m);
230
+ const mode = modeMatch ? modeMatch[1] : 'audit';
231
+ const angle = angleMatch ? angleMatch[1] : 'general';
232
+ const targetMatch = request.match(/## Target\s*\n\n([\s\S]*)$/);
233
+ const target = targetMatch ? targetMatch[1].trim() : request;
234
+ return { mode, angle, target };
235
+ }
236
+
237
+ // API-only pick (preferredSource: 'api') -- skip spawnCli entirely.
238
+ if (pick.preferredSource === 'api' && pick.apiFallback && isReachable(pick.id, env).api) {
239
+ if (signal?.aborted) return { stdout: '', stderr: 'aborted', exitCode: null, status: 'aborted', source: 'none', elapsedMs: elapsed() };
240
+ const { mode, angle, target } = extractApiParams();
241
+ const apiResult = await runViaApi(pick, mode, angle, target, env, PROVIDER_TIMEOUT_MS['api-mode'], signal);
242
+ if (apiResult.status === 'ok') {
243
+ return { stdout: apiResult.raw, stderr: '', exitCode: 0, status: 'fallback-used', source: 'api', elapsedMs: elapsed() };
244
+ }
245
+ return { stdout: '', stderr: apiResult.error, exitCode: null, status: 'failed', source: 'none', elapsedMs: elapsed() };
246
+ }
247
+
248
+ const raw = await spawnCli(pick, request, timeoutMs, signal, env);
249
+
250
+ // Aborted by runAc
251
+ if (raw && raw.aborted) {
252
+ return { stdout: '', stderr: 'aborted', exitCode: null, status: 'aborted', source: 'none', elapsedMs: elapsed() };
253
+ }
254
+
255
+ // Explicit timeout -- attempt API fallback before giving up.
256
+ if (raw && raw.timedOut) {
257
+ if (pick.apiFallback && isReachable(pick.id, env).api) {
258
+ const { mode, angle, target } = extractApiParams();
259
+ const apiResult = await runViaApi(pick, mode, angle, target, env, PROVIDER_TIMEOUT_MS['api-mode'], signal);
260
+ if (apiResult.status === 'ok') {
261
+ return { stdout: apiResult.raw, stderr: '', exitCode: 0, status: 'fallback-used', source: 'api', elapsedMs: elapsed() };
262
+ }
263
+ }
264
+ return { stdout: '', stderr: 'timeout', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
265
+ }
266
+
267
+ // CLI failed -- try API fallback
268
+ const cliOk = raw !== null && raw.exitCode === 0;
269
+ if (!cliOk && pick.apiFallback && isReachable(pick.id, env).api) {
270
+ const { mode, angle, target } = extractApiParams();
271
+ const apiResult = await runViaApi(pick, mode, angle, target, env, PROVIDER_TIMEOUT_MS['api-mode'], signal);
272
+
273
+ if (apiResult.status === 'ok') {
274
+ return { stdout: apiResult.raw, stderr: '', exitCode: 0, status: 'fallback-used', source: 'api', elapsedMs: elapsed() };
275
+ }
276
+ return { stdout: '', stderr: apiResult.error, exitCode: null, status: 'failed', source: 'none', elapsedMs: elapsed() };
277
+ }
278
+
279
+ if (raw === null) {
280
+ return { stdout: '', stderr: 'spawn error', exitCode: null, status: 'failed', source: 'none', elapsedMs: elapsed() };
281
+ }
282
+
283
+ return { stdout: raw.stdout, stderr: raw.stderr, exitCode: raw.exitCode, status: null, source: 'cli', elapsedMs: elapsed() };
284
+ }
285
+
286
+ // fanOut -- rolling concurrency window; zero-dep semaphore.
287
+ async function fanOut(tasks, concurrency = 3) {
288
+ const results = Array.from({ length: tasks.length });
289
+ let next = 0;
290
+ async function worker() {
291
+ while (next < tasks.length) {
292
+ const i = next++;
293
+ results[i] = await tasks[i]();
294
+ }
295
+ }
296
+ const workers = [];
297
+ for (let w = 0; w < Math.min(concurrency, tasks.length); w++) workers.push(worker());
298
+ await Promise.all(workers);
299
+ return results;
300
+ }
301
+
302
+ // minResponsesFanOut -- abort stragglers once minResponses auditors settle
303
+ // productively. Takes a shared AbortController (runAc) so pending picks get
304
+ // killed on threshold.
305
+ //
306
+ // 1.2.5 audit fixes:
307
+ // - 1.4 (HIGH): only "productive" results (status null = CLI exit 0, or
308
+ // 'fallback-used' = API succeeded) count toward minResponses. Failed /
309
+ // timeout / aborted results count toward all-settled detection so the
310
+ // promise still resolves, but they no longer prematurely satisfy
311
+ // minResponses. Previously: 2 immediate failures with minResponses=2
312
+ // could abort still-running productive auditors.
313
+ // - 1.1 (HIGH): .catch() guard on fireExternal so a synchronous throw
314
+ // can never leave the orchestrator promise unresolved forever.
315
+ async function minResponsesFanOut(requests, picks, resolvedTimeoutSec, env, concurrency, minResponses, runAc) {
316
+ const total = requests.length;
317
+ const results = Array.from({ length: total }, () => null);
318
+ let productiveCount = 0;
319
+ let settledCount = 0;
320
+ let nextIdx = 0;
321
+ let done = false;
322
+
323
+ function isProductive(raw) {
324
+ return Boolean(raw) && (raw.status === null || raw.status === 'fallback-used');
325
+ }
326
+
327
+ return new Promise((resolveAll) => {
328
+ function check() {
329
+ if (done) return;
330
+ const enoughProductive = productiveCount >= Math.min(minResponses, total);
331
+ const allDone = settledCount >= total;
332
+ if (enoughProductive || allDone) {
333
+ done = true;
334
+ runAc.abort(); // signal remaining in-flight picks to terminate
335
+ for (let j = 0; j < total; j++) {
336
+ if (results[j] === null) {
337
+ results[j] = { stdout: '', stderr: 'aborted', exitCode: null, status: 'aborted', source: 'none', elapsedMs: 0 };
338
+ }
339
+ }
340
+ resolveAll(results);
341
+ }
342
+ }
343
+ function launchNext() {
344
+ if (done || nextIdx >= total) return;
345
+ const i = nextIdx++;
346
+ const { pick, payload } = requests[i];
347
+ fireExternal(pick, payload, timeoutForPick(pick, resolvedTimeoutSec), env, runAc.signal)
348
+ .then(raw => {
349
+ results[i] = raw;
350
+ settledCount++;
351
+ if (isProductive(raw)) productiveCount++;
352
+ check();
353
+ launchNext();
354
+ })
355
+ .catch(err => {
356
+ results[i] = {
357
+ stdout: '',
358
+ stderr: `unexpected: ${err && err.message ? err.message : 'unknown'}`,
359
+ exitCode: null, status: 'failed', source: 'none', elapsedMs: 0,
360
+ };
361
+ settledCount++;
362
+ check();
363
+ launchNext();
364
+ });
365
+ }
366
+ for (let w = 0; w < Math.min(concurrency, total); w++) launchNext();
367
+ });
368
+ }
369
+
370
+ function countItems(p) {
371
+ if (Array.isArray(p.items)) return p.items.length;
372
+ if (Array.isArray(p.consensus)) return p.consensus.length + (p.contested || []).length;
373
+ return 0;
374
+ }
375
+
376
+ export async function runCrossOp({
377
+ mode,
378
+ target,
379
+ projectDir,
380
+ env,
381
+ runStamp,
382
+ expand: _expand, // reserved -- passed through but unused in current CLI context
383
+ only,
384
+ confirm, // reserved -- handled by caller (CLI layer)
385
+ perAuditorTimeoutSec,
386
+ minResponses,
387
+ quiet = false, // suppress uxGate stderr warnings (used by demo)
388
+ gate = null, // GA-H2: release-blocker gate name (publish/tag/deploy/release)
389
+ accept_degraded = false, // GA-H2: explicit override for single-lens release-blocker audits
390
+ } = {}) {
391
+ projectDir = projectDir ?? process.cwd();
392
+ runStamp = runStamp ?? new Date().toISOString();
393
+ env = env ?? process.env;
394
+
395
+ const start = Date.now();
396
+
397
+ // Shared abort controller for this run -- used by minResponsesFanOut to kill stragglers.
398
+ const runAc = new AbortController();
399
+
400
+ const rawTimeoutSec = env.IJFW_AUDIT_TIMEOUT_SEC;
401
+ const envTimeoutSec = parsePosInt(rawTimeoutSec, null, 1, 3600);
402
+ if (rawTimeoutSec !== undefined && rawTimeoutSec !== null && envTimeoutSec === null && !quiet) {
403
+ process.stderr.write(`IJFW_AUDIT_TIMEOUT_SEC=${rawTimeoutSec} is invalid; using default ${DEFAULT_TIMEOUT_MS / 1000}s.\n`);
404
+ }
405
+ const resolvedTimeoutSec = perAuditorTimeoutSec ?? envTimeoutSec ?? null;
406
+
407
+ // 1. Roster pick (isInstalled cached in audit-roster per U6)
408
+ const { picks, missing, note } = pickAuditors({ strategy: 'diversity', env, only });
409
+
410
+ // 2. Short-circuit when no auditors are available
411
+ if (picks.length === 0) {
412
+ process.stderr.write('No external auditors ready -- install codex or gemini for full Trident.\n');
413
+ return { merged: null, picks: [], missing, note };
414
+ }
415
+
416
+ // 2b. Budget guard -- post-flight accumulation check (2nd+ calls in session)
417
+ const sessionStart = new Date(Date.now() - process.uptime() * 1000);
418
+ const priorReceipts = readReceipts(projectDir);
419
+ const budgetMsg = checkBudget({ target, picks, receipts: priorReceipts, sessionStart, env });
420
+ if (budgetMsg) {
421
+ process.stderr.write(budgetMsg + '\n');
422
+ process.exit(2);
423
+ }
424
+
425
+ // 3. UX gate -- emit status line or prompt before firing
426
+ const proceed = await uxGate(picks, missing, confirm, quiet);
427
+ if (!proceed) process.exit(0);
428
+
429
+ // 4. Swarm config (specialist list; swarm dispatch skipped in CLI context)
430
+ const swarmConfig = loadSwarmConfig(projectDir);
431
+
432
+ // 5. Build request payloads for each external pick
433
+ const requests = picks.map(pick => ({
434
+ pick,
435
+ payload: buildRequest(mode, target, pick.id, angleFor(mode, pick.id), null),
436
+ }));
437
+
438
+ // 6. Fan-out with concurrency cap + optional minResponses short-circuit
439
+ const rawConcurrency = env.IJFW_AUDIT_CONCURRENCY;
440
+ const concurrencyParsed = rawConcurrency != null ? parsePosInt(rawConcurrency, null, 1, 16) : 3;
441
+ const concurrency = concurrencyParsed ?? 3;
442
+ if (rawConcurrency != null && concurrencyParsed === null && !quiet) {
443
+ process.stderr.write(`IJFW_AUDIT_CONCURRENCY=${rawConcurrency} is invalid; using default 3.\n`);
444
+ }
445
+
446
+ let rawResults;
447
+ if (minResponses && minResponses < picks.length) {
448
+ rawResults = await minResponsesFanOut(requests, picks, resolvedTimeoutSec, env, concurrency, minResponses, runAc);
449
+ } else {
450
+ const tasks = requests.map(({ pick, payload }) => () =>
451
+ fireExternal(pick, payload, timeoutForPick(pick, resolvedTimeoutSec), env)
452
+ );
453
+ rawResults = await fanOut(tasks, concurrency);
454
+ }
455
+
456
+ // 7. Parse each response; classify failures vs empty vs success
457
+ const auditorResults = rawResults.map((raw, i) => {
458
+ const pick = picks[i];
459
+
460
+ if (raw === null) {
461
+ return { status: 'failed', source: 'none', stderr: 'spawn error', exitCode: null, elapsedMs: 0, parsed: { items: [], prose: `[${pick.id}: spawn failed]` } };
462
+ }
463
+
464
+ const { stdout, stderr: rawStderr, exitCode, status: rawStatus, source, elapsedMs } = raw;
465
+ const stderrSnip = rawStderr ? rawStderr.slice(0, 500) : '';
466
+
467
+ if (rawStatus === 'aborted') {
468
+ return { status: 'aborted', source: 'none', stderr: stderrSnip, exitCode: null, elapsedMs, parsed: { items: [], prose: `[${pick.id}: aborted]` } };
469
+ }
470
+ if (rawStatus === 'timeout') {
471
+ return { status: 'timeout', source: 'none', stderr: stderrSnip, exitCode: null, elapsedMs, parsed: { items: [], prose: `[${pick.id}: timeout]` } };
472
+ }
473
+ if (rawStatus === 'failed') {
474
+ return { status: 'failed', source: 'none', stderr: stderrSnip, exitCode, elapsedMs, parsed: { items: [], prose: `[${pick.id}: failed]` } };
475
+ }
476
+ if (rawStatus === 'fallback-used') {
477
+ const p = parseResponse(mode, stdout);
478
+ const itemCount = countItems(p);
479
+ return { status: itemCount === 0 ? 'empty' : 'fallback-used', source: 'api', stderr: stderrSnip, exitCode: 0, elapsedMs, parsed: p };
480
+ }
481
+
482
+ // CLI path (rawStatus === null → normal exit from spawnCli)
483
+ if (exitCode !== 0 || (stderrSnip && !stdout.trim())) {
484
+ return { status: 'failed', source: source ?? 'none', stderr: stderrSnip, exitCode, elapsedMs, parsed: { items: [], prose: `[${pick.id}: exited ${exitCode}]` } };
485
+ }
486
+ const p = parseResponse(mode, stdout);
487
+ const itemCount = countItems(p);
488
+ return { status: itemCount === 0 ? 'empty' : 'ok', source: source ?? 'cli', stderr: stderrSnip, exitCode, elapsedMs, parsed: p };
489
+ });
490
+
491
+ // 7b. GA-H2: C9.7 degraded-mode enforcement on the production path.
492
+ // - Counts productive lens results (status 'ok' or 'fallback-used')
493
+ // plus the in-process Claude-swarm leg (always live in-process).
494
+ // - When the release-blocker gate is active and the productive lens
495
+ // count drops to <=1, throw DegradedTridentError unless caller passed
496
+ // accept_degraded=true. Mirrors src/trident/dispatch.js semantics so
497
+ // the same invariant fires in tests AND in production.
498
+ // - When non-release-blocker, we let the audit complete; the verdict
499
+ // floor coercion below ensures a single-lens result never silently
500
+ // surfaces as PASS.
501
+ const productiveCount = auditorResults.filter(r => r.status === 'ok' || r.status === 'fallback-used').length
502
+ + 1; // claude-swarm leg always live in-process
503
+ const totalLenses = picks.length + 1;
504
+ const isReleaseBlocker = gate && RELEASE_BLOCKER_GATES.has(String(gate).toLowerCase());
505
+ const isDegraded = productiveCount <= 1;
506
+ const tridentMode = productiveCount >= 3
507
+ ? 'full'
508
+ : productiveCount === 2
509
+ ? 'partial'
510
+ : (accept_degraded ? 'single-lens-accepted' : 'single-lens-degraded');
511
+
512
+ if (isReleaseBlocker && isDegraded && !accept_degraded) {
513
+ throw new DegradedTridentError(
514
+ `Trident is degraded (${productiveCount}/${totalLenses} productive lenses; ` +
515
+ `auditor statuses: ${auditorResults.map(r => `${picks[auditorResults.indexOf(r)] && picks[auditorResults.indexOf(r)].id}=${r.status}`).join(', ')}). ` +
516
+ `Release-blocker gate "${gate}" rejects single-lens verdicts. Pass accept_degraded:true to override after human review.`,
517
+ { lensHealth: { productiveCount, totalLenses, auditorResults }, gate, requested_accept_degraded: accept_degraded }
518
+ );
519
+ }
520
+
521
+ // 8. All-timeout guard
522
+ if (auditorResults.length > 0 && auditorResults.every(r => r.status === 'timeout')) {
523
+ const currentVal = resolvedTimeoutSec ?? env.IJFW_AUDIT_TIMEOUT_SEC ?? 'default';
524
+ process.stderr.write(
525
+ `All auditors timed out -- check network or raise IJFW_AUDIT_TIMEOUT_SEC (currently ${currentVal})\n`
526
+ );
527
+ return {
528
+ merged: null, picks, missing, note, auditorResults,
529
+ allTimedOut: true, duration_ms: Date.now() - start,
530
+ };
531
+ }
532
+
533
+ const parsed = auditorResults.map(r => r.parsed);
534
+
535
+ // 9. Merge
536
+ const merged = mergeResponses(mode, parsed);
537
+
538
+ const duration_ms = Date.now() - start;
539
+
540
+ // 10. Extract findings shape for receipt
541
+ let findings;
542
+ if (mode === 'audit' || mode === 'critique') {
543
+ findings = { items: Array.isArray(merged) ? merged : [] };
544
+ } else {
545
+ findings = merged;
546
+ }
547
+
548
+ // 11. Write receipt
549
+ const receipt = {
550
+ v: 1,
551
+ timestamp: new Date().toISOString(),
552
+ run_stamp: runStamp,
553
+ mode,
554
+ target,
555
+ auditors: picks.map((p, i) => ({
556
+ id: p.id,
557
+ family: p.family,
558
+ model: p.model || '',
559
+ status: auditorResults[i].status,
560
+ source: auditorResults[i].source,
561
+ elapsedMs: auditorResults[i].elapsedMs,
562
+ ...(['failed', 'timeout'].includes(auditorResults[i].status)
563
+ ? { error: auditorResults[i].stderr, exitCode: auditorResults[i].exitCode }
564
+ : {}),
565
+ })),
566
+ findings,
567
+ duration_ms,
568
+ input_tokens: null,
569
+ cost_usd: null,
570
+ model: null,
571
+ specialist_swarm: 'skipped (CLI context)',
572
+ swarm_project_type: swarmConfig.project_type,
573
+ // GA-H2: C9.7 lens-health metadata embedded in every receipt so post-
574
+ // hoc audits can reconstruct the degraded-mode posture without re-
575
+ // probing. trident_mode mirrors src/trident/dispatch.js values.
576
+ trident_mode: tridentMode,
577
+ productive_lens_count: productiveCount,
578
+ total_lens_count: totalLenses,
579
+ gate: gate || null,
580
+ accept_degraded: !!accept_degraded,
581
+ };
582
+
583
+ writeReceipt(projectDir, receipt);
584
+
585
+ return {
586
+ merged,
587
+ receipt,
588
+ picks,
589
+ missing,
590
+ note,
591
+ auditorResults,
592
+ trident_mode: tridentMode,
593
+ productive_lens_count: productiveCount,
594
+ total_lens_count: totalLenses,
595
+ gate: gate || null,
596
+ accept_degraded: !!accept_degraded,
597
+ };
598
+ }