@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,1885 @@
1
+ // cross-orchestrator-cli.js -- thin CLI for `ijfw cross <mode> <target>`.
2
+ //
3
+ // Commands:
4
+ // ijfw cross <mode> <target> [--confirm] [--with <id>] [--expand]
5
+ // ijfw status
6
+ // ijfw --help
7
+ //
8
+ // Zero external deps. Parse argv manually.
9
+
10
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, statSync, openSync, readSync, closeSync, readdirSync, rmSync, realpathSync } from 'node:fs';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+ import { join, dirname, basename, isAbsolute, resolve } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { writeAtomic } from './lib/atomic-io.js';
16
+ import { runCrossOp } from './cross-orchestrator.js';
17
+ import { readReceipts, purgeReceipts } from './receipts.js';
18
+ import { renderHeroLine } from './hero-line.js';
19
+ import { ROSTER, isInstalled, isReachable } from './audit-roster.js';
20
+ import { aggregatePortfolioFindings } from './cross-project-search.js';
21
+ import { runImport, runImportAll, listImporters } from './importers/cli.js';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Auditor error translator (1.2.5)
25
+ // ---------------------------------------------------------------------------
26
+ // Pattern-match common auditor failure signatures and turn the raw stderr
27
+ // into one actionable sentence. Returns a short string the degraded-auditor
28
+ // surface in cmdCross renders directly.
29
+ //
30
+ // Order matters: more specific patterns come first so a generic "401" or
31
+ // "timeout" doesn't shadow a tool-specific re-auth instruction.
32
+ export function translateAuditorError(id, status, stderr, exitCode) {
33
+ const s = String(stderr || '');
34
+ const lower = s.toLowerCase();
35
+
36
+ // Tool-specific re-auth signatures (highest specificity)
37
+ if (id === 'codex' && (
38
+ /failed to refre/i.test(s) ||
39
+ /codex_models_manager/i.test(s) ||
40
+ /token.*(expired|invalid)/i.test(s)
41
+ )) {
42
+ return 'Codex auth token expired or stale. Run `codex login` to refresh, then re-run.';
43
+ }
44
+ if (id === 'qwen' && /no auth type is selected/i.test(s)) {
45
+ return 'No auth configured. Run `qwen auth` and pick a provider, then re-run.';
46
+ }
47
+ if (id === 'gemini' && (/safety/i.test(s) || /blocked/i.test(s) || /BLOCKED_BY_SAFETY/i.test(s))) {
48
+ return 'Safety filter blocked output -- may be a false negative on this target. Try a different file or pass --with to swap auditor.';
49
+ }
50
+ if (id === 'claude' && /credit balance.*too low/i.test(lower)) {
51
+ return 'Anthropic credits low. Top up at console.anthropic.com or use the CLI path instead of API.';
52
+ }
53
+
54
+ // Status-driven generic patterns
55
+ if (status === 'timeout' || /(operation was )?aborted due to timeout/i.test(s) || /etimedout/i.test(lower)) {
56
+ return 'API timed out. Check network, retry, or pass --with to drop this auditor on the next run.';
57
+ }
58
+
59
+ // Generic auth signatures
60
+ if (/\b(401|403)\b/.test(s) || /unauthorized/i.test(s) || /forbidden/i.test(s)) {
61
+ return `Authentication rejected (HTTP 401/403). Re-check the API key for ${id}, then re-run.`;
62
+ }
63
+ if (/(api[_ ]?key|auth(env)?)\s+not\s+set/i.test(s)) {
64
+ return `API key not set. Export the auth env var for ${id} in your shell, then re-run.`;
65
+ }
66
+ if (/\b429\b/.test(s) || /rate[ -]?limit/i.test(lower) || /quota/i.test(lower)) {
67
+ return 'Rate-limited or quota exhausted. Wait a minute or top up your provider, then re-run.';
68
+ }
69
+ if (/enotfound|econnrefused|getaddrinfo/i.test(s)) {
70
+ return 'Network unreachable. Check connectivity, then re-run.';
71
+ }
72
+
73
+ // Empty + stderr usually means model emitted prose without the JSON fence.
74
+ if (status === 'empty') {
75
+ return 'Returned no parseable findings (model may have emitted prose without the JSON fence). Re-run or pass --with to swap auditor.';
76
+ }
77
+
78
+ // Spawn / exec issues
79
+ if (/spawn .* enoent/i.test(lower) || /command not found/i.test(lower)) {
80
+ return `CLI binary not found on PATH. Install ${id} or set its API key to use the API fallback.`;
81
+ }
82
+
83
+ // Catch-all: short raw, but suffix the right action
84
+ const head = s.split('\n')[0].slice(0, 80) || `exit ${exitCode}`;
85
+ return `Failed (${head}). Run \`ijfw doctor\` to diagnose, or pass --with to drop this auditor.`;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Findings printer
90
+ // ---------------------------------------------------------------------------
91
+ function printFindings(mode, merged) {
92
+ if (mode === 'audit') {
93
+ const items = Array.isArray(merged) ? merged : [];
94
+ if (items.length === 0) {
95
+ console.log(' Auditors returned no findings -- your target looks solid.');
96
+ console.log(' Run `ijfw cross audit <another-file>` to audit a different target.');
97
+ return;
98
+ }
99
+ console.log('');
100
+ items.forEach((item, i) => {
101
+ const sev = item.severity ? ` [${item.severity}]` : '';
102
+ const loc = item.location ? ` | ${item.location}` : '';
103
+ const issue = String(item.issue || '');
104
+ console.log(` Step 1.${i + 1} --${sev}${loc} -- ${issue}`);
105
+ });
106
+ return;
107
+ }
108
+
109
+ if (mode === 'research') {
110
+ const { consensus = [], contested = [], synthesisPending } = merged || {};
111
+ console.log('');
112
+ console.log(` Consensus: ${consensus.length} | Contested: ${contested.length}`);
113
+ if (synthesisPending) console.log(' Note: synthesis pass pending -- lexical match only.');
114
+ consensus.slice(0, 5).forEach((item, i) => {
115
+ console.log(` Step 1.${i + 1} -- [consensus] ${String(item.claim || '')}`);
116
+ });
117
+ contested.slice(0, 3).forEach((item, i) => {
118
+ console.log(` Step 2.${i + 1} -- [contested] ${String(item.claim || '')}`);
119
+ });
120
+ return;
121
+ }
122
+
123
+ if (mode === 'critique') {
124
+ const items = Array.isArray(merged) ? merged : [];
125
+ if (items.length === 0) {
126
+ console.log(' No counter-arguments surfaced -- argument appears well-supported.');
127
+ console.log(' Run `ijfw cross critique <another-target>` to challenge a different position.');
128
+ return;
129
+ }
130
+ console.log('');
131
+ items.forEach((item, i) => {
132
+ const sev = item.severity ? ` [${item.severity}]` : '';
133
+ const arg = String(item.counterArg || '');
134
+ console.log(` Step 1.${i + 1} --${sev} ${arg}`);
135
+ });
136
+ return;
137
+ }
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Arg parser
142
+ // ---------------------------------------------------------------------------
143
+ // True when the caller wants machine-readable output: explicit --json flag,
144
+ // or stdout is not a TTY (gh-CLI convention -- piped/redirected output is
145
+ // presumed to be consumed by another process, including sub-agents shelling
146
+ // out via bash).
147
+ function wantsJson(opts) {
148
+ return Boolean(opts && opts.json) || !process.stdout.isTTY;
149
+ }
150
+
151
+ function emitJson(value) {
152
+ process.stdout.write(JSON.stringify(value, null, 2) + '\n');
153
+ }
154
+
155
+ function parseArgs(argv) {
156
+ const args = argv.slice(2); // strip node + script path
157
+
158
+ // Global --json flag: any command can be forced to JSON output regardless
159
+ // of TTY. Strip it before per-command parsing so it doesn't confuse
160
+ // positional argument handling.
161
+ let json = false;
162
+ const rest = [];
163
+ for (const a of args) {
164
+ if (a === '--json') json = true;
165
+ else rest.push(a);
166
+ }
167
+ const out = parseArgsInner(rest);
168
+ if (out && typeof out === 'object') out.json = json;
169
+ return out;
170
+ }
171
+
172
+ function parseArgsInner(args) {
173
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
174
+ return { cmd: 'help' };
175
+ }
176
+
177
+ if (args[0] === '--version' || args[0] === '-v') {
178
+ return { cmd: 'version', verbose: args.includes('--verbose') };
179
+ }
180
+
181
+ if (args[0] === 'version') {
182
+ return { cmd: 'version', verbose: args.includes('--verbose') };
183
+ }
184
+
185
+ if (args[0] === 'help') {
186
+ return { cmd: 'guide', browser: args.includes('--browser') };
187
+ }
188
+
189
+ if (args[0] === 'status') {
190
+ return { cmd: 'status' };
191
+ }
192
+
193
+ if (args[0] === 'demo') {
194
+ return { cmd: 'demo' };
195
+ }
196
+
197
+ if (args[0] === 'doctor') {
198
+ return { cmd: 'doctor' };
199
+ }
200
+
201
+ if (args[0] === 'update') {
202
+ const opts = { cmd: 'update' };
203
+ for (let i = 1; i < args.length; i++) {
204
+ const a = args[i];
205
+ if (a === '--check') opts.check = true;
206
+ else if (a === '--yes' || a === '-y') opts.yes = true;
207
+ else if (a === '--verify') opts.verify = true;
208
+ else if (a === '--changelog') opts.changelog = true;
209
+ else if (a === '--confirm' && args[i + 1]) { opts.confirm = args[++i]; }
210
+ else if (a === '--auto' && args[i + 1]) { opts.auto = args[++i]; }
211
+ }
212
+ return opts;
213
+ }
214
+
215
+ if (args[0] === 'statusline') {
216
+ const opts = { cmd: 'statusline' };
217
+ for (let i = 1; i < args.length; i++) {
218
+ const a = args[i];
219
+ if (a === '--install') opts.sub = 'install';
220
+ else if (a === '--compose') opts.sub = 'compose';
221
+ else if (a === '--disable') opts.sub = 'disable';
222
+ else if (a === '--status') opts.sub = 'status';
223
+ else if (a === '--recompute') opts.sub = 'recompute';
224
+ }
225
+ if (!opts.sub) opts.sub = 'status';
226
+ return opts;
227
+ }
228
+
229
+ if (args[0] === 'config') {
230
+ const opts = { cmd: 'config' };
231
+ for (let i = 1; i < args.length; i++) {
232
+ if (args[i] === '--audit') opts.sub = 'audit';
233
+ }
234
+ return opts;
235
+ }
236
+
237
+ if (args[0] === 'insight') {
238
+ return { cmd: 'insight', sub: args[1] || 'start' };
239
+ }
240
+
241
+ if (args[0] === 'install') {
242
+ return { cmd: 'install' };
243
+ }
244
+
245
+ if (args[0] === 'uninstall' || (args[0] === 'off' && args.length === 1)) {
246
+ return { cmd: 'uninstall' };
247
+ }
248
+
249
+ if (args[0] === 'preflight') {
250
+ return { cmd: 'preflight' };
251
+ }
252
+
253
+ if (args[0] === 'dashboard') {
254
+ return { cmd: 'dashboard', sub: args[1] || 'status' };
255
+ }
256
+
257
+ if (args[0] === 'receipt') {
258
+ return { cmd: 'receipt', sub: args[1] || 'last' };
259
+ }
260
+
261
+ if (args[0] === '--purge-receipts') {
262
+ return { cmd: 'purge-receipts' };
263
+ }
264
+
265
+ if (args[0] === 'import') {
266
+ const tool = args[1];
267
+ let dryRun = false, force = false, includeMetrics = false, customPath = null, allMode = false, yes = false;
268
+ for (let i = 2; i < args.length; i++) {
269
+ if (args[i] === '--dry-run') dryRun = true;
270
+ else if (args[i] === '--force') force = true;
271
+ else if (args[i] === '--all') allMode = true;
272
+ else if (args[i] === '--yes' || args[i] === '-y') yes = true;
273
+ else if (args[i] === '--include-metrics') includeMetrics = true;
274
+ else if (args[i] === '--path' && args[i + 1]) customPath = args[++i];
275
+ }
276
+ return { cmd: 'import', tool, dryRun, force, includeMetrics, customPath, allMode, yes };
277
+ }
278
+
279
+ if (args[0] === 'cross') {
280
+ const mode = args[1];
281
+
282
+ if (mode === 'project-audit') {
283
+ const rule = args[2];
284
+ let dryRun = false;
285
+ for (let i = 3; i < args.length; i++) {
286
+ if (args[i] === '--dry-run') dryRun = true;
287
+ }
288
+ return { cmd: 'cross-project-audit', rule, dryRun };
289
+ }
290
+
291
+ const target = args[2];
292
+ let only = null;
293
+ let confirm = false;
294
+ let expand = false;
295
+
296
+ for (let i = 3; i < args.length; i++) {
297
+ if (args[i] === '--confirm') { confirm = true; }
298
+ else if (args[i] === '--expand') { expand = true; }
299
+ else if (args[i] === '--with' && args[i + 1]) { only = args[++i]; }
300
+ }
301
+
302
+ return { cmd: 'cross', mode, target, only, confirm, expand };
303
+ }
304
+
305
+ return { cmd: 'unknown', raw: args[0] };
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Commands
310
+ // ---------------------------------------------------------------------------
311
+
312
+ function printUsage() {
313
+ console.log(`
314
+ ijfw -- It Just Fucking Works CLI
315
+ Fire 2-4 AIs at any target. Receipts logged. Cache hits tracked. Memory follows you.
316
+
317
+ Usage:
318
+ ijfw install
319
+ ijfw uninstall
320
+ ijfw preflight
321
+ ijfw dashboard [start|stop|status]
322
+ ijfw cross <mode> <target> [options]
323
+ ijfw cross project-audit <rule-file> [--dry-run]
324
+ ijfw import <tool> [--all] [--dry-run] [--force] [--path <p>]
325
+ ijfw status
326
+ ijfw doctor
327
+ ijfw update
328
+ ijfw receipt last
329
+ ijfw --purge-receipts
330
+ ijfw --help
331
+
332
+ Commands:
333
+ install Install IJFW into your AI coding agents.
334
+ uninstall Remove IJFW and revert AI-agent configs. Same as: ijfw off
335
+ preflight Run the 12-gate quality pipeline (blocking + advisory).
336
+ dashboard Control the dashboard server (start, stop, status).
337
+ demo 30-second live tour of the Trident (fires real auditors).
338
+ cross Fire external auditors at a target. Try: ijfw cross audit README.md
339
+ import Pull memory in from another tool. Try: ijfw import claude-mem --all
340
+ status Show recent cross-audit activity. Try: ijfw status
341
+ doctor Probe which CLIs and API keys are reachable. Try: ijfw doctor
342
+ update Pull latest IJFW + reinstall merge-safely. Try: ijfw update
343
+ update --check Non-invasive check. Exits 0 always; prints "update-available: <ver>" when an update exists (grep-safe).
344
+ receipt last Print a redacted, shareable block from the last Trident run.
345
+ --purge-receipts Clear the cross-runs receipt log. Try: ijfw --purge-receipts
346
+
347
+ Modes (for ijfw cross):
348
+ audit Adversarial review of a file, module, or path
349
+ research Multi-source research on a topic
350
+ critique Structured counter-argument generation
351
+ project-audit Run the same audit across every registered IJFW project
352
+ Usage: ijfw cross project-audit <rule-file> [--dry-run]
353
+
354
+ Options for ijfw cross:
355
+ --with <id> Force a specific auditor (comma-separated for multiple)
356
+ --confirm Prompt for confirmation before firing
357
+ --expand Include extended swarm when available
358
+
359
+ Global flags:
360
+ --json Emit JSON instead of human output. status and doctor auto-JSON
361
+ on non-TTY (gh-CLI convention); version stays one-line on pipe
362
+ and only JSON-ifies with explicit --json. Other commands ignore.
363
+
364
+ Environment:
365
+ IJFW_AUDIT_BUDGET_USD Session spend cap (default $2.00). First call is always
366
+ allowed (no cap). Cap enforced from the 2nd call on.
367
+
368
+ Examples:
369
+ ijfw demo
370
+ ijfw cross audit README.md
371
+ ijfw cross research "vector search approaches"
372
+ ijfw cross critique HEAD~3..HEAD
373
+ ijfw cross audit CLAUDE.md --with codex,gemini
374
+ ijfw status
375
+ ijfw doctor
376
+ `.trim());
377
+ }
378
+
379
+ async function cmdStatus(projectDir, opts = {}) {
380
+ const receipts = readReceipts(projectDir);
381
+ const last = receipts[receipts.length - 1];
382
+
383
+ if (wantsJson(opts)) {
384
+ emitJson({
385
+ runs: receipts.length,
386
+ last: last ? { mode: last.mode || 'cross', timestamp: last.timestamp || null } : null,
387
+ hero: receipts.length > 0 ? renderHeroLine(receipts) : null,
388
+ });
389
+ return;
390
+ }
391
+
392
+ if (receipts.length === 0) {
393
+ console.log('No cross-audit runs recorded yet.');
394
+ console.log('Recommended next: `ijfw cross audit <file>` to run your first Trident audit.');
395
+ return;
396
+ }
397
+ const hero = renderHeroLine(receipts);
398
+ const mode = last?.mode || 'cross';
399
+ const ts = last?.timestamp ? last.timestamp.slice(0, 10) : '';
400
+ console.log(`Trident -- run ${receipts.length} -- ${mode}${ts ? ' (' + ts + ')' : ''}`);
401
+ console.log('--');
402
+ console.log(hero);
403
+ console.log('--');
404
+ console.log(`${receipts.length} Trident run${receipts.length === 1 ? '' : 's'} on record.`);
405
+ console.log('Recommended next: `ijfw cross audit <file>`. Say no/alt to override.');
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // Demo
410
+ // ---------------------------------------------------------------------------
411
+
412
+ // Pre-flight: return true if any auditor is reachable via CLI or API key.
413
+ function _anyAuditorReachable() {
414
+ for (const entry of ROSTER) {
415
+ try {
416
+ if (isReachable(entry.id, process.env).any) return true;
417
+ } catch {
418
+ if (isInstalled(entry.id)) return true;
419
+ }
420
+ }
421
+ return false;
422
+ }
423
+
424
+ function _printDemoFindings(picks, auditorResults) {
425
+ const attributed = [];
426
+
427
+ for (let i = 0; i < picks.length; i++) {
428
+ const { status, parsed } = auditorResults[i];
429
+ const id = picks[i].id;
430
+ const capitalized = id.charAt(0).toUpperCase() + id.slice(1);
431
+ const items = Array.isArray(parsed?.items) ? parsed.items : [];
432
+ const hasFindings = (status === 'ok' || status === 'fallback-used') && items.length > 0;
433
+ if (!hasFindings) {
434
+ console.log(` ${capitalized}: no findings returned (status: ${status})`);
435
+ continue;
436
+ }
437
+ for (const item of items) {
438
+ const issue = String(item.issue || '').slice(0, 80);
439
+ const sev = item.severity ? ` [${item.severity}]` : '';
440
+ console.log(` ${capitalized} found:${sev} ${issue}`);
441
+ attributed.push({ id, item });
442
+ }
443
+ }
444
+ return attributed;
445
+ }
446
+
447
+ async function cmdDemo() {
448
+ const reachable = _anyAuditorReachable();
449
+ if (!reachable) {
450
+ console.log('No auditors reachable yet.');
451
+ console.log('Install codex or gemini, or set OPENAI_API_KEY / GEMINI_API_KEY, then run `ijfw demo`.');
452
+ console.log('Run `ijfw doctor` to see the full roster status.');
453
+ process.exit(0);
454
+ }
455
+
456
+ console.log('IJFW demo -- 30-second tour of the Trident');
457
+ console.log('');
458
+
459
+ const fixturePath = join(dirname(fileURLToPath(import.meta.url)), '../fixtures/demo-target.js');
460
+ if (!existsSync(fixturePath)) {
461
+ console.log('Demo fixture not found -- run `npm pack` or reinstall @ijfw/memory-server.');
462
+ process.exit(0);
463
+ }
464
+
465
+ const target = readFileSync(fixturePath, 'utf8');
466
+
467
+ let result;
468
+ try {
469
+ // TODO post-merge: perAuditorTimeoutSec, minResponses, quiet are added by Item 2 agent.
470
+ // Passed through here; current orchestrator silently ignores unknown params.
471
+ result = await runCrossOp({
472
+ mode: 'audit',
473
+ target,
474
+ projectDir: process.cwd(),
475
+ perAuditorTimeoutSec: 30,
476
+ minResponses: 2,
477
+ quiet: true,
478
+ });
479
+ } catch (err) {
480
+ console.log(`Demo run encountered an issue: ${err.message}`);
481
+ process.exit(0);
482
+ }
483
+
484
+ const { picks, auditorResults } = result;
485
+
486
+ if (!picks || picks.length === 0) {
487
+ console.log('No auditors responded this run.');
488
+ console.log('Install codex or gemini, or set OPENAI_API_KEY / GEMINI_API_KEY, then run `ijfw demo`.');
489
+ console.log('');
490
+ console.log('Run `ijfw cross audit <your-file>` when an auditor is reachable.');
491
+ return;
492
+ }
493
+
494
+ console.log('Findings:');
495
+ console.log('');
496
+
497
+ if (auditorResults && auditorResults.length === picks.length) {
498
+ // Per-auditor attribution (U11: read auditorResults pre-merge). The helper
499
+ // prints findings as a side effect; its return value is intentionally unused.
500
+ _printDemoFindings(picks, auditorResults);
501
+ } else {
502
+ // Graceful fallback to merged listing when auditorResults unavailable
503
+ const items = Array.isArray(result.merged) ? result.merged : [];
504
+ if (items.length === 0) {
505
+ console.log(' No findings returned.');
506
+ } else {
507
+ console.log(' Note: per-auditor attribution unavailable; showing merged findings.');
508
+ for (const item of items) {
509
+ const sev = item.severity ? ` [${item.severity}]` : '';
510
+ console.log(` ${sev} ${String(item.issue || '').slice(0, 80)}`);
511
+ }
512
+ }
513
+ }
514
+
515
+ const allItems = Array.isArray(result.merged) ? result.merged : [];
516
+ const consensusCritical = allItems.filter(i => i.severity === 'critical' || i.severity === 'high').length;
517
+ console.log('');
518
+ console.log(`That was ${picks.length} AIs, one command. ${allItems.length} findings surfaced${consensusCritical > 0 ? `, ${consensusCritical} consensus-critical` : ''}.`);
519
+ console.log('Try `ijfw cross audit <your-file>` next.');
520
+ }
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // Doctor
524
+ // ---------------------------------------------------------------------------
525
+
526
+ // One-line install hints per auditor id. Used by cmdDoctor to tell the user
527
+ // the literal command, not just the dependency name.
528
+ const INSTALL_HINT = {
529
+ codex: 'npm install -g @openai/codex',
530
+ gemini: 'npm install -g @google/generative-ai-cli',
531
+ claude: 'npm install -g @anthropic-ai/claude-code',
532
+ copilot: 'gh extension install github/gh-copilot',
533
+ opencode: 'npm install -g opencode',
534
+ aider: 'pipx install aider-chat',
535
+ };
536
+
537
+ // Integration depth definitions per platform.
538
+ // depth: list of detected capabilities that constitute "native" integration.
539
+ const INTEGRATION_DEPTH = {
540
+ claude: {
541
+ label: 'Claude Code',
542
+ checks: [
543
+ { name: 'native plugin', detect: () => existsSync(join(homedir(), '.claude', 'plugins', 'ijfw')) || existsSync(join(homedir(), '.claude', 'settings.json')) },
544
+ { name: 'skills', detect: () => existsSync(join(homedir(), '.claude', 'plugins', 'ijfw', 'skills')) },
545
+ { name: 'hooks', detect: () => existsSync(join(homedir(), '.claude', 'plugins', 'ijfw', 'hooks')) },
546
+ { name: 'agents', detect: () => existsSync(join(homedir(), '.claude', 'plugins', 'ijfw', 'agents')) },
547
+ { name: 'commands', detect: () => existsSync(join(homedir(), '.claude', 'plugins', 'ijfw', 'commands')) },
548
+ { name: 'MCP', detect: () => { try { const s = JSON.parse(readFileSync(join(homedir(), '.claude', 'settings.json'), 'utf8')); return Boolean(s.mcpServers?.['ijfw-memory'] || s.enabledPlugins?.['ijfw-core@ijfw']); } catch { return false; } } },
549
+ ],
550
+ },
551
+ codex: {
552
+ label: 'Codex',
553
+ checks: [
554
+ { name: 'native skills', detect: () => existsSync(join(homedir(), '.codex', 'skills')) },
555
+ { name: 'hooks', detect: () => existsSync(join(homedir(), '.codex', 'hooks.json')) },
556
+ { name: 'context file', detect: () => existsSync(join(homedir(), '.codex', 'IJFW.md')) },
557
+ { name: 'MCP', detect: () => { try { const t = readFileSync(join(homedir(), '.codex', 'config.toml'), 'utf8'); return t.includes('ijfw-memory'); } catch { return false; } } },
558
+ ],
559
+ },
560
+ gemini: {
561
+ label: 'Gemini',
562
+ checks: [
563
+ { name: 'native extension', detect: () => existsSync(join(homedir(), '.gemini', 'extensions', 'ijfw', 'gemini-extension.json')) },
564
+ { name: 'skills', detect: () => existsSync(join(homedir(), '.gemini', 'extensions', 'ijfw', 'skills')) },
565
+ { name: 'hooks', detect: () => existsSync(join(homedir(), '.gemini', 'extensions', 'ijfw', 'hooks', 'hooks.json')) },
566
+ { name: 'commands', detect: () => existsSync(join(homedir(), '.gemini', 'extensions', 'ijfw', 'commands')) },
567
+ { name: 'policy', detect: () => existsSync(join(homedir(), '.gemini', 'extensions', 'ijfw', 'policies', 'ijfw.toml')) },
568
+ { name: 'MCP', detect: () => { try { const s = JSON.parse(readFileSync(join(homedir(), '.gemini', 'settings.json'), 'utf8')); return Boolean(s.mcpServers?.['ijfw-memory']); } catch { return false; } } },
569
+ ],
570
+ },
571
+ cursor: {
572
+ label: 'Cursor',
573
+ checks: [
574
+ { name: 'rules', detect: () => existsSync(join(process.cwd(), '.cursor', 'rules', 'ijfw.mdc')) },
575
+ { name: 'MCP', detect: () => { try { const s = JSON.parse(readFileSync(join(process.cwd(), '.cursor', 'mcp.json'), 'utf8')); return Boolean(s.mcpServers?.['ijfw-memory']); } catch { return false; } } },
576
+ ],
577
+ },
578
+ windsurf: {
579
+ label: 'Windsurf',
580
+ checks: [
581
+ { name: 'rules', detect: () => existsSync(join(process.cwd(), '.windsurfrules')) },
582
+ { name: 'MCP', detect: () => { try { const s = JSON.parse(readFileSync(join(homedir(), '.codeium', 'windsurf', 'mcp_config.json'), 'utf8')); return Boolean(s.mcpServers?.['ijfw-memory']); } catch { return false; } } },
583
+ ],
584
+ },
585
+ copilot: {
586
+ label: 'Copilot',
587
+ checks: [
588
+ { name: 'instructions', detect: () => existsSync(join(process.cwd(), '.github', 'copilot-instructions.md')) },
589
+ { name: 'MCP', detect: () => { try { const s = JSON.parse(readFileSync(join(process.cwd(), '.vscode', 'mcp.json'), 'utf8')); return Boolean(s.mcpServers?.['ijfw-memory']); } catch { return false; } } },
590
+ ],
591
+ },
592
+ };
593
+
594
+ function cmdDoctor(opts = {}) {
595
+ const auditors = [];
596
+ for (const entry of ROSTER) {
597
+ isReachable(entry.id, process.env); // side effect: prime probe cache
598
+ const cli = isInstalled(entry.id);
599
+ const apiOk = Boolean(entry.apiFallback && process.env[entry.apiFallback.authEnv]);
600
+ auditors.push({
601
+ id: entry.id,
602
+ name: entry.name,
603
+ cli_installed: cli,
604
+ api_env: entry.apiFallback ? entry.apiFallback.authEnv : null,
605
+ api_set: apiOk,
606
+ });
607
+ }
608
+
609
+ const integrations = [];
610
+ for (const [id, def] of Object.entries(INTEGRATION_DEPTH)) {
611
+ const detected = def.checks
612
+ .filter(c => { try { return c.detect(); } catch { return false; } })
613
+ .map(c => c.name);
614
+ if (detected.length > 0) integrations.push({ id, label: def.label, components: detected });
615
+ }
616
+
617
+ const anyReachable = ROSTER.some(e => isReachable(e.id, process.env).any);
618
+
619
+ if (wantsJson(opts)) {
620
+ emitJson({ auditors, integrations, any_reachable: anyReachable });
621
+ return;
622
+ }
623
+
624
+ console.log('ijfw doctor -- roster + key probe');
625
+ console.log('');
626
+
627
+ for (const a of auditors) {
628
+ if (a.cli_installed) {
629
+ console.log(` [ ok ] ${a.id} CLI -- ${a.name} ready`);
630
+ } else {
631
+ const cmd = INSTALL_HINT[a.id] || `npm install -g ${a.id}`;
632
+ console.log(` [ .. ] ${a.id} CLI -- standing by`);
633
+ console.log(` fix: ${cmd}`);
634
+ }
635
+ if (a.api_env) {
636
+ if (a.api_set) {
637
+ console.log(` [ ok ] ${a.api_env} -- set`);
638
+ } else {
639
+ console.log(` [ .. ] ${a.api_env} -- standing by`);
640
+ console.log(` fix: export ${a.api_env}=<your-key> (or add to ~/.ijfw/env)`);
641
+ }
642
+ }
643
+ }
644
+ console.log('');
645
+
646
+ if (anyReachable) {
647
+ console.log('At least one auditor is reachable. Run `ijfw cross audit <file>` to start.');
648
+ } else {
649
+ console.log('IJFW has the Trident ready -- install codex or gemini (or set OPENAI_API_KEY / GEMINI_API_KEY), then run `ijfw demo`.');
650
+ }
651
+
652
+ console.log('');
653
+ console.log('Integration depth:');
654
+ if (integrations.length === 0) {
655
+ console.log(' Run `bash scripts/install.sh` in your IJFW repo to activate platform bundles.');
656
+ } else {
657
+ for (const i of integrations) console.log(` ${i.label}: ${i.components.join(' + ')}`);
658
+ }
659
+ }
660
+
661
+ // ---------------------------------------------------------------------------
662
+ // Purge receipts
663
+ // ---------------------------------------------------------------------------
664
+
665
+ function cmdPurgeReceipts(projectDir) {
666
+ const count = purgeReceipts(projectDir);
667
+ if (count === 0) {
668
+ console.log('Receipt log is already empty. Run `ijfw cross audit <file>` to generate entries.');
669
+ } else {
670
+ console.log(`Receipt log cleared -- ${count} entr${count === 1 ? 'y' : 'ies'} removed.`);
671
+ console.log('Run `ijfw cross audit <file>` to start fresh.');
672
+ }
673
+ }
674
+
675
+ // Fixes issue #6: target path string was sent to auditors verbatim, causing
676
+ // hallucinated findings. If target resolves to a regular file on disk, read
677
+ // its contents (with a size cap) so auditors see real code. Topics, git
678
+ // ranges, and non-existent paths pass through unchanged.
679
+ const TARGET_FILE_SIZE_CAP = 64 * 1024; // 64 KB -- leaves prompt headroom
680
+
681
+ export function resolveTarget(raw, opts = {}) {
682
+ const cap = typeof opts.sizeCap === 'number' ? opts.sizeCap : TARGET_FILE_SIZE_CAP;
683
+ if (typeof raw !== 'string' || !raw) return raw;
684
+
685
+ let absPath;
686
+ try {
687
+ absPath = isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
688
+ } catch {
689
+ return raw;
690
+ }
691
+
692
+ if (!existsSync(absPath)) return raw;
693
+
694
+ let stat;
695
+ try {
696
+ stat = statSync(absPath);
697
+ } catch {
698
+ return raw;
699
+ }
700
+
701
+ if (!stat.isFile()) return raw;
702
+
703
+ let contents;
704
+ try {
705
+ if (stat.size > cap) {
706
+ const buf = Buffer.alloc(cap);
707
+ const fd = openSync(absPath, 'r');
708
+ try {
709
+ readSync(fd, buf, 0, cap, 0);
710
+ } finally {
711
+ closeSync(fd);
712
+ }
713
+ contents = buf.toString('utf8')
714
+ + `\n\n[... truncated: file is ${stat.size} bytes, showing first ${cap} ...]`;
715
+ } else {
716
+ contents = readFileSync(absPath, 'utf8');
717
+ }
718
+ } catch {
719
+ return raw;
720
+ }
721
+
722
+ return `File: ${raw}\n\n${contents}`;
723
+ }
724
+
725
+ async function cmdCross({ mode, target, only, confirm, expand }) {
726
+ const VALID_MODES = ['audit', 'research', 'critique'];
727
+ if (!mode || !VALID_MODES.includes(mode)) {
728
+ console.error(`ijfw cross requires a mode: ${VALID_MODES.join(', ')}. Example: ijfw cross audit <file>`);
729
+ process.exit(1);
730
+ }
731
+ if (!target) {
732
+ console.error('ijfw cross needs a target -- pass a file path, git range, or topic. Example: ijfw cross audit CLAUDE.md');
733
+ process.exit(1);
734
+ }
735
+
736
+ // Issue #6 fix: substitute file contents for path string when target is a
737
+ // regular file. Keep the raw target for the user-facing echo line.
738
+ const rawTarget = target;
739
+ target = resolveTarget(target);
740
+
741
+ // Polish 6: pre-flight reachability check. If no auditor is wired, give a
742
+ // positive recovery hint instead of bombing through to a runCrossOp error.
743
+ if (!_anyAuditorReachable()) {
744
+ console.log('');
745
+ console.log('Trident is standing by -- no auditors reachable yet.');
746
+ console.log('Wire one in 30 seconds: run `ijfw doctor` for the exact install commands.');
747
+ console.log('Tip: any one of codex / gemini / claude / copilot is enough to start.');
748
+ process.exit(0);
749
+ }
750
+
751
+ const projectDir = process.cwd();
752
+ const runStamp = new Date().toISOString();
753
+
754
+ console.log(`\nijfw cross ${mode} -- target: ${rawTarget}`);
755
+ console.log('Probing roster...');
756
+
757
+ let result;
758
+ try {
759
+ result = await runCrossOp({ mode, target, projectDir, runStamp, only, confirm, expand });
760
+ } catch (err) {
761
+ console.log('');
762
+ console.log(`Run didn't complete: ${err.message}`);
763
+ console.log('Try `ijfw doctor` to see what to wire next.');
764
+ process.exit(1);
765
+ }
766
+
767
+ const { merged, picks, note, auditorResults } = result;
768
+
769
+ if (picks.length === 0) {
770
+ console.log('\nIJFW has the Trident ready -- install codex or gemini (or set OPENAI_API_KEY / GEMINI_API_KEY), then run `ijfw demo`.');
771
+ console.log('Run `ijfw doctor` to see which auditors are available on this machine.');
772
+ return;
773
+ }
774
+
775
+ console.log(`Fired: ${picks.map(p => p.id).join(', ')}`);
776
+
777
+ if (note) {
778
+ console.log(`\nNote: ${note}`);
779
+ }
780
+
781
+ // Issue #9-B + 1.2.5: surface silent auditor degradation AND translate the
782
+ // raw error signature into one actionable line. Old behavior dumped the
783
+ // first 80 chars of stderr, which read like a stack trace. New behavior:
784
+ // pattern-match common auth/timeout/safety/no-key signatures and render
785
+ // "this is what's wrong, this is how to fix it" -- so the user knows
786
+ // whether to re-auth, retry, or change combo without reading source.
787
+ if (auditorResults && auditorResults.length === picks.length) {
788
+ const degraded = [];
789
+ for (let i = 0; i < picks.length; i++) {
790
+ const r = auditorResults[i];
791
+ const id = picks[i].id;
792
+ if (r.status === 'failed' || r.status === 'timeout' || (r.status === 'empty' && r.stderr && r.stderr.trim())) {
793
+ degraded.push({ id, reason: translateAuditorError(id, r.status, r.stderr || '', r.exitCode) });
794
+ }
795
+ }
796
+ if (degraded.length > 0) {
797
+ console.log('');
798
+ console.log('Heads up -- one or more auditors did not contribute this run:');
799
+ for (const d of degraded) {
800
+ console.log(` - ${d.id}: ${d.reason}`);
801
+ }
802
+ console.log('Trident lineage diversity is reduced for this result. Re-run after fixing the auditor, or pass --with <id> to force a different combination.');
803
+ }
804
+ }
805
+
806
+ console.log('\nFindings:');
807
+ printFindings(mode, merged);
808
+
809
+ console.log('\nReceipt logged -- run `ijfw status` to see it.');
810
+ }
811
+
812
+ // ---------------------------------------------------------------------------
813
+ // Portfolio audit -- `ijfw cross project-audit <rule-file>`
814
+ // ---------------------------------------------------------------------------
815
+
816
+ // Read the registry (same format as server.js: path|hash|iso lines). Lives
817
+ // here as a narrow duplicate so the CLI does not depend on server.js bootstrap.
818
+ function readProjectRegistry() {
819
+ const file = join(homedir(), '.ijfw', 'registry.md');
820
+ if (!existsSync(file)) return [];
821
+ const body = readFileSync(file, 'utf8');
822
+ const out = [];
823
+ for (const line of body.split('\n')) {
824
+ const parts = line.split('|').map(s => s.trim());
825
+ if (parts.length < 3) continue;
826
+ const [path, hash, iso] = parts;
827
+ if (!path || !isAbsolute(path)) continue;
828
+ out.push({ path, hash, iso });
829
+ }
830
+ return out;
831
+ }
832
+
833
+ async function cmdCrossProjectAudit({ rule, dryRun }) {
834
+ if (!rule) {
835
+ console.error('Usage: ijfw cross project-audit <rule-file> [--dry-run]');
836
+ process.exit(1);
837
+ }
838
+
839
+ const resolvedRule = isAbsolute(rule) ? rule : resolve(process.cwd(), rule);
840
+ if (!existsSync(resolvedRule)) {
841
+ console.error(`Rule file not found: ${resolvedRule}`);
842
+ process.exit(1);
843
+ }
844
+
845
+ const projects = readProjectRegistry();
846
+ if (projects.length === 0) {
847
+ console.log('No other IJFW projects registered yet.');
848
+ console.log('The registry auto-populates the first time you run any IJFW command in a project:');
849
+ console.log(' cd /path/to/another/project && ijfw status');
850
+ console.log('Then re-run: ijfw cross project-audit ' + rule);
851
+ return;
852
+ }
853
+
854
+ console.log(`Phase 12 / Wave 12B -- portfolio audit -- ${projects.length} project${projects.length === 1 ? '' : 's'}.`);
855
+
856
+ if (dryRun) {
857
+ for (const p of projects) console.log(` - ${basename(p.path)} (${p.path})`);
858
+ console.log('\n--dry-run: no audits dispatched. Drop the flag to fire.');
859
+ return;
860
+ }
861
+
862
+ const startedAt = new Date().toISOString();
863
+ const results = [];
864
+ for (const p of projects) {
865
+ const tag = basename(p.path);
866
+ console.log(` [${tag}] running cross audit ...`);
867
+ const r = spawnSync('ijfw', ['cross', 'audit', resolvedRule], {
868
+ cwd: p.path,
869
+ encoding: 'utf8',
870
+ timeout: 5 * 60 * 1000,
871
+ // shell:true on Windows so ijfw.cmd resolves through PATH.
872
+ shell: process.platform === 'win32',
873
+ });
874
+ if (r.error) {
875
+ results.push({ project: tag, path: p.path, status: 'failed', findings: '', error: `spawn-${r.error.code || 'unknown'}: ${r.error.message}` });
876
+ } else if (r.signal) {
877
+ // Without surfacing r.signal, killed children would otherwise report
878
+ // "exit null" -- portfolio aggregator treated that as silently OK.
879
+ results.push({ project: tag, path: p.path, status: 'failed', findings: r.stdout || '', error: `killed by ${r.signal} (timeout or OS signal)` });
880
+ } else if (r.status !== 0) {
881
+ results.push({ project: tag, path: p.path, status: 'failed', findings: r.stdout || '', error: (r.stderr || '').trim().split('\n')[0] || `exit ${r.status}` });
882
+ } else {
883
+ results.push({ project: tag, path: p.path, status: 'ok', findings: r.stdout || '' });
884
+ }
885
+ }
886
+ const finishedAt = new Date().toISOString();
887
+
888
+ const body = aggregatePortfolioFindings(results, { rule: basename(resolvedRule), startedAt, finishedAt });
889
+ const outDir = join(process.cwd(), '.ijfw', 'memory');
890
+ mkdirSync(outDir, { recursive: true });
891
+ const outFile = join(outDir, `portfolio-audit-${finishedAt.replace(/[:.]/g, '-')}.md`);
892
+ writeFileSync(outFile, body, 'utf8');
893
+ console.log(`\nPortfolio findings written: ${outFile}`);
894
+ }
895
+
896
+ // ---------------------------------------------------------------------------
897
+ // Import -- `ijfw import <tool> [--dry-run] [--force] [--path <p>]`
898
+ // ---------------------------------------------------------------------------
899
+
900
+ async function cmdImport(parsed) {
901
+ const tool = parsed.tool;
902
+ if (!tool) {
903
+ console.error(`Usage: ijfw import <tool> [--all] [--dry-run] [--force] [--path <p>]`);
904
+ console.error(`Tools: ${listImporters().join(', ')}`);
905
+ console.error(` --all Discover all projects and import each to its own .ijfw/memory/`);
906
+ process.exit(1);
907
+ }
908
+
909
+ if (parsed.allMode) {
910
+ return cmdImportAll(parsed);
911
+ }
912
+
913
+ const result = await runImport({
914
+ tool,
915
+ dryRun: parsed.dryRun,
916
+ force: parsed.force,
917
+ includeMetrics: parsed.includeMetrics,
918
+ path: parsed.customPath,
919
+ });
920
+ if (!result.ok) {
921
+ console.error(result.error);
922
+ process.exit(1);
923
+ }
924
+ console.log(result.summary);
925
+ if (result.dryRun && result.samples && result.samples.length > 0) {
926
+ console.log('\nSample entries (dry-run):');
927
+ for (const s of result.samples) console.log(` - [${s.type}] ${s.summary || '(no title)'}`);
928
+ console.log('\nRe-run without --dry-run to write them to .ijfw/memory/.');
929
+ }
930
+ }
931
+
932
+ async function cmdImportAll(parsed) {
933
+ // Phase 1: always do a dry-run preview first, regardless of user's --dry-run flag.
934
+ const preview = await runImportAll({
935
+ tool: parsed.tool,
936
+ dryRun: true,
937
+ force: parsed.force,
938
+ path: parsed.customPath,
939
+ });
940
+ if (!preview.ok) {
941
+ console.error(preview.error);
942
+ process.exit(1);
943
+ }
944
+
945
+ const plan = preview.plan;
946
+ console.log(`\nDiscovered ${plan.matched.length + plan.ambiguous.length + plan.unmatched.length} projects in ${parsed.tool} (${plan.totalEntries} total entries).\n`);
947
+
948
+ if (plan.matched.length > 0) {
949
+ console.log(`Auto-matched (${plan.matched.length}):`);
950
+ for (const m of plan.matched) {
951
+ console.log(` [${m.entryCount.toString().padStart(5)}] ${m.project.padEnd(30)} -> ${m.path}`);
952
+ }
953
+ console.log('');
954
+ }
955
+
956
+ if (plan.ambiguous.length > 0) {
957
+ console.log(`Ambiguous (${plan.ambiguous.length}) -- will go to global archive:`);
958
+ for (const a of plan.ambiguous) {
959
+ const paths = a.candidates.slice(0, 3).map((c) => c.path).join(' OR ');
960
+ console.log(` [${a.entryCount.toString().padStart(5)}] ${a.project.padEnd(30)} -> ${paths}`);
961
+ }
962
+ console.log('');
963
+ }
964
+
965
+ if (plan.unmatched.length > 0) {
966
+ console.log(`No local match (${plan.unmatched.length}) -- will go to global archive:`);
967
+ for (const u of plan.unmatched) {
968
+ console.log(` [${u.entryCount.toString().padStart(5)}] ${u.project}`);
969
+ }
970
+ console.log('');
971
+ }
972
+
973
+ if (parsed.dryRun) {
974
+ console.log('Dry run only. Re-run without --dry-run to execute.');
975
+ return;
976
+ }
977
+
978
+ // --all is destructive (writes to multiple project dirs). Require --yes.
979
+ if (!parsed.yes) {
980
+ console.log('This will import into all matched projects. Re-run with --yes to confirm.');
981
+ console.log(' ijfw import ' + parsed.tool + ' --all --yes');
982
+ return;
983
+ }
984
+
985
+ // Phase 2: execute.
986
+ console.log('Importing...\n');
987
+ const result = await runImportAll({
988
+ tool: parsed.tool,
989
+ dryRun: false,
990
+ force: parsed.force,
991
+ path: parsed.customPath,
992
+ });
993
+
994
+ if (!result.ok) {
995
+ console.error('Some imports failed. See per-project results below.');
996
+ }
997
+
998
+ // Stats categories from common.js emptyStats(): decisions, patterns,
999
+ // observations, handoffs, preferences, skipped, failed, total.
1000
+ const WRITTEN_KEYS = ['decisions', 'patterns', 'observations', 'handoffs', 'preferences'];
1001
+ const sumWritten = (s) => s ? WRITTEN_KEYS.reduce((n, k) => n + (s[k] || 0), 0) : 0;
1002
+
1003
+ let totalWritten = 0, totalSkipped = 0, totalFailed = 0;
1004
+ for (const r of result.results) {
1005
+ if (r.stats) {
1006
+ totalWritten += sumWritten(r.stats);
1007
+ totalSkipped += (r.stats.skipped || 0);
1008
+ totalFailed += (r.stats.failed || 0);
1009
+ }
1010
+ const status = r.ok === false ? 'FAILED' : 'OK';
1011
+ const written = sumWritten(r.stats);
1012
+ const skipped = r.stats ? (r.stats.skipped || 0) : 0;
1013
+ console.log(` [${status}] ${r.project.padEnd(30)} -> ${r.path} (${written} written, ${skipped} skipped)`);
1014
+ }
1015
+
1016
+ if (result.orphanResult) {
1017
+ console.log(`\nGlobal archive (~/.ijfw/memory/global-archive/):`);
1018
+ for (const p of result.orphanResult.projects) {
1019
+ const written = sumWritten(p.stats);
1020
+ const skipped = p.stats ? (p.stats.skipped || 0) : 0;
1021
+ totalWritten += written;
1022
+ totalSkipped += skipped;
1023
+ console.log(` ${p.ok !== false ? '[OK]' : '[FAILED]'} ${p.project} (${written} written, ${skipped} skipped)`);
1024
+ }
1025
+ }
1026
+
1027
+ console.log(`\nTotal: ${totalWritten} written, ${totalSkipped} skipped (already present), ${totalFailed} failed.`);
1028
+ console.log('Done.');
1029
+ }
1030
+
1031
+ // ---------------------------------------------------------------------------
1032
+ // Update -- `ijfw update` (polish 5)
1033
+ // ---------------------------------------------------------------------------
1034
+ //
1035
+ // Walks up from the launcher to the IJFW source repo, runs git pull, and
1036
+ // reruns scripts/install.sh in merge-safe mode. Designed for users who
1037
+ // installed via git clone (the canonical path). For users on
1038
+ // `npm install -g @ijfw/install`, hints them at the npm command instead.
1039
+
1040
+ // v1.1.6 update CLI -- replaces the v1.1.5 git-pull-only flow.
1041
+ // Subflags:
1042
+ // ijfw update interactive update (provenance + shasum verified)
1043
+ // ijfw update --check non-invasive availability check
1044
+ // ijfw update --yes non-interactive (terminal-only; rejects MCP context)
1045
+ // ijfw update --verify verification dry-run (no install)
1046
+ // ijfw update --changelog full release notes for available version
1047
+ // ijfw update --confirm <token> consume MCP-issued token, run update
1048
+ // ijfw update --auto on|off|ask set/query auto-update preference
1049
+ function cmdUpdate(opts = {}) {
1050
+ if (opts.auto !== undefined) return cmdUpdateAuto(opts.auto);
1051
+ if (opts.check) return cmdUpdateCheck();
1052
+ if (opts.verify) return cmdUpdateVerify();
1053
+ if (opts.changelog) return cmdUpdateChangelog();
1054
+ if (opts.confirm) return cmdUpdateConfirm(opts.confirm);
1055
+ process.exit(cmdUpdateInteractive(opts));
1056
+ }
1057
+
1058
+ function ijfwHome() {
1059
+ return process.env.IJFW_HOME || join(process.env.HOME || homedir(), '.ijfw');
1060
+ }
1061
+
1062
+ function readJsonSafe(path) {
1063
+ try {
1064
+ if (!existsSync(path)) return null;
1065
+ return JSON.parse(readFileSync(path, 'utf8'));
1066
+ } catch { return null; }
1067
+ }
1068
+
1069
+ function npmViewVersion(pkg = '@ijfw/install') {
1070
+ // shell:true on Windows so npm.cmd / npm.bat resolve. Without it, Node's
1071
+ // spawnSync can't find npm and returns ENOENT before the command runs.
1072
+ // pkg is hardcoded internally so there is no injection surface.
1073
+ const r = spawnSync('npm', ['view', pkg, 'version', '--json'], {
1074
+ encoding: 'utf8',
1075
+ timeout: 10_000,
1076
+ shell: process.platform === 'win32',
1077
+ });
1078
+ // Distinguish three failure modes so users + bug reports get actionable text
1079
+ // instead of a generic "npm view failed".
1080
+ if (r.error) {
1081
+ const code = r.error.code || 'unknown';
1082
+ return { ok: false, message: `spawn-${code}: ${(r.error.message || '').slice(0, 120)}` };
1083
+ }
1084
+ if (r.signal) {
1085
+ return { ok: false, message: `killed by ${r.signal} (likely network timeout)` };
1086
+ // r.status === null when killed by signal; explicit branch keeps the next
1087
+ // check clean.
1088
+ }
1089
+ if (r.status !== 0) {
1090
+ const stderr = (r.stderr || '').trim();
1091
+ return { ok: false, message: stderr || `npm view exited ${r.status} with no stderr` };
1092
+ }
1093
+ const raw = (r.stdout || '').trim().replace(/^"|"$/g, '');
1094
+ if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(raw)) return { ok: false, message: `malformed: ${raw.slice(0, 80)}` };
1095
+ return { ok: true, version: raw };
1096
+ }
1097
+
1098
+ function cmpSemver(a, b) {
1099
+ const parse = v => {
1100
+ const [main, pre] = String(v).split('-', 2);
1101
+ const nums = main.split('.').map(n => parseInt(n, 10) || 0);
1102
+ while (nums.length < 3) nums.push(0);
1103
+ return { nums, pre: pre || null };
1104
+ };
1105
+ const A = parse(a); const B = parse(b);
1106
+ for (let i = 0; i < 3; i++) {
1107
+ if (A.nums[i] !== B.nums[i]) return A.nums[i] < B.nums[i] ? -1 : 1;
1108
+ }
1109
+ if (A.pre === B.pre) return 0;
1110
+ if (A.pre && !B.pre) return -1;
1111
+ if (!A.pre && B.pre) return 1;
1112
+ return A.pre < B.pre ? -1 : 1;
1113
+ }
1114
+
1115
+ function readState() { return readJsonSafe(join(ijfwHome(), 'state.json')) || {}; }
1116
+ function readSettings() { return readJsonSafe(join(ijfwHome(), 'settings.json')) || {}; }
1117
+ function writeStateFields(updates) {
1118
+ const path = join(ijfwHome(), 'state.json');
1119
+ const state = Object.assign(readState(), updates);
1120
+ try {
1121
+ writeAtomic(path, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
1122
+ } catch (e) {
1123
+ console.error(`could not persist state.json: ${e.message}`);
1124
+ }
1125
+ }
1126
+
1127
+ function cmdUpdateCheck() {
1128
+ const state = readState();
1129
+ const current = state.installed_version || '0.0.0';
1130
+ const r = npmViewVersion('@ijfw/install');
1131
+ if (!r.ok) {
1132
+ console.log(`IJFW: update check failed (${r.message}). Will retry next session.`);
1133
+ process.exit(1);
1134
+ }
1135
+ const cmp = cmpSemver(current, r.version);
1136
+ if (cmp >= 0) {
1137
+ console.log(`IJFW is up to date (v${current}).`);
1138
+ process.exit(0);
1139
+ }
1140
+ // Exit 0 + machine-readable sentinel so shell scripts can grep rather than
1141
+ // rely on exit codes. Previously exited 3 which broke POSIX if-statements.
1142
+ console.log(`update-available: ${r.version}`);
1143
+ console.log(`Update available: v${current} -> v${r.version}`);
1144
+ console.log(` Release notes: https://gitlab.com/therealseandonahoe/ijfw/-/releases/v${r.version}`);
1145
+ console.log(` Run: ijfw update`);
1146
+ process.exit(0);
1147
+ }
1148
+
1149
+ function cmdUpdateVerify() {
1150
+ const state = readState();
1151
+ const current = state.installed_version || '0.0.0';
1152
+ const r = npmViewVersion('@ijfw/install');
1153
+ console.log('IJFW update verification:');
1154
+ console.log(` installed: v${current}`);
1155
+ console.log(` install_method: ${state.install_method || 'unknown'}`);
1156
+ console.log(` last_good_shasum: ${state.last_good_shasum || '(none)'}`);
1157
+ if (!r.ok) {
1158
+ console.log(` registry: UNREACHABLE (${r.message})`);
1159
+ process.exit(1);
1160
+ }
1161
+ console.log(` registry latest: v${r.version}`);
1162
+ // Provenance check via npm audit signatures
1163
+ const sig = spawnSync('npm', ['audit', 'signatures', `@ijfw/install@${r.version}`], {
1164
+ encoding: 'utf8',
1165
+ timeout: 15_000,
1166
+ shell: process.platform === 'win32',
1167
+ });
1168
+ if (sig.status === 0) {
1169
+ console.log(` provenance: VERIFIED (npm audit signatures)`);
1170
+ } else {
1171
+ console.log(` provenance: NOT VERIFIED (audit signatures exited ${sig.status})`);
1172
+ }
1173
+ console.log('Verification complete.');
1174
+ process.exit(0);
1175
+ }
1176
+
1177
+ function cmdUpdateChangelog() {
1178
+ const r = npmViewVersion('@ijfw/install');
1179
+ if (!r.ok) {
1180
+ console.error(`could not fetch latest version: ${r.message}`);
1181
+ process.exit(1);
1182
+ }
1183
+ const url = `https://api.github.com/repos/therealseandonahoe/ijfw/releases/tags/v${r.version}`;
1184
+ const fetchRes = spawnSync('curl', ['-fsSL', '-H', 'User-Agent: ijfw', url], { encoding: 'utf8', timeout: 10_000 });
1185
+ if (fetchRes.status !== 0) {
1186
+ console.log(`No release notes available for v${r.version}.`);
1187
+ console.log(`Visit: https://gitlab.com/therealseandonahoe/ijfw/-/releases/v${r.version}`);
1188
+ process.exit(0);
1189
+ }
1190
+ let body = '';
1191
+ try {
1192
+ const data = JSON.parse(fetchRes.stdout || '{}');
1193
+ body = data.body || '(no body)';
1194
+ } catch { body = '(could not parse release JSON)'; }
1195
+ // ANSI strip + cap 4KB. Control-char regex is intentional -- defangs
1196
+ // CHANGELOG bytes fetched over HTTPS so paste into the terminal can't
1197
+ // execute escape sequences. (oxlint flags this as no-control-regex; OK.)
1198
+ // oxlint-disable no-control-regex
1199
+ const stripped = body
1200
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
1201
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
1202
+ .slice(0, 4096);
1203
+ // oxlint-enable no-control-regex
1204
+ console.log(`Changelog for v${r.version}`);
1205
+ console.log('');
1206
+ console.log(stripped);
1207
+ if (body.length > 4096) console.log(`\n... (truncated; full notes at https://gitlab.com/therealseandonahoe/ijfw/-/releases/v${r.version})`);
1208
+ process.exit(0);
1209
+ }
1210
+
1211
+ function cmdUpdateAuto(value) {
1212
+ const valid = ['on', 'off', 'ask'];
1213
+ if (!valid.includes(value)) {
1214
+ console.error(`--auto must be one of: ${valid.join(', ')}`);
1215
+ process.exit(1);
1216
+ }
1217
+ const settingsPath = join(ijfwHome(), 'settings.json');
1218
+ const settings = readSettings();
1219
+ if (!settings.schema_version) settings.schema_version = 1;
1220
+ settings.auto_update = value;
1221
+ try {
1222
+ writeAtomic(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
1223
+ console.log(`auto_update set to "${value}".`);
1224
+ } catch (e) {
1225
+ console.error(`could not persist settings.json: ${e.message}`);
1226
+ process.exit(1);
1227
+ }
1228
+ }
1229
+
1230
+ function cmdUpdateConfirm(token) {
1231
+ // Locate pending sentinel under any session in ~/.ijfw/run/
1232
+ const runRoot = join(ijfwHome(), 'run');
1233
+ if (!existsSync(runRoot)) {
1234
+ console.error('No pending update sentinel found. Run `ijfw_update_apply` via your AI first.');
1235
+ process.exit(1);
1236
+ }
1237
+ let sentinelPath = null;
1238
+ try {
1239
+ for (const dir of readdirSync(runRoot)) {
1240
+ const candidate = join(runRoot, dir, 'update-pending.json');
1241
+ if (existsSync(candidate)) { sentinelPath = candidate; break; }
1242
+ }
1243
+ } catch { /* */ }
1244
+ if (!sentinelPath) {
1245
+ console.error('No pending update sentinel found. The MCP `ijfw_update_apply` tool issues sentinels.');
1246
+ process.exit(1);
1247
+ }
1248
+ const pending = readJsonSafe(sentinelPath);
1249
+ if (!pending || pending.token !== token) {
1250
+ console.error('Token mismatch -- run ijfw_update_check + ijfw_update_apply via your AI to issue a fresh token.');
1251
+ process.exit(1);
1252
+ }
1253
+ if (process.env.IJFW_FROM_MCP === '1') {
1254
+ console.error('Refusing: --confirm must be invoked from a terminal, not an MCP-spawned subprocess.');
1255
+ process.exit(1);
1256
+ }
1257
+ console.log(`Confirming update to v${pending.target_version}...`);
1258
+ // Hold sentinel through install; remove synchronously on all paths
1259
+ // (happy + error + signal). Node does NOT run pending `finally` blocks
1260
+ // on SIGINT/SIGTERM by default, so register explicit handlers that
1261
+ // scrub the sentinel before exit.
1262
+ const scrubAndExit = (sig) => {
1263
+ try { rmSync(sentinelPath, { force: true }); } catch { /* */ }
1264
+ // Mimic the shell's default exit code for the signal: 128 + signum.
1265
+ const signum = sig === 'SIGINT' ? 2 : sig === 'SIGTERM' ? 15 : 1;
1266
+ process.exit(128 + signum);
1267
+ };
1268
+ process.on('SIGINT', () => scrubAndExit('SIGINT'));
1269
+ process.on('SIGTERM', () => scrubAndExit('SIGTERM'));
1270
+ let exitCode = 1;
1271
+ try {
1272
+ exitCode = cmdUpdateInteractive({ yes: true, _confirmedFromToken: pending.target_version });
1273
+ } finally {
1274
+ try { rmSync(sentinelPath, { force: true }); } catch { /* */ }
1275
+ }
1276
+ process.exit(exitCode);
1277
+ }
1278
+
1279
+ function cmdUpdateInteractive(opts = {}) {
1280
+ if (process.env.IJFW_FROM_MCP === '1' && opts.yes) {
1281
+ console.error('Refusing: `ijfw update --yes` from an MCP-spawned context. Run from your terminal.');
1282
+ return 1;
1283
+ }
1284
+ const state = readState();
1285
+ const current = state.installed_version || '0.0.0';
1286
+ const r = npmViewVersion('@ijfw/install');
1287
+ if (!r.ok) {
1288
+ console.error(`Update check failed: ${r.message}`);
1289
+ return 1;
1290
+ }
1291
+ const cmp = cmpSemver(current, r.version);
1292
+ if (cmp >= 0) {
1293
+ console.log(`IJFW is up to date (v${current}). Nothing to do.`);
1294
+ return 0;
1295
+ }
1296
+ console.log(`IJFW update v${current} -> v${r.version}`);
1297
+ console.log('');
1298
+ // Provenance check (best-effort; report but don't block on transient failures)
1299
+ const sig = spawnSync('npm', ['audit', 'signatures', `@ijfw/install@${r.version}`], {
1300
+ encoding: 'utf8',
1301
+ timeout: 15_000,
1302
+ shell: process.platform === 'win32',
1303
+ });
1304
+ if (sig.status === 0) {
1305
+ console.log(' Provenance: verified');
1306
+ } else {
1307
+ console.log(' Provenance: WARNING -- could not verify signatures');
1308
+ if (!opts.yes) {
1309
+ console.log(' Continuing requires --yes (acknowledge unverified provenance).');
1310
+ return 1;
1311
+ }
1312
+ }
1313
+ // Method dispatch
1314
+ const method = state.install_method || 'manual';
1315
+ console.log(` install_method: ${method}`);
1316
+ let installRes;
1317
+ if (method === 'npm-global') {
1318
+ console.log(' Running: npm install -g @ijfw/install@latest');
1319
+ installRes = spawnSync('npm', ['install', '-g', `@ijfw/install@${r.version}`], {
1320
+ stdio: 'inherit',
1321
+ shell: process.platform === 'win32',
1322
+ });
1323
+ if (installRes.error) {
1324
+ console.error(`npm install -g could not be spawned (${installRes.error.code}). Run \`npm install -g @ijfw/install@${r.version}\` manually.`);
1325
+ return 1;
1326
+ }
1327
+ if (installRes.signal) {
1328
+ console.error(`npm install -g killed by ${installRes.signal}. Run \`npm install -g @ijfw/install@${r.version}\` manually.`);
1329
+ return 1;
1330
+ }
1331
+ if (installRes.status !== 0) {
1332
+ console.error(`npm install -g did not complete (exit ${installRes.status}). Run \`npm install -g @ijfw/install@${r.version}\` manually.`);
1333
+ return 1;
1334
+ }
1335
+ // npm install -g only refreshes the CLI shim. The mcp-server payload under
1336
+ // ~/.ijfw/mcp-server/ comes from the git tree and is only refreshed when
1337
+ // ijfw-install runs. Without this, `ijfw update` reports "updated" while
1338
+ // the actual MCP tools keep running stale code until the next manual
1339
+ // ijfw-install. Auto-invoke ijfw-install so the upgrade self-completes.
1340
+ console.log(' Refreshing ~/.ijfw/ via ijfw-install...');
1341
+ const refresh = spawnSync('ijfw-install', [], {
1342
+ stdio: 'inherit',
1343
+ shell: process.platform === 'win32',
1344
+ });
1345
+ if (refresh.error) {
1346
+ console.error(`Auto-refresh could not be spawned (${refresh.error.code}). Run \`ijfw-install\` manually to finish the upgrade.`);
1347
+ return 1;
1348
+ }
1349
+ if (refresh.signal) {
1350
+ console.error(`Auto-refresh killed by ${refresh.signal}. Run \`ijfw-install\` manually to finish the upgrade.`);
1351
+ return 1;
1352
+ }
1353
+ if (refresh.status !== 0) {
1354
+ console.error(`Auto-refresh did not complete (exit ${refresh.status}). Run \`ijfw-install\` manually to finish the upgrade.`);
1355
+ return 1;
1356
+ }
1357
+ } else if (method === 'git-clone') {
1358
+ const repoRoot = repoRootFromCli();
1359
+ const installSh = join(repoRoot, 'scripts', 'install.sh');
1360
+ console.log(' Running: git pull + scripts/install.sh');
1361
+ // Capture stderr explicitly so bug reports include the why -- previously a
1362
+ // network/auth/conflict failure surfaced only as "git pull failed".
1363
+ const pull = spawnSync('git', ['-C', repoRoot, 'pull', '--ff-only'], { encoding: 'utf8' });
1364
+ if (pull.status !== 0) {
1365
+ console.error('git pull failed:');
1366
+ if (pull.stderr) console.error(pull.stderr.trim().split('\n').map(l => ' ' + l).join('\n'));
1367
+ console.error(` Run \`git -C ${repoRoot} status\` to inspect.`);
1368
+ return 1;
1369
+ }
1370
+ console.log(' Running: npm install --omit=dev --ignore-scripts');
1371
+ const npmInstall = spawnSync('npm', ['install', '--omit=dev', '--ignore-scripts'], {
1372
+ cwd: repoRoot,
1373
+ stdio: 'inherit',
1374
+ shell: process.platform === 'win32',
1375
+ });
1376
+ if (npmInstall.error) {
1377
+ console.error(`npm install could not be spawned (${npmInstall.error.code}). Run \`npm install --omit=dev --ignore-scripts\` in ${repoRoot} manually.`);
1378
+ return 1;
1379
+ }
1380
+ if (npmInstall.signal) {
1381
+ console.error(`npm install killed by ${npmInstall.signal}. Run \`npm install --omit=dev --ignore-scripts\` in ${repoRoot} manually.`);
1382
+ return 1;
1383
+ }
1384
+ if (npmInstall.status !== 0) {
1385
+ console.error(`npm install did not complete (exit ${npmInstall.status}). Run \`npm install --omit=dev --ignore-scripts\` in ${repoRoot} manually.`);
1386
+ return 1;
1387
+ }
1388
+ installRes = spawnSync('bash', [installSh], { stdio: 'inherit' });
1389
+ } else {
1390
+ console.log(' Manual install detected -- run: npx @ijfw/install');
1391
+ installRes = spawnSync('npx', ['-y', `@ijfw/install@${r.version}`], { stdio: 'inherit' });
1392
+ }
1393
+ if (!installRes) {
1394
+ console.error('Update did not complete (install command could not be spawned)');
1395
+ return 1;
1396
+ }
1397
+ if (installRes.signal) {
1398
+ console.error(`Update did not complete (killed by ${installRes.signal}). State not written.`);
1399
+ return 1;
1400
+ }
1401
+ if (installRes.status !== 0) {
1402
+ console.error(`Update did not complete (exit ${installRes.status}). State not written.`);
1403
+ return 1;
1404
+ }
1405
+ // Persist both fields atomically -- single write avoids concurrent-reader inconsistency
1406
+ writeStateFields({ last_applied_version: r.version, installed_version: r.version });
1407
+ console.log('');
1408
+ console.log(`IJFW updated to v${r.version}. Run \`ijfw status\` to confirm.`);
1409
+ return 0;
1410
+ }
1411
+
1412
+ // `ijfw --version` (pure) and `ijfw --version --verbose` per v3 �section MEDIUM
1413
+ function cmdVersion(opts = {}) {
1414
+ const root = repoRootFromCli();
1415
+ const pkgPath = join(root, 'installer', 'package.json');
1416
+ let version = 'unknown';
1417
+ try { version = JSON.parse(readFileSync(pkgPath, 'utf8')).version || 'unknown'; } catch { /* */ }
1418
+
1419
+ // `ijfw --version` is a stable shell-script contract -- only switch to
1420
+ // JSON when --json is explicit. status/doctor follow the gh-CLI convention
1421
+ // and auto-JSON on non-TTY, but version stays one-line on pipe.
1422
+ if (opts.json) {
1423
+ if (!opts.verbose) {
1424
+ emitJson({ package: '@ijfw/install', version });
1425
+ return;
1426
+ }
1427
+ const state = readState();
1428
+ const settings = readSettings();
1429
+ emitJson({
1430
+ package: '@ijfw/install',
1431
+ version,
1432
+ install_method: state.install_method || null,
1433
+ installed_at: state.installed_at ? new Date(state.installed_at * 1000).toISOString() : null,
1434
+ last_applied: state.last_applied_version || null,
1435
+ auto_update: settings.auto_update || 'ask',
1436
+ update_check_interval_hours: (settings.update_check && settings.update_check.interval_hours) || 24,
1437
+ ijfw_home: ijfwHome(),
1438
+ });
1439
+ return;
1440
+ }
1441
+
1442
+ if (!opts.verbose) {
1443
+ console.log(`@ijfw/install@${version}`);
1444
+ return;
1445
+ }
1446
+ const state = readState();
1447
+ const settings = readSettings();
1448
+ console.log(`@ijfw/install@${version}`);
1449
+ console.log(` install_method: ${state.install_method || '(not recorded)'}`);
1450
+ console.log(` installed_at: ${state.installed_at ? new Date(state.installed_at * 1000).toISOString() : '(unknown)'}`);
1451
+ console.log(` last_applied: ${state.last_applied_version || '(none)'}`);
1452
+ console.log(` auto_update: ${settings.auto_update || 'ask'}`);
1453
+ console.log(` bg update check: every ${settings.update_check && settings.update_check.interval_hours || 24}h`);
1454
+ console.log(` kill switches: IJFW_DISABLE_UPDATE_CHECK={1,true,yes,on}`);
1455
+ console.log(` ijfw home: ${ijfwHome()}`);
1456
+ }
1457
+
1458
+ // 1.1.6b statusline CLI family. Manages the Claude Code statusLine slot
1459
+ // at ~/.claude/settings.json. Compose-mode allowlist + change-detection per v3 sec 8.
1460
+
1461
+ const STATUSLINE_PATH_ALLOWLIST = [
1462
+ // Canonical compose targets -- canonicalized at compare time
1463
+ '/.claude/', '/.gsd/', '/.ijfw/claude/', '/.cursor/',
1464
+ ];
1465
+
1466
+ function statuslineScriptPath() {
1467
+ // Where Claude Code reads the statusline command from
1468
+ const root = repoRootFromCli();
1469
+ return join(root, 'claude', 'hooks', 'scripts', 'ijfw-statusline.js');
1470
+ }
1471
+
1472
+ function readClaudeSettings() {
1473
+ const path = join(homedir(), '.claude', 'settings.json');
1474
+ return { path, data: readJsonSafe(path) || {} };
1475
+ }
1476
+
1477
+ function writeClaudeSettings(path, data) {
1478
+ try {
1479
+ writeAtomic(path, JSON.stringify(data, null, 2) + '\n');
1480
+ return true;
1481
+ } catch (e) {
1482
+ console.error(`could not write ${path}: ${e.message}`);
1483
+ return false;
1484
+ }
1485
+ }
1486
+
1487
+ function setIjfwStatuslineSetting(field, value) {
1488
+ const settingsPath = join(ijfwHome(), 'settings.json');
1489
+ const settings = readSettings();
1490
+ if (!settings.schema_version) settings.schema_version = 1;
1491
+ if (!settings.statusline) settings.statusline = {};
1492
+ settings.statusline[field] = value;
1493
+ try {
1494
+ writeAtomic(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
1495
+ return true;
1496
+ } catch (e) {
1497
+ console.error(`could not persist settings.json: ${e.message}`);
1498
+ return false;
1499
+ }
1500
+ }
1501
+
1502
+ function isPathInAllowlist(p) {
1503
+ if (!p || typeof p !== 'string') return false;
1504
+ for (const seg of STATUSLINE_PATH_ALLOWLIST) {
1505
+ if (p.includes(seg)) return true;
1506
+ }
1507
+ return false;
1508
+ }
1509
+
1510
+ function cmdStatusline(sub) {
1511
+ switch (sub) {
1512
+ case 'status': return statuslineStatus();
1513
+ case 'install': return statuslineInstall();
1514
+ case 'compose': return statuslineCompose();
1515
+ case 'disable': return statuslineDisable();
1516
+ case 'recompute': return statuslineRecompute();
1517
+ default:
1518
+ console.log('Usage: ijfw statusline --install|--compose|--disable|--status|--recompute');
1519
+ process.exit(1);
1520
+ }
1521
+ }
1522
+
1523
+ function statuslineStatus() {
1524
+ const settings = readSettings();
1525
+ const claude = readClaudeSettings();
1526
+ const claudeStatusline = claude.data.statusLine;
1527
+
1528
+ console.log('IJFW statusline status:');
1529
+ console.log(` IJFW setting: ${(settings.statusline && settings.statusline.enabled) || 'auto'}`);
1530
+ console.log(` IJFW mode: ${(settings.statusline && settings.statusline.mode) || 'compose'}`);
1531
+ if (claudeStatusline) {
1532
+ const cmd = claudeStatusline.command || '(none)';
1533
+ const owner = cmd.includes('ijfw-statusline.js') ? 'IJFW'
1534
+ : isPathInAllowlist(cmd) ? 'allowlisted (compose-eligible)'
1535
+ : 'unknown (will skip on next install)';
1536
+ console.log(` Claude statusLine: ${cmd}`);
1537
+ console.log(` Owner classification: ${owner}`);
1538
+ } else {
1539
+ console.log(' Claude statusLine: (not set)');
1540
+ }
1541
+ console.log('');
1542
+ console.log('Run ijfw statusline --install to take ownership, --compose to coexist with another tool, --disable to remove.');
1543
+ }
1544
+
1545
+ function statuslineInstall() {
1546
+ const ijfwScript = statuslineScriptPath();
1547
+ if (!existsSync(ijfwScript)) {
1548
+ console.error(`statusline script not found at ${ijfwScript}`);
1549
+ process.exit(1);
1550
+ }
1551
+ const claude = readClaudeSettings();
1552
+ const prev = claude.data.statusLine;
1553
+ if (prev && prev.command && !prev.command.includes('ijfw-statusline.js')) {
1554
+ console.log(`Existing statusLine found: ${prev.command}`);
1555
+ console.log(` -> backing up to ~/.claude/settings.json (statusLine field replaced)`);
1556
+ }
1557
+ claude.data.statusLine = { type: 'command', command: `node ${ijfwScript}` };
1558
+ if (writeClaudeSettings(claude.path, claude.data)) {
1559
+ setIjfwStatuslineSetting('enabled', 'on');
1560
+ setIjfwStatuslineSetting('mode', 'own');
1561
+ console.log(`statusline installed -- IJFW now owns the slot.`);
1562
+ console.log(`Open a new Claude Code session to see it.`);
1563
+ }
1564
+ }
1565
+
1566
+ function statuslineCompose() {
1567
+ const claude = readClaudeSettings();
1568
+ const prev = claude.data.statusLine;
1569
+ if (!prev || !prev.command) {
1570
+ console.error('No existing statusLine to compose with. Run --install instead.');
1571
+ process.exit(1);
1572
+ }
1573
+ if (prev.command.includes('ijfw-statusline.js')) {
1574
+ console.log('IJFW already owns the slot. Nothing to compose with.');
1575
+ return;
1576
+ }
1577
+ if (!isPathInAllowlist(prev.command)) {
1578
+ console.error(`Existing statusLine command not in allowlist for safe compose: ${prev.command}`);
1579
+ console.error('Refusing for security. Use --install to replace, or contact the existing tool maintainer.');
1580
+ process.exit(1);
1581
+ }
1582
+ // Persist compose target (the IJFW script reads this from settings.json)
1583
+ setIjfwStatuslineSetting('mode', 'compose');
1584
+ setIjfwStatuslineSetting('composed_command', prev.command);
1585
+ setIjfwStatuslineSetting('enabled', 'on');
1586
+ // Wrap the existing command so IJFW renders alongside it
1587
+ const ijfwScript = statuslineScriptPath();
1588
+ claude.data.statusLine = { type: 'command', command: `node ${ijfwScript}` };
1589
+ if (writeClaudeSettings(claude.path, claude.data)) {
1590
+ console.log(`statusline composed -- IJFW renders alongside ${prev.command}.`);
1591
+ console.log('Open a new Claude Code session to see it.');
1592
+ }
1593
+ }
1594
+
1595
+ function statuslineDisable() {
1596
+ const claude = readClaudeSettings();
1597
+ const prev = claude.data.statusLine;
1598
+ if (prev && prev.command && prev.command.includes('ijfw-statusline.js')) {
1599
+ delete claude.data.statusLine;
1600
+ writeClaudeSettings(claude.path, claude.data);
1601
+ }
1602
+ setIjfwStatuslineSetting('enabled', 'off');
1603
+ console.log('statusline disabled.');
1604
+ }
1605
+
1606
+ function statuslineRecompute() {
1607
+ // Force statusline to re-read everything (cache + settings) -- a no-op for
1608
+ // the script itself (it reads on every invocation), but useful as a UX hook
1609
+ // after the user changes settings.json manually.
1610
+ console.log('statusline will re-read cache + settings on the next render.');
1611
+ console.log('Open a new Claude Code session to see the change.');
1612
+ }
1613
+
1614
+ function cmdConfig(sub) {
1615
+ if (sub === 'audit') {
1616
+ console.log('ijfw config --audit -- this feature is queued for a later release.');
1617
+ console.log('Track progress: https://gitlab.com/therealseandonahoe/ijfw/issues');
1618
+ return;
1619
+ }
1620
+ console.log('Usage: ijfw config --audit');
1621
+ console.log(' --audit Show the active configuration resolution hierarchy (queued for a later release).');
1622
+ }
1623
+
1624
+ function cmdInsight(sub) {
1625
+ // Alias for `ijfw dashboard start` -- context-mode parity per v3 CLI table.
1626
+ cmdDashboard(sub === 'start' || !sub ? 'start' : sub);
1627
+ }
1628
+
1629
+ // ---------------------------------------------------------------------------
1630
+ // Receipt -- `ijfw receipt last` (polish 8)
1631
+ // ---------------------------------------------------------------------------
1632
+ //
1633
+ // Prints a redacted, shareable block from the most recent Trident run.
1634
+ // Strips absolute paths + project basenames so the user can paste it in
1635
+ // PR comments / Slack without leaking environment detail.
1636
+
1637
+ const RECEIPT_SUBS = new Set(['last']);
1638
+
1639
+ function cmdReceipt(sub = 'last') {
1640
+ if (!RECEIPT_SUBS.has(sub)) {
1641
+ console.log(`Unknown receipt sub-command: ${sub}`);
1642
+ console.log(`Usage: ijfw receipt <${[...RECEIPT_SUBS].join('|')}>`);
1643
+ process.exit(1);
1644
+ }
1645
+ const receipts = readReceipts(process.cwd());
1646
+ if (receipts.length === 0) {
1647
+ console.log('No Trident runs on record yet. Try `ijfw cross audit <file>` first.');
1648
+ return;
1649
+ }
1650
+ const last = receipts[receipts.length - 1];
1651
+ // Receipts schema: { findings: { items: [...] } }. Earlier draft read
1652
+ // merged.findings -- caught by Trident audit on the polish pass.
1653
+ const findings = Array.isArray(last.findings?.items) ? last.findings.items : [];
1654
+ const auditors = Array.isArray(last.auditors)
1655
+ ? last.auditors.map(a => a.id).filter(Boolean)
1656
+ : [];
1657
+
1658
+ const lines = [];
1659
+ lines.push('```');
1660
+ lines.push(`Trident -- ${last.mode || 'audit'} -- ${(last.timestamp || '').slice(0, 10)}`);
1661
+ lines.push(`Auditors: ${auditors.join(', ') || 'n/a'}`);
1662
+ lines.push(`Findings: ${findings.length}`);
1663
+ for (const f of findings.slice(0, 5)) {
1664
+ const sev = f.severity ? `[${String(f.severity).toLowerCase()}] ` : '';
1665
+ const claim = redact(String(f.claim || f.issue || ''));
1666
+ if (claim) lines.push(` ${sev}${claim.slice(0, 140)}`);
1667
+ }
1668
+ if (findings.length > 5) lines.push(` ... ${findings.length - 5} more.`);
1669
+ lines.push(`Receipt: ijfw status`);
1670
+ lines.push('```');
1671
+ console.log(lines.join('\n'));
1672
+ }
1673
+
1674
+ // Redact absolute paths + git directories so the receipt is safe to paste.
1675
+ function redact(s) {
1676
+ return s
1677
+ .replace(/\/Users\/[^/\s]+/g, '~')
1678
+ .replace(/\/home\/[^/\s]+/g, '~')
1679
+ .replace(/\/var\/folders\/[^/]+\/[^/]+\/T\//g, '/tmp/')
1680
+ .replace(/\/run\/user\/\d+\//g, '/run/user/<uid>/')
1681
+ .replace(/[A-Z]:\\Users\\[^\\\s]+/g, '%USERPROFILE%');
1682
+ }
1683
+
1684
+ // ---------------------------------------------------------------------------
1685
+ // ijfw help -- open the full guide (terminal paged or rendered HTML)
1686
+ // ---------------------------------------------------------------------------
1687
+ async function handleGuide(useBrowser) {
1688
+ const __dir = dirname(fileURLToPath(import.meta.url));
1689
+ const candidates = [
1690
+ resolve(__dir, '..', '..', 'docs', 'GUIDE.md'),
1691
+ resolve(__dir, '..', '..', '..', 'docs', 'GUIDE.md'),
1692
+ join(homedir(), '.ijfw', 'docs', 'GUIDE.md'),
1693
+ ];
1694
+ const guidePath = candidates.find(p => existsSync(p));
1695
+ if (!guidePath) {
1696
+ console.error('[ijfw] Guide not found. Visit https://gitlab.com/therealseandonahoe/ijfw/-/blob/main/docs/GUIDE.md');
1697
+ process.exit(1);
1698
+ }
1699
+ const md = readFileSync(guidePath, 'utf8');
1700
+
1701
+ if (useBrowser) {
1702
+ const outDir = join(homedir(), '.ijfw', 'guide');
1703
+ mkdirSync(outDir, { recursive: true });
1704
+ const outFile = join(outDir, 'index.html');
1705
+ const esc = md.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
1706
+ const html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
1707
+ <title>IJFW Guide</title>
1708
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.5.1/github-markdown-dark.min.css">
1709
+ <script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
1710
+ <style>
1711
+ body{background:#0d1117;color:#c9d1d9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;margin:0;padding:2rem}
1712
+ .markdown-body{max-width:1000px;margin:0 auto;padding:2.5rem 3rem;background:#161b22;border-radius:12px;border:1px solid #30363d}
1713
+ img{max-width:100%;border-radius:8px}
1714
+ .brand{position:fixed;top:1rem;right:1.25rem;font-size:0.8rem;color:#8b949e;font-family:'SF Mono',Menlo,monospace}
1715
+ </style></head>
1716
+ <body>
1717
+ <div class="brand">ijfw help --browser</div>
1718
+ <article class="markdown-body" id="content"></article>
1719
+ <script>
1720
+ // Trusted source: GUIDE.md is shipped with IJFW; no user input in this path.
1721
+ const md = \`${esc}\`;
1722
+ const host = document.getElementById('content');
1723
+ const range = document.createRange();
1724
+ range.selectNodeContents(host);
1725
+ host.appendChild(range.createContextualFragment(marked.parse(md)));
1726
+ </script></body></html>`;
1727
+ writeFileSync(outFile, html);
1728
+ const opener = process.platform === 'darwin' ? 'open'
1729
+ : process.platform === 'win32' ? 'start'
1730
+ : 'xdg-open';
1731
+ spawnSync(opener, [outFile], { stdio: 'ignore', detached: true });
1732
+ console.log(`[ijfw] Guide rendered to ${outFile}`);
1733
+ return;
1734
+ }
1735
+
1736
+ // Terminal: pipe through less -R when available + interactive, else dump to stdout.
1737
+ // If less exits non-zero (e.g. user hits q, or less is broken), fall back to
1738
+ // plain stdout so the content is never silently swallowed.
1739
+ const lessAvailable = spawnSync('less', ['--version'], { stdio: 'ignore' }).status === 0;
1740
+ if (lessAvailable && process.stdout.isTTY) {
1741
+ const lessRes = spawnSync('less', ['-R'], { input: md, stdio: ['pipe', 'inherit', 'inherit'] });
1742
+ if (lessRes.status !== 0 && lessRes.status !== null) {
1743
+ process.stdout.write(md);
1744
+ }
1745
+ } else {
1746
+ process.stdout.write(md);
1747
+ }
1748
+ }
1749
+
1750
+ // ---------------------------------------------------------------------------
1751
+ // Main
1752
+ // ---------------------------------------------------------------------------
1753
+ // Only dispatch when run directly. When imported as a module (e.g. by tests),
1754
+ // skip the CLI entry so the test runner can use exported helpers.
1755
+
1756
+ // Two-layer check:
1757
+ // 1. pathToFileURL normalizes Windows drive paths (C:\...) and MSYS-style
1758
+ // paths (/c/...) into the same file:///C:/... form that import.meta.url
1759
+ // uses on Git Bash / MINGW64, where literal string interpolation breaks.
1760
+ // 2. Realpath fallback for macOS symlink hops (/tmp -> /private/tmp etc.)
1761
+ // that would otherwise make a string-equality check spuriously false.
1762
+ const isMainModule = (() => {
1763
+ try {
1764
+ if (!process.argv[1]) return false;
1765
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) return true;
1766
+ let argvPath = process.argv[1];
1767
+ let metaPath = fileURLToPath(import.meta.url);
1768
+ try { argvPath = realpathSync(argvPath); } catch { /* */ }
1769
+ try { metaPath = realpathSync(metaPath); } catch { /* */ }
1770
+ return argvPath === metaPath;
1771
+ } catch {
1772
+ return false;
1773
+ }
1774
+ })();
1775
+
1776
+ if (isMainModule) {
1777
+ const parsed = parseArgs(process.argv);
1778
+
1779
+ if (parsed.cmd === 'guide') {
1780
+ await handleGuide(parsed.browser);
1781
+ process.exit(0);
1782
+ }
1783
+
1784
+ if (parsed.cmd === 'help') {
1785
+ printUsage();
1786
+ process.exit(0);
1787
+ }
1788
+
1789
+ if (parsed.cmd === 'status') {
1790
+ cmdStatus(process.cwd(), parsed).catch(err => { console.error(err.message); process.exit(1); });
1791
+ } else if (parsed.cmd === 'demo') {
1792
+ cmdDemo().catch(err => { console.error(err.message); process.exit(1); });
1793
+ } else if (parsed.cmd === 'cross') {
1794
+ cmdCross(parsed).catch(err => { console.error(err.message); process.exit(1); });
1795
+ } else if (parsed.cmd === 'cross-project-audit') {
1796
+ cmdCrossProjectAudit(parsed).catch(err => { console.error(err.message); process.exit(1); });
1797
+ } else if (parsed.cmd === 'import') {
1798
+ cmdImport(parsed).catch(err => { console.error(err.message); process.exit(1); });
1799
+ } else if (parsed.cmd === 'doctor') {
1800
+ cmdDoctor(parsed);
1801
+ } else if (parsed.cmd === 'update') {
1802
+ cmdUpdate(parsed);
1803
+ } else if (parsed.cmd === 'version') {
1804
+ cmdVersion(parsed);
1805
+ } else if (parsed.cmd === 'statusline') {
1806
+ cmdStatusline(parsed.sub);
1807
+ } else if (parsed.cmd === 'config') {
1808
+ cmdConfig(parsed.sub);
1809
+ } else if (parsed.cmd === 'insight') {
1810
+ cmdInsight(parsed.sub);
1811
+ } else if (parsed.cmd === 'receipt') {
1812
+ cmdReceipt(parsed.sub);
1813
+ } else if (parsed.cmd === 'purge-receipts') {
1814
+ cmdPurgeReceipts(process.cwd());
1815
+ } else if (parsed.cmd === 'install') {
1816
+ cmdInstall();
1817
+ } else if (parsed.cmd === 'uninstall') {
1818
+ cmdUninstall();
1819
+ } else if (parsed.cmd === 'preflight') {
1820
+ cmdPreflight();
1821
+ } else if (parsed.cmd === 'dashboard') {
1822
+ cmdDashboard(parsed.sub);
1823
+ } else {
1824
+ console.error(`Unknown command: ${parsed.raw}`);
1825
+ printUsage();
1826
+ process.exit(1);
1827
+ }
1828
+ }
1829
+
1830
+ // --- install / uninstall / preflight / dashboard ---
1831
+ // These shell out to the existing scripts/installer modules so there is one
1832
+ // CLI entry point that covers every command named in the README, regardless
1833
+ // of how the user installed (git clone + install.sh OR npm @ijfw/install).
1834
+ // Resolve internal assets across both the repo-clone layout and the
1835
+ // post-install ~/.ijfw layout. Same shape as installer/src/ijfw.js findInTree.
1836
+ // Without this, dispatch paths fail for any user whose CLI shim originates
1837
+ // from npm-global without a sibling scripts/ tree.
1838
+ function repoRootFromCli() {
1839
+ const here = dirname(fileURLToPath(import.meta.url));
1840
+ return join(here, '..', '..');
1841
+ }
1842
+ function findCliAsset(...rel) {
1843
+ const candidates = [
1844
+ join(repoRootFromCli(), ...rel),
1845
+ process.env.IJFW_HOME ? join(process.env.IJFW_HOME, ...rel) : null,
1846
+ join(homedir(), '.ijfw', ...rel),
1847
+ ].filter(Boolean);
1848
+ return candidates.find(p => existsSync(p)) || null;
1849
+ }
1850
+ function cmdInstall() {
1851
+ const script = findCliAsset('scripts', 'install.sh');
1852
+ if (!script) {
1853
+ console.error('install.sh not found. Run `ijfw-install` to deploy ~/.ijfw/, or set IJFW_HOME to your IJFW tree.');
1854
+ process.exit(1);
1855
+ }
1856
+ const res = spawnSync('bash', [script, ...process.argv.slice(3)], { stdio: 'inherit' });
1857
+ process.exit(res.status ?? 1);
1858
+ }
1859
+ function cmdUninstall() {
1860
+ const script = findCliAsset('installer', 'src', 'uninstall.js');
1861
+ if (!script) {
1862
+ console.error('uninstall.js not found. Remove ~/.ijfw manually and strip ijfw keys from ~/.claude/settings.json.');
1863
+ process.exit(1);
1864
+ }
1865
+ const res = spawnSync(process.execPath, [script, ...process.argv.slice(3)], { stdio: 'inherit' });
1866
+ process.exit(res.status ?? 1);
1867
+ }
1868
+ function cmdPreflight() {
1869
+ const script = findCliAsset('scripts', 'check-all.sh');
1870
+ if (!script) {
1871
+ console.error('check-all.sh not found. Run `ijfw-install` to deploy ~/.ijfw/, or set IJFW_HOME to your IJFW tree.');
1872
+ process.exit(1);
1873
+ }
1874
+ const res = spawnSync('bash', [script, ...process.argv.slice(3)], { stdio: 'inherit' });
1875
+ process.exit(res.status ?? 1);
1876
+ }
1877
+ function cmdDashboard(sub) {
1878
+ const script = findCliAsset('scripts', 'dashboard', 'bin.js');
1879
+ if (!script) {
1880
+ console.error('dashboard/bin.js not found. Run `ijfw-install` to deploy ~/.ijfw/, or set IJFW_HOME to your IJFW tree.');
1881
+ process.exit(1);
1882
+ }
1883
+ const res = spawnSync(process.execPath, [script, sub], { stdio: 'inherit' });
1884
+ process.exit(res.status ?? 1);
1885
+ }