@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.
- package/bin/ijfw +27 -0
- package/bin/ijfw-dashboard +180 -0
- package/bin/ijfw-dispatch-plan +41 -0
- package/bin/ijfw-memorize +273 -0
- package/bin/ijfw-memory +51 -0
- package/fixtures/demo-target.js +28 -0
- package/package.json +53 -0
- package/src/api-client.js +190 -0
- package/src/audit-roster.js +315 -0
- package/src/caps.js +37 -0
- package/src/cold-scan-runner.mjs +37 -0
- package/src/compute/edges.js +155 -0
- package/src/compute/extract.js +560 -0
- package/src/compute/fts5.js +420 -0
- package/src/compute/graph-auto-index.js +191 -0
- package/src/compute/graph-lock.js +114 -0
- package/src/compute/index.js +18 -0
- package/src/compute/migration-runner.js +116 -0
- package/src/compute/migrations/001-initial.js +23 -0
- package/src/compute/migrations/002-porter-stemming-source.js +139 -0
- package/src/compute/migrations/003-tier-semantic.js +69 -0
- package/src/compute/migrations/004-kg-tables.js +83 -0
- package/src/compute/migrations/005-stale-candidate.js +72 -0
- package/src/compute/python-resolver.js +106 -0
- package/src/compute/runner-vm.js +185 -0
- package/src/compute/runner.js +416 -0
- package/src/compute/sandbox-detect.js +122 -0
- package/src/compute/sandbox-linux.js +164 -0
- package/src/compute/sandbox-macos.js +167 -0
- package/src/compute/sandbox-windows.js +63 -0
- package/src/compute/schema.sql +118 -0
- package/src/compute/staleness.js +239 -0
- package/src/compute/synonyms.js +367 -0
- package/src/compute/traverse.js +180 -0
- package/src/cost/aggregator.js +229 -0
- package/src/cost/pricing.js +134 -0
- package/src/cost/readers/claude.js +179 -0
- package/src/cost/readers/codex.js +131 -0
- package/src/cost/readers/gemini.js +111 -0
- package/src/cost/savings.js +243 -0
- package/src/cross-dispatcher.js +437 -0
- package/src/cross-orchestrator-cli.js +1885 -0
- package/src/cross-orchestrator.js +598 -0
- package/src/cross-project-search.js +114 -0
- package/src/dashboard-client.html +1180 -0
- package/src/dashboard-server.js +895 -0
- package/src/design-companion.js +81 -0
- package/src/dispatch/colon-syntax.js +732 -0
- package/src/dispatch-planner.js +235 -0
- package/src/dream/cooldown.js +105 -0
- package/src/dream/runner.mjs +373 -0
- package/src/dream/staleness-wiring.js +195 -0
- package/src/feedback-detector.js +57 -0
- package/src/hero-line.js +115 -0
- package/src/importers/claude-mem.js +152 -0
- package/src/importers/cli.js +311 -0
- package/src/importers/common.js +84 -0
- package/src/importers/discover.js +235 -0
- package/src/importers/rtk.js +107 -0
- package/src/intent-router.js +221 -0
- package/src/lib/atomic-io.js +201 -0
- package/src/lib/cache.js +33 -0
- package/src/lib/npm-view.js +104 -0
- package/src/lib/status-card.js +95 -0
- package/src/lib/token.js +85 -0
- package/src/memory/fts5.js +349 -0
- package/src/memory/migration-runner.js +116 -0
- package/src/memory/migrations/001-fts5-init.js +26 -0
- package/src/memory/migrations/002-tier-semantic.js +60 -0
- package/src/memory/migrations/003-stale-candidate.js +60 -0
- package/src/memory/reader.js +300 -0
- package/src/memory/recall-counter.js +76 -0
- package/src/memory/schema.sql +79 -0
- package/src/memory/search.js +431 -0
- package/src/memory/staleness.js +237 -0
- package/src/memory/tier-promotion.js +377 -0
- package/src/memory/tokenize.js +63 -0
- package/src/project-type-detector.js +866 -0
- package/src/prompt-check.js +171 -0
- package/src/ralph-allowlist.js +88 -0
- package/src/receipts.js +129 -0
- package/src/redactor.js +107 -0
- package/src/sandbox.js +275 -0
- package/src/sanitizer.js +69 -0
- package/src/scan-resume.js +167 -0
- package/src/schema.js +82 -0
- package/src/search-bm25.js +108 -0
- package/src/server.js +1414 -0
- package/src/swarm-config.js +80 -0
- package/src/trident/dispatch.js +211 -0
- package/src/trident/lens-health.js +253 -0
- package/src/update-apply.js +79 -0
- package/src/update-check.js +136 -0
- package/src/vectors.js +178 -0
- package/templates/design/bento-grid.md +84 -0
- package/templates/design/brutalist-luxe.md +82 -0
- package/templates/design/cinematic-dark.md +82 -0
- package/templates/design/data-dense-dashboard.md +88 -0
- package/templates/design/editorial-warm.md +81 -0
- package/templates/design/glassmorphic.md +84 -0
- package/templates/design/magazine-editorial.md +84 -0
- package/templates/design/maximalist-vibrant.md +85 -0
- package/templates/design/neo-swiss-tech.md +85 -0
- package/templates/design/swiss-minimal.md +80 -0
- package/templates/design/terminal-native.md +83 -0
- 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
|
+
}
|