@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,80 @@
1
+ // --- swarm.json schema + lazy init ---
2
+ //
3
+ // Knows what specialists belong on a project's swarm. On first use the
4
+ // orchestrator calls loadSwarmConfig(projectDir); the result is written to
5
+ // <projectDir>/.ijfw/swarm.json so the user can customize later.
6
+ //
7
+ // Never writes at require/install time. ESM. Zero external deps.
8
+
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ export const SCHEMA = {
13
+ project_type: 'string',
14
+ specialists: [{ id: 'string', role: 'string', agent_type: 'string' }],
15
+ };
16
+
17
+ const BASE = [
18
+ { id: 'reviewer', role: 'Code review', agent_type: 'code-reviewer' },
19
+ { id: 'reliability', role: 'Silent failures', agent_type: 'silent-failure-hunter' },
20
+ ];
21
+
22
+ const TESTS_SPECIALIST = { id: 'tests', role: 'Test coverage', agent_type: 'pr-test-analyzer' };
23
+ const TYPES_SPECIALIST = { id: 'types', role: 'Type invariants', agent_type: 'type-design-analyzer' };
24
+
25
+ export const DEFAULT_SPECIALISTS = {
26
+ node: [...BASE, TESTS_SPECIALIST],
27
+ python: [...BASE, TESTS_SPECIALIST],
28
+ typed: [...BASE, TESTS_SPECIALIST, TYPES_SPECIALIST],
29
+ go: [...BASE],
30
+ rust: [...BASE],
31
+ other: [...BASE],
32
+ };
33
+
34
+ // Detects project type from filesystem signals in projectDir.
35
+ // Returns 'node' | 'python' | 'go' | 'rust' | 'typed' | 'other'.
36
+ export function detectProjectType(projectDir) {
37
+ const has = (f) => existsSync(join(projectDir, f));
38
+
39
+ const isNode = has('package.json');
40
+ const isPython = has('pyproject.toml') || has('requirements.txt');
41
+ const isGo = has('go.mod');
42
+ const isRust = has('Cargo.toml');
43
+ const isTyped = has('tsconfig.json');
44
+
45
+ // TypeScript takes precedence: typed variant of node (or bare TS project).
46
+ if (isTyped) return 'typed';
47
+ if (isNode) return 'node';
48
+ if (isPython) return 'python';
49
+ if (isGo) return 'go';
50
+ if (isRust) return 'rust';
51
+ return 'other';
52
+ }
53
+
54
+ // Returns a fresh default config object for the given project type.
55
+ function buildDefault(projectType) {
56
+ const specialists = DEFAULT_SPECIALISTS[projectType] ?? DEFAULT_SPECIALISTS.other;
57
+ return { project_type: projectType, specialists: specialists.map(s => ({ ...s })) };
58
+ }
59
+
60
+ // Reads .ijfw/swarm.json if present, otherwise detects type, generates
61
+ // default config, persists it, and returns it.
62
+ export function loadSwarmConfig(projectDir) {
63
+ const swarmPath = join(projectDir, '.ijfw', 'swarm.json');
64
+
65
+ if (existsSync(swarmPath)) {
66
+ return JSON.parse(readFileSync(swarmPath, 'utf8'));
67
+ }
68
+
69
+ const projectType = detectProjectType(projectDir);
70
+ const config = buildDefault(projectType);
71
+
72
+ const ijfwDir = join(projectDir, '.ijfw');
73
+ if (!existsSync(ijfwDir)) mkdirSync(ijfwDir, { recursive: true });
74
+ writeFileSync(swarmPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
75
+
76
+ return config;
77
+ }
78
+
79
+ // Convenience alias used by orchestrator (matches the spec name).
80
+ export const getSwarmConfig = loadSwarmConfig;
@@ -0,0 +1,211 @@
1
+ // --- Trident audit dispatcher (C9.7) ---
2
+ //
3
+ // Orchestrates a 3-lens Trident audit with degraded-mode handling:
4
+ //
5
+ // 3/3 live -> full Trident, can produce PASS verdict.
6
+ // 2/3 live -> CONDITIONAL verdict possible; never auto-PASS.
7
+ // 1/3 live -> single-lens degraded; returns WARN/FLAG only. NEVER PASS.
8
+ // 0/3 live -> offline; throws DegradedTridentError unconditionally.
9
+ //
10
+ // Release-blocker gates (publish, tag, deploy) MUST pass `accept_degraded:
11
+ // true` to bypass single-lens guard. Otherwise the dispatcher throws
12
+ // DegradedTridentError so the calling release pipeline halts before doing
13
+ // anything irreversible.
14
+ //
15
+ // The actual lens execution is pluggable via `executor` so tests can stub
16
+ // without spawning real Codex/Gemini/Claude calls. Production executor lives
17
+ // alongside cross-orchestrator.js (existing surface).
18
+ //
19
+ // Zero external deps. ESM. No emoji.
20
+
21
+ import { probeLenses } from './lens-health.js';
22
+
23
+ // Custom error so callers can distinguish degraded-rejection from a normal
24
+ // audit failure. Carries the lens-health snapshot for diagnostic output.
25
+ export class DegradedTridentError extends Error {
26
+ constructor(message, { lensHealth, gate, requested_accept_degraded } = {}) {
27
+ super(message);
28
+ this.name = 'DegradedTridentError';
29
+ this.code = 'TRIDENT_DEGRADED';
30
+ this.lensHealth = lensHealth || null;
31
+ this.gate = gate || null;
32
+ this.requested_accept_degraded = !!requested_accept_degraded;
33
+ }
34
+ }
35
+
36
+ // Gates that block on degraded mode unless explicitly overridden.
37
+ // Kept in module scope so callers don't have to memorise the list.
38
+ export const RELEASE_BLOCKER_GATES = new Set([
39
+ 'publish',
40
+ 'tag',
41
+ 'deploy',
42
+ 'release',
43
+ ]);
44
+
45
+ // Severity ordering used to coerce verdicts down. Higher = more severe.
46
+ const VERDICT_RANK = {
47
+ PASS: 0,
48
+ CONDITIONAL: 1,
49
+ WARN: 2,
50
+ FLAG: 3,
51
+ FAIL: 4,
52
+ };
53
+
54
+ function rankOf(v) {
55
+ if (v == null) return VERDICT_RANK.WARN;
56
+ const up = String(v).toUpperCase();
57
+ return VERDICT_RANK[up] != null ? VERDICT_RANK[up] : VERDICT_RANK.WARN;
58
+ }
59
+
60
+ // Coerce a verdict so it never sits below `floor`. Used to enforce the
61
+ // single-lens NEVER-auto-PASS rule.
62
+ function coerceVerdict(verdict, floor) {
63
+ const r = rankOf(verdict);
64
+ const f = rankOf(floor);
65
+ if (r >= f) return String(verdict || floor).toUpperCase();
66
+ return String(floor).toUpperCase();
67
+ }
68
+
69
+ // Default executor for tests / stubs. Production callers pass their own.
70
+ // Receives ({ lens, brief }), returns { verdict, findings, latency_ms }.
71
+ async function defaultExecutor({ lens }) {
72
+ return {
73
+ lens,
74
+ verdict: 'PASS',
75
+ findings: [],
76
+ latency_ms: 0,
77
+ note: 'default-stub-executor',
78
+ };
79
+ }
80
+
81
+ // runTrident -- top-level orchestrator.
82
+ //
83
+ // opts:
84
+ // brief -- string, the audit target/prompt. Required.
85
+ // accept_degraded -- bool. Default false. If true, single-lens runs
86
+ // may return non-PASS verdicts but are still
87
+ // flagged with mode 'single-lens-accepted'.
88
+ // gate -- string. If in RELEASE_BLOCKER_GATES, degraded
89
+ // runs throw unless accept_degraded=true.
90
+ // executor -- ({lens,brief}) -> Promise<{verdict, findings, ...}>
91
+ // probeOpts -- forwarded to probeLenses (e.g. { fresh: true })
92
+ // which -- which lenses to probe (default all three)
93
+ //
94
+ // Returns:
95
+ // { verdict, mode, live_lenses, dead_lenses, lens_results, lens_health }
96
+ //
97
+ // `mode` values:
98
+ // 'full' -- 3/3 live, PASS allowed
99
+ // 'partial' -- 2/3 live, CONDITIONAL ceiling
100
+ // 'single-lens-degraded' -- 1/3 live, WARN ceiling (caller didn't accept)
101
+ // 'single-lens-accepted' -- 1/3 live, accept_degraded=true, non-PASS verdicts
102
+ // 'offline' -- 0/3 live (only reachable when not throwing)
103
+ export async function runTrident(opts = {}) {
104
+ const {
105
+ brief,
106
+ accept_degraded = false,
107
+ gate = null,
108
+ executor = defaultExecutor,
109
+ probeOpts = {},
110
+ which = { codex: true, gemini: true, claude: true },
111
+ } = opts;
112
+
113
+ if (!brief || typeof brief !== 'string') {
114
+ throw new Error('runTrident: `brief` (string) is required');
115
+ }
116
+
117
+ // 1. Probe lens health.
118
+ const lensHealth = await probeLenses(which, probeOpts);
119
+ const summary = lensHealth.summary || { live_count: 0, total: 0, live_lenses: [], dead_lenses: [], mode: 'offline' };
120
+ const live = summary.live_lenses;
121
+ const dead = summary.dead_lenses;
122
+
123
+ // 2. Release-blocker gating: degraded + release-blocker + no override -> throw.
124
+ const isReleaseBlocker = gate && RELEASE_BLOCKER_GATES.has(String(gate).toLowerCase());
125
+ const isDegraded = summary.live_count <= 1;
126
+
127
+ if (isReleaseBlocker && isDegraded && !accept_degraded) {
128
+ throw new DegradedTridentError(
129
+ `Trident is degraded (${summary.live_count}/${summary.total} lenses live: ${live.join(', ') || 'none'}). ` +
130
+ `Release-blocker gate "${gate}" rejects single-lens verdicts. Pass accept_degraded:true to override after human review.`,
131
+ { lensHealth, gate, requested_accept_degraded: accept_degraded }
132
+ );
133
+ }
134
+
135
+ // 3. Offline -> always throw, regardless of gate. No lenses = no audit.
136
+ if (summary.live_count === 0) {
137
+ throw new DegradedTridentError(
138
+ 'Trident offline: no lenses live. Cannot run audit.',
139
+ { lensHealth, gate, requested_accept_degraded: accept_degraded }
140
+ );
141
+ }
142
+
143
+ // 4. Run executor against each live lens in parallel.
144
+ const lensResults = await Promise.all(
145
+ live.map(async (lens) => {
146
+ try {
147
+ const r = await executor({ lens, brief });
148
+ return { lens, ok: true, ...r };
149
+ } catch (err) {
150
+ return {
151
+ lens,
152
+ ok: false,
153
+ verdict: 'FAIL',
154
+ findings: [],
155
+ error: err && err.message ? err.message : String(err),
156
+ };
157
+ }
158
+ })
159
+ );
160
+
161
+ // 5. Determine effective mode + verdict floor.
162
+ let mode;
163
+ let floor;
164
+ if (summary.live_count >= 3) {
165
+ mode = 'full';
166
+ floor = 'PASS';
167
+ } else if (summary.live_count === 2) {
168
+ mode = 'partial';
169
+ // 2/3 can return CONDITIONAL at best; PASS is reserved for 3/3.
170
+ floor = 'CONDITIONAL';
171
+ } else { // single-lens
172
+ if (accept_degraded) {
173
+ mode = 'single-lens-accepted';
174
+ // Caller accepted reduced confidence. Verdict floor = CONDITIONAL.
175
+ // PASS is still impossible -- one lens cannot produce 3-lens consensus.
176
+ floor = 'CONDITIONAL';
177
+ } else {
178
+ mode = 'single-lens-degraded';
179
+ // Hard ceiling: WARN. Never PASS, never CONDITIONAL.
180
+ floor = 'WARN';
181
+ }
182
+ }
183
+
184
+ // 6. Aggregate verdict: take the max severity reported by any lens, then
185
+ // coerce up to the floor for the current mode.
186
+ let aggregateVerdict = 'PASS';
187
+ for (const r of lensResults) {
188
+ if (rankOf(r.verdict) > rankOf(aggregateVerdict)) {
189
+ aggregateVerdict = String(r.verdict || 'WARN').toUpperCase();
190
+ }
191
+ }
192
+ const finalVerdict = coerceVerdict(aggregateVerdict, floor);
193
+
194
+ return {
195
+ verdict: finalVerdict,
196
+ mode,
197
+ live_lenses: live,
198
+ dead_lenses: dead,
199
+ lens_results: lensResults,
200
+ lens_health: lensHealth,
201
+ accept_degraded: !!accept_degraded,
202
+ gate: gate || null,
203
+ };
204
+ }
205
+
206
+ // Convenience: surface a one-line health string for narration.
207
+ export function summariseLensHealth(lensHealth) {
208
+ const s = lensHealth && lensHealth.summary;
209
+ if (!s) return 'lens health: unknown';
210
+ return `lens health: ${s.live_count}/${s.total} live (${s.live_lenses.join(', ') || 'none'})`;
211
+ }
@@ -0,0 +1,253 @@
1
+ // --- Trident lens-health (C9.7) ---
2
+ //
3
+ // Lightweight liveness probes for the three Trident lineages.
4
+ //
5
+ // codex -- runs `codex --version` (no API call, fast).
6
+ // gemini -- runs `gemini --version`.
7
+ // claude -- always live: this code IS the Anthropic-lineage caller.
8
+ //
9
+ // Per C9.7 the probe must stay fast and cheap. We never gate on actual API
10
+ // reachability here -- a `--version` exit-0 means the CLI binary is on PATH
11
+ // and responsive. Quota / auth failures surface later in the dispatcher when
12
+ // the lens actually fires.
13
+ //
14
+ // Cache: results are memoised for 60s by default. Callers can force a fresh
15
+ // probe via { fresh: true } or change the TTL via { ttlMs }.
16
+ //
17
+ // Zero external deps. ESM. No emoji. LC_ALL=C.
18
+
19
+ import { spawn } from 'node:child_process';
20
+
21
+ // Per-process cache. Reset by tests via _resetCache().
22
+ const _cache = new Map(); // lens id -> { result, ts }
23
+
24
+ // Default cache TTL (ms). Overridable per-call.
25
+ export const DEFAULT_TTL_MS = 60_000;
26
+
27
+ // Default per-probe timeout (ms). `--version` for codex returns ~50-100ms;
28
+ // gemini's CLI cold-start can take 400-600ms before printing the version
29
+ // (Node startup + module load). 1500ms covers gemini cold-start with margin
30
+ // while staying ~80x cheaper than an actual audit call. Override per-call
31
+ // via { timeoutMs }.
32
+ export const PROBE_TIMEOUT_MS = 1500;
33
+
34
+ // Map a lens id to the binary + args used for liveness probing.
35
+ // `--version` is universal across CLI tools; exit 0 means alive.
36
+ const PROBE_COMMAND = {
37
+ codex: { bin: 'codex', args: ['--version'] },
38
+ gemini: { bin: 'gemini', args: ['--version'] },
39
+ };
40
+
41
+ // runProbe -- spawn a single probe child process with a hard timeout.
42
+ // Resolves with { live: bool, latency_ms: number, error: string|null }.
43
+ // Never throws; spawn errors / timeouts collapse into { live: false, error }.
44
+ function runProbe(bin, args, timeoutMs) {
45
+ return new Promise((resolve) => {
46
+ const t0 = Date.now();
47
+ let settled = false;
48
+ const settle = (val) => { if (settled) return; settled = true; resolve(val); };
49
+
50
+ let proc;
51
+ const timer = setTimeout(() => {
52
+ if (proc) {
53
+ try { proc.kill('SIGKILL'); } catch { /* ignore */ }
54
+ try { if (proc.stdout) proc.stdout.destroy(); } catch { /* ignore */ }
55
+ try { if (proc.stderr) proc.stderr.destroy(); } catch { /* ignore */ }
56
+ }
57
+ settle({ live: false, latency_ms: Date.now() - t0, error: 'timeout' });
58
+ }, timeoutMs);
59
+
60
+ try {
61
+ // LC_ALL=C: deterministic, locale-independent output. We don't read the
62
+ // version string itself -- we only care about exit 0 -- but pinning the
63
+ // locale costs nothing and matches the rest of the codebase.
64
+ proc = spawn(bin, args, {
65
+ stdio: ['ignore', 'pipe', 'pipe'],
66
+ env: { ...process.env, LC_ALL: 'C' },
67
+ });
68
+ } catch (err) {
69
+ clearTimeout(timer);
70
+ settle({ live: false, latency_ms: Date.now() - t0, error: 'spawn_error: ' + (err && err.message ? err.message : String(err)) });
71
+ return;
72
+ }
73
+
74
+ // Drain stdio so the child doesn't hang on a full pipe.
75
+ proc.stdout.on('data', () => { /* discard */ });
76
+ proc.stderr.on('data', () => { /* discard */ });
77
+
78
+ proc.on('error', (err) => {
79
+ clearTimeout(timer);
80
+ // ENOENT -> binary not on PATH. Treat as not live, not as a probe error.
81
+ if (err && err.code === 'ENOENT') {
82
+ settle({ live: false, latency_ms: Date.now() - t0, error: 'not_installed' });
83
+ } else {
84
+ settle({ live: false, latency_ms: Date.now() - t0, error: 'spawn_error: ' + (err && err.message ? err.message : String(err)) });
85
+ }
86
+ });
87
+
88
+ proc.on('close', (code) => {
89
+ clearTimeout(timer);
90
+ const latency_ms = Date.now() - t0;
91
+ if (code === 0) {
92
+ settle({ live: true, latency_ms, error: null });
93
+ } else {
94
+ settle({ live: false, latency_ms, error: `exit_${code}` });
95
+ }
96
+ });
97
+ });
98
+ }
99
+
100
+ // probeOne -- public single-lens probe with cache.
101
+ //
102
+ // lens -- 'codex' | 'gemini' | 'claude'
103
+ // opts -- { fresh, ttlMs, timeoutMs }
104
+ //
105
+ // Returns the same shape stored in the cache.
106
+ export async function probeOne(lens, opts = {}) {
107
+ if (lens === 'claude') {
108
+ // We ARE the Anthropic lineage. No external probe needed; the dispatcher
109
+ // is running in this process. Always live.
110
+ const result = { live: true, latency_ms: 0, error: null, source: 'in_process' };
111
+ return result;
112
+ }
113
+
114
+ const cmd = PROBE_COMMAND[lens];
115
+ if (!cmd) {
116
+ return { live: false, latency_ms: 0, error: 'unknown_lens', source: 'invalid' };
117
+ }
118
+
119
+ const ttlMs = typeof opts.ttlMs === 'number' ? opts.ttlMs : DEFAULT_TTL_MS;
120
+ const fresh = Boolean(opts.fresh);
121
+ const timeoutMs = typeof opts.timeoutMs === 'number' ? opts.timeoutMs : PROBE_TIMEOUT_MS;
122
+ const now = Date.now();
123
+
124
+ if (!fresh) {
125
+ const cached = _cache.get(lens);
126
+ if (cached && (now - cached.ts) < ttlMs) {
127
+ return { ...cached.result, source: 'cache', cached_age_ms: now - cached.ts };
128
+ }
129
+ }
130
+
131
+ const result = await runProbe(cmd.bin, cmd.args, timeoutMs);
132
+ _cache.set(lens, { result, ts: Date.now() });
133
+ return { ...result, source: 'probe' };
134
+ }
135
+
136
+ // probeLenses -- run liveness checks across multiple lenses in parallel.
137
+ //
138
+ // { codex: bool, gemini: bool, claude: bool } -- which lenses to probe.
139
+ // opts is forwarded to probeOne (fresh, ttlMs, timeoutMs).
140
+ //
141
+ // Default: probe all three. Returns:
142
+ // {
143
+ // codex: { live, latency_ms, error, source } (only if requested)
144
+ // gemini: { live, latency_ms, error, source } (only if requested)
145
+ // claude: { live: true, ... } (only if requested)
146
+ // summary: { live_count, total, live_lenses, dead_lenses, mode }
147
+ // }
148
+ // where mode is one of: 'full' (3/3), 'partial' (2/3), 'degraded' (1/3),
149
+ // 'offline' (0/3).
150
+ export async function probeLenses(which = { codex: true, gemini: true, claude: true }, opts = {}) {
151
+ const targets = [];
152
+ if (which.codex) targets.push('codex');
153
+ if (which.gemini) targets.push('gemini');
154
+ if (which.claude) targets.push('claude');
155
+
156
+ const results = await Promise.all(targets.map(t => probeOne(t, opts)));
157
+
158
+ const out = {};
159
+ targets.forEach((t, i) => { out[t] = results[i]; });
160
+
161
+ const live_lenses = targets.filter((t) => out[t] && out[t].live);
162
+ const dead_lenses = targets.filter((t) => !(out[t] && out[t].live));
163
+ const mode = computeMode(live_lenses.length, targets.length);
164
+
165
+ out.summary = {
166
+ live_count: live_lenses.length,
167
+ total: targets.length,
168
+ live_lenses,
169
+ dead_lenses,
170
+ mode,
171
+ probed_at: Date.now(),
172
+ };
173
+ return out;
174
+ }
175
+
176
+ // Mode classifier. Used by dashboard tile + dispatcher gating.
177
+ function computeMode(live, total) {
178
+ if (live === total && total >= 3) return 'full';
179
+ if (live === total && total === 2) return 'partial';
180
+ if (live === total && total === 1) return 'degraded';
181
+ if (live === 0) return 'offline';
182
+ if (live >= 2) return 'partial';
183
+ if (live === 1) return 'degraded';
184
+ return 'offline';
185
+ }
186
+
187
+ // Health-tile shape: per-lens last-probe, plus an alert flag if any lens
188
+ // has been dead for longer than alertThresholdMs (default 24h).
189
+ export function healthTileShape(probeResult, opts = {}) {
190
+ const alertThresholdMs = typeof opts.alertThresholdMs === 'number'
191
+ ? opts.alertThresholdMs
192
+ : 24 * 60 * 60 * 1000; // 24h
193
+ const now = Date.now();
194
+
195
+ const lenses = ['codex', 'gemini', 'claude'].filter(l => probeResult[l]);
196
+ const tile = {
197
+ mode: probeResult.summary && probeResult.summary.mode,
198
+ live_count: probeResult.summary && probeResult.summary.live_count,
199
+ total: probeResult.summary && probeResult.summary.total,
200
+ lenses: {},
201
+ alert: false,
202
+ alert_reason: null,
203
+ };
204
+
205
+ // Track dead-since timestamps in cache so we can compute duration.
206
+ for (const l of lenses) {
207
+ const r = probeResult[l];
208
+ const live = !!(r && r.live);
209
+ const deadSince = _deadSinceTracker.get(l);
210
+
211
+ if (!live) {
212
+ if (!deadSince) {
213
+ _deadSinceTracker.set(l, now);
214
+ }
215
+ const since = _deadSinceTracker.get(l);
216
+ const duration_ms = now - since;
217
+ tile.lenses[l] = {
218
+ live: false,
219
+ latency_ms: r ? r.latency_ms : null,
220
+ error: r ? r.error : null,
221
+ dead_since_ms: since,
222
+ dead_for_ms: duration_ms,
223
+ };
224
+ if (duration_ms >= alertThresholdMs) {
225
+ tile.alert = true;
226
+ tile.alert_reason = `${l} dead for ${Math.floor(duration_ms / 3_600_000)}h`;
227
+ }
228
+ } else {
229
+ _deadSinceTracker.delete(l);
230
+ tile.lenses[l] = {
231
+ live: true,
232
+ latency_ms: r.latency_ms,
233
+ error: null,
234
+ };
235
+ }
236
+ }
237
+ return tile;
238
+ }
239
+
240
+ // Outage-duration tracker: lens id -> first-seen-dead timestamp.
241
+ const _deadSinceTracker = new Map();
242
+
243
+ // Test hooks. Not part of the stable API.
244
+ export function _resetCache() {
245
+ _cache.clear();
246
+ _deadSinceTracker.clear();
247
+ }
248
+ export function _setCache(lens, result, ts = Date.now()) {
249
+ _cache.set(lens, { result, ts });
250
+ }
251
+ export function _setDeadSince(lens, ts) {
252
+ _deadSinceTracker.set(lens, ts);
253
+ }
@@ -0,0 +1,79 @@
1
+ // MCP tool: ijfw_update_apply
2
+ //
3
+ // Does NOT execute the update. Validates the token, writes (or overwrites)
4
+ // the pending sentinel, returns instruction telling the user to run the
5
+ // terminal-side confirm command. Idempotent against a matching sentinel
6
+ // already written by ijfw_update_check -- the sentinel + token are the
7
+ // same artifact, so re-writing with the same values is a no-op.
8
+ // Air-gaps the MCP path from actual code execution -- per v3 sec 16 blocker fix.
9
+
10
+ import { validateToken, writePendingSentinel } from './lib/token.js';
11
+ import { isVersionStringValid } from './lib/npm-view.js';
12
+
13
+ export function ijfwUpdateApply(args = {}) {
14
+ const { target_version, confirmation_token } = args || {};
15
+ const sessionId = args.session_id || process.env.IJFW_SESSION_ID || 'default-session';
16
+
17
+ if (!target_version || !isVersionStringValid(target_version)) {
18
+ return {
19
+ status: 'error',
20
+ message: 'target_version is required and must be a valid semver string',
21
+ };
22
+ }
23
+ if (!confirmation_token || typeof confirmation_token !== 'string') {
24
+ return {
25
+ status: 'error',
26
+ message:
27
+ 'confirmation_token is required. Run ijfw_update_check first to receive a token, then ' +
28
+ "the user must run 'ijfw update --confirm <token>' in their TERMINAL to proceed.",
29
+ };
30
+ }
31
+
32
+ const v = validateToken(sessionId, confirmation_token);
33
+ if (!v.ok) {
34
+ return {
35
+ status: 'error',
36
+ reason: v.error,
37
+ message:
38
+ v.error === 'expired' ? 'Token expired. Re-run ijfw_update_check to issue a fresh one.' :
39
+ v.error === 'mismatch' ? 'Token mismatch. The token must match the one issued by the most recent ijfw_update_check.' :
40
+ v.error === 'already-consumed' ? 'Token already consumed -- the update either ran or was attempted.' :
41
+ 'No active token. Run ijfw_update_check first.',
42
+ };
43
+ }
44
+ if (v.target_version !== target_version) {
45
+ return {
46
+ status: 'error',
47
+ reason: 'target-mismatch',
48
+ message: `Token was issued for v${v.target_version}, not v${target_version}. Re-run ijfw_update_check.`,
49
+ };
50
+ }
51
+
52
+ const path = writePendingSentinel(sessionId, target_version, confirmation_token);
53
+
54
+ return {
55
+ status: 'pending_user_confirmation',
56
+ target_version,
57
+ sentinel_path: path,
58
+ instruction:
59
+ `Run in your TERMINAL: ijfw update --confirm ${confirmation_token}\n` +
60
+ `This MCP tool cannot execute the update -- only a terminal command can.`,
61
+ };
62
+ }
63
+
64
+ export const TOOL_DEF = {
65
+ name: 'ijfw_update_apply',
66
+ description:
67
+ 'Stage an IJFW update behind out-of-band terminal confirmation. Writes a pending sentinel; ' +
68
+ "actual update only runs when the user types 'ijfw update --confirm <token>' in their terminal. " +
69
+ 'This MCP tool NEVER executes the update directly.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ required: ['target_version', 'confirmation_token'],
73
+ properties: {
74
+ target_version: { type: 'string', description: 'Target semver to install' },
75
+ confirmation_token: { type: 'string', description: 'Token from ijfw_update_check' },
76
+ session_id: { type: 'string', description: 'Session ID for token scoping (optional)' },
77
+ },
78
+ },
79
+ };