Repository navigation
Expand file tree
/
Copy patheb.mjs
More file actions
650 lines (639 loc) · 44.4 KB
/
Copy patheb.mjs
File metadata and controls
650 lines (639 loc) · 44.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
#!/usr/bin/env node
// SourceCompass CLI (`eb`). All outputs go under this folder; the source repo is
// only ever read through git plumbing (see src/git.mjs).
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { snapshot } from './src/snapshot.mjs';
import { buildIndex } from './src/build.mjs';
import { freshness, whichBuild } from './src/freshness.mjs';
import { query, formatQuery, formatDest, formatImpact } from './src/query.mjs';
import { cardFor, startHere } from './src/cards.mjs';
import { slimForViewer, viewerHtml } from './src/viewer.mjs';
import { redactDeep } from './src/sanitize.mjs';
import { assertInside, assertOutputRoot } from './src/policy.mjs';
import { clean, escapeHtml } from './src/sanitize.mjs';
import { spawn } from 'node:child_process';
import { resolveAreas, loadWorkflows } from './src/views.mjs';
import { loadLifecycle } from './src/lifecycle.mjs';
import { buildModules } from './src/modules.mjs';
import { exportObsidian } from './src/obsidian.mjs';
import { exportUa } from './src/ua-projection.mjs';
import { context as agentContext, map as agentMap, moduleText, workflowText, controlText, lifecycleText, envelope, intentWarning } from './src/agent.mjs';
import { opLog, lsTree, diffTree, commitExists, revParse } from './src/git.mjs';
import { readLedger } from './src/ledger.mjs';
import { loadCatalog, boardFromCatalog, catalogText } from './src/catalog.mjs';
import { brief } from './src/brief.mjs';
import { loadArchitecture, homesByWorkflow, architectureText, placeText, checkRoutingCases, requirementText, CARD_SECTIONS } from './src/architecture.mjs';
import { loadPlatform, platformText } from './src/platform.mjs';
import { loadExamples, exampleText } from './src/examples.mjs';
import { draftConfig } from './src/init.mjs';
import { agentSnippet, agentSetup } from './src/snippets.mjs';
import { recordInputs, inputFreshness, toolFingerprint, listInputs } from './src/inputs.mjs';
import { propose, listProposals, resolveProposal } from './src/proposals.mjs';
import { bindCheckout, RefuseError, rootCommitOfRepo } from './src/checkout.mjs';
import { locateRequirement } from './src/requirements.mjs';
import { addEvidence, evidenceFor, listEvidence, recordLine } from './src/evidence.mjs';
import { loadFindings, findingsText } from './src/findings.mjs';
import { loadMaps } from './src/maps.mjs';
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const EVIDENCE_USAGE = 'usage: eb evidence add --in <name> --req <id> --expect-rev <n> --status PASS|FAIL|NOT_RUN|N/A --layer <layer> --env <environment> --source-sha <sha> --receipt <path or URL> [--eval <evaluation id>] [--by <who>]\n eb evidence list --in <name> [--req <id>]';
const argv = process.argv.slice(2);
const cmd = argv.shift();
const opt = (k, d) => { const i = argv.indexOf(`--${k}`); if (i < 0) return d; const v = argv[i + 1]; argv.splice(i, 2); return v; };
const flag = (k) => { const i = argv.indexOf(`--${k}`); if (i < 0) return false; argv.splice(i, 1); return true; };
function instanceDir(name) {
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(String(name))) throw new Error(`invalid instance name: ${name}`);
if (name === 'demo') return assertInside(ROOT, path.join(ROOT, 'out', 'demo'));
return assertInside(ROOT, path.join(ROOT, 'instances', name));
}
function defaultInstance() {
const dir = path.join(ROOT, 'instances');
const names = fs.existsSync(dir) ? fs.readdirSync(dir).filter((n) => fs.existsSync(path.join(dir, n, 'brain.config.json'))) : [];
return names.length === 1 ? names[0] : 'demo';
}
// What a record's source is compared with: the commit the map was built at, and the checkout that holds it.
const evidenceCtx = (index, name) => ({ builtSha: index.manifest.source.sha, repo: loadConfig(name).repo });
function loadConfig(name) {
const f = path.join(instanceDir(name), 'brain.config.json');
if (!fs.existsSync(f)) throw new Error(`no config at ${f}`);
return JSON.parse(fs.readFileSync(f, 'utf8'));
}
// Write to a temp file, then rename: a reader never sees a half-written file.
function write(root, rel, content) {
const target = assertInside(root, path.join(root, rel));
fs.mkdirSync(path.dirname(target), { recursive: true });
const tmp = `${target}.tmp-${process.pid}`;
// 'wx' never follows a pre-existing file or link at the temp path; a leftover is removed
// (rm removes a link itself, not its target) and creation is retried once.
try { fs.writeFileSync(tmp, content, { flag: 'wx' }); }
catch (e) { if (e.code !== 'EEXIST') throw e; fs.rmSync(tmp, { force: true }); fs.writeFileSync(tmp, content, { flag: 'wx' }); }
for (let i = 0; ; i++) {
try { fs.renameSync(tmp, target); break; }
catch (e) { // Windows refuses to replace a file another process has open for a moment
if (i >= 40 || !['EPERM', 'EBUSY', 'EACCES'].includes(e.code)) { fs.rmSync(tmp, { force: true }); throw e; }
sleep(50);
}
}
return target;
}
// One build per instance at a time, like git's index.lock. The lock file holds its owner's token
// ("pid:uuid"). A second caller waits for it to go away. A lock is never taken over
// automatically, because no plain-file takeover is race-free: if its owner process is gone, the
// build stops and names the file for a person to delete. A live build is never displaced.
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
// null only when the lock is gone; any other read error is reported, never retried blindly.
const readLock = (f) => {
try { return fs.readFileSync(f, 'utf8'); }
catch (e) { if (e.code === 'ENOENT') return null; throw new LockError(`cannot read the build lock ${f} (${e.code}); check it, then delete it if no build is running`); }
};
const ownerAlive = (token) => {
const pid = Number(String(token).split(':')[0]);
if (!Number.isInteger(pid) || pid <= 0) return false;
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
};
export class LockError extends Error {}
// A design input that cannot be parsed stops the build; the last valid map stays in place.
export class BuildRefused extends Error {}
function withBuildLock(name, fn, { waitMs = 120000 } = {}) {
const lock = assertInside(ROOT, path.join(ROOT, '.cache', 'instances', `${name}.lock`));
fs.mkdirSync(path.dirname(lock), { recursive: true });
const token = `${process.pid}:${crypto.randomUUID()}`;
const t0 = Date.now();
let waited = false;
for (;;) {
try { fs.writeFileSync(lock, token, { flag: 'wx' }); break; }
catch (e) {
if (e.code !== 'EEXIST') throw e;
if (Date.now() - t0 > waitMs) throw new LockError(`the build lock ${lock} is still held after ${Math.round(waitMs / 1000)} s. If no build of ${name} is running, delete it and try again.`);
const seen = readLock(lock);
if (seen !== null) {
const tokenOk = /^\d+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(seen);
// A malformed token or a dead owner: a crashed build. Say so; do not touch it.
if (!tokenOk || !ownerAlive(seen)) throw new LockError(`a previous build of ${name} stopped without releasing its lock. If no build is running, delete ${lock} and try again.`);
waited = true;
}
sleep(250); // also after a release race, so a busy lock never spins
}
}
// Nobody else removes or replaces a lock they do not own, so reading then deleting ours is safe.
try { return fn(waited); } finally { if (readLock(lock) === token) fs.rmSync(lock, { force: true }); }
}
// index.json and texts.json carry the same buildId. A reader that catches a rebuild between the
// two files retries instead of pairing a new index with old texts.
function loadIndex(name) {
const dir = instanceDir(name);
for (let i = 0; ; i++) {
const index = JSON.parse(fs.readFileSync(path.join(dir, 'index.json'), 'utf8'));
const tf = path.join(dir, 'texts.json');
const raw = fs.existsSync(tf) ? JSON.parse(fs.readFileSync(tf, 'utf8')) : [];
// Older builds wrote a bare array and no buildId: accept that pair only when BOTH are old.
const legacyPair = !index.buildId && Array.isArray(raw);
const samePair = index.buildId && !Array.isArray(raw) && raw.buildId === index.buildId;
if (legacyPair || samePair) return { index, texts: legacyPair ? raw : raw.texts || [] };
if (i >= 50) throw new Error(`the map for ${name} is being rebuilt right now (index and texts differ); try again`);
sleep(100);
}
}
export function build(name, opts = {}) {
return withBuildLock(name, (waited) => {
// Someone else built while we waited: use theirs if it is current.
if (waited && opts.skipIfCurrent) {
const dir = instanceDir(name);
if (fs.existsSync(path.join(dir, 'index.json'))) {
const { index } = loadIndex(name);
const fr = freshness(loadConfig(name), index, { toolSha256: toolFingerprint(ROOT) });
if (!fr.staleAll && fr.against && fr.indexSha === fr.against && inputFreshness(index, dir, loadConfig(name)).state === 'FRESH') return { index, fresh: fr, dir, reused: true };
}
}
return buildLocked(name, opts);
});
}
function buildLocked(name, { ref, quiet, checkoutLabel } = {}) {
const t0 = Date.now();
const config = loadConfig(name);
const dir = assertOutputRoot(ROOT, instanceDir(name), config.repo);
const cacheRoot = assertOutputRoot(ROOT, path.join(ROOT, '.cache', 'instances', name), config.repo);
for (const inp of listInputs(dir, config)) {
if (inp.sha256 === null) continue;
try { JSON.parse(fs.readFileSync(path.join(dir, inp.rel), 'utf8').replace(/^\uFEFF/, '')); }
catch (e) { throw new BuildRefused(`${inp.rel} is not valid JSON (${String(e.message).slice(0, 100)}); the previous map is kept. Fix the file, then run eb build --in ${name}`); }
}
const snap = snapshot(config, cacheRoot, { ref });
const index = redactDeep(buildIndex(config, snap));
index.builtAt = new Date().toISOString();
index.buildId = crypto.randomUUID();
index.areas = resolveAreas(config.areas, index.destinations);
index.workflows = redactDeep(loadWorkflows(path.join(dir, 'workflows'), index.destinations));
// Intent files named in the config must stay inside this instance's folder.
index.moduleMap = redactDeep(buildModules(index, config, assertInside(dir, path.join(dir, config.intentFile || path.join('intent', 'modules.json')))));
// A workflow catalog, when present, is the single source: the start-to-finish board is a view of it.
index.catalog = redactDeep(loadCatalog(assertInside(dir, path.join(dir, config.catalogFile || path.join('intent', 'workflows.json')))));
index.lifecycle = index.catalog ? redactDeep(boardFromCatalog(index.catalog))
: redactDeep(loadLifecycle(assertInside(dir, path.join(dir, config.lifecycleFile || path.join('intent', 'lifecycle.json'))), index));
// A target module architecture, when present, gives every catalog workflow a home module.
const archFile = assertInside(dir, path.join(dir, config.architectureFile || path.join('intent', 'architecture.json')));
index.architecture = redactDeep(loadArchitecture(archFile, index.catalog, index.moduleMap));
// Reviewed routing cases must still reach their expected home; a failure is an architecture problem.
if (index.architecture) index.architecture.problems.push(...checkRoutingCases(index));
// A platform design, when present: ports, flows, growth stages, decisions and architecture-document coverage.
const platFile = assertInside(dir, path.join(dir, config.platformFile || path.join('intent', 'platform.json')));
index.platform = redactDeep(loadPlatform(platFile));
// Worked examples, checked against the requirement ids they cite.
const reqIds = new Set([...(index.architecture?.modules || []).flatMap((m) => (m.contract?.doneWhen || []).map((r) => r.id)), ...(index.architecture?.contractRules || []).map((r) => r.id), ...(index.platform?.ports || []).flatMap((p) => p.doneWhen.map((r) => r.id))]);
index.examples = redactDeep(loadExamples(assertInside(dir, path.join(dir, config.examplesFile || path.join('intent', 'examples.json'))), reqIds.size ? reqIds : null));
// Review findings mapped to requirement revisions; owners are checked against the plan file at the built commit.
const reqMap = new Map([...(index.architecture?.modules || []).flatMap((m) => m.contract?.doneWhen || []), ...(index.architecture?.contractRules || []), ...(index.platform?.ports || []).flatMap((p) => p.doneWhen)].map((r) => [r.id, r]));
let ledger = null;
let ledgerError = '';
if (config.ledger?.file) { try { ledger = readLedger(config.repo, index.manifest.source.sha, config.ledger.file, config.policy); } catch (e) { ledgerError = String(e.message).slice(0, 120); } }
index.findings = redactDeep(loadFindings(assertInside(dir, path.join(dir, config.findingsFile || path.join('intent', 'findings.json'))), { reqs: reqMap, ledger }));
if (index.findings && ledgerError) index.findings.problems.push(`the plan file could not be read (${ledgerError}); finding owners are not checked against the ledger`);
if (index.findings && ledger && (ledger.missing || ledger.unreadable)) index.findings.problems.push(`the plan file ${ledger.file} is ${ledger.missing ? 'missing at the built commit' : ledger.unreadable}; finding owners are not checked against the ledger`);
// Hand-declared design maps: drawn by the Maps tab, never read as code truth.
index.maps = redactDeep(loadMaps(assertInside(dir, path.join(dir, config.mapsFile || path.join('intent', 'maps.json')))));
if (index.architecture && index.lifecycle?.fromCatalog) {
const homes = homesByWorkflow(index.architecture);
for (const p of index.lifecycle.phases) for (const c of p.capabilities) { c.home = homes[c.workflow]?.home || []; c.alsoIn = homes[c.workflow]?.also || []; }
}
const texts = [];
for (const [p, f] of snap.facts) {
if (!config.surfaces.some((s) => p.startsWith(s.root)) && !(config.uiRoots || []).some((u) => p.startsWith(u))) continue;
for (const t of f.texts || []) texts.push({ t: t.t, at: `${p}:${t.line}` });
}
index.inputs = recordInputs(dir, config, ROOT);
const fresh = freshness(config, index, { toolSha256: index.inputs.toolSha256 });
fresh.intent = inputFreshness(index, dir, config);
// A page built for publication names its checkout neutrally: the absolute path is a private fact of this machine.
fresh.checkout = { label: `${checkoutLabel || `configured ${config.repo}`} (at build time)` };
write(dir, 'texts.json', JSON.stringify({ buildId: index.buildId, texts: redactDeep(texts) }));
write(dir, 'index.json', JSON.stringify(index));
write(dir, 'records.jsonl', index.destinations.map((d) => JSON.stringify(d)).join('\n') + '\n');
fs.rmSync(assertInside(dir, path.join(dir, 'cards')), { recursive: true, force: true });
for (const d of index.destinations) write(dir, `cards/${d.id.replace(/[^A-Za-z0-9._-]+/g, '_')}.md`, clean(cardFor(d, index)));
write(dir, 'START-HERE.md', clean(startHere(index, { instance: name, cli: `node ${path.join(ROOT, 'eb.mjs')}` })));
write(dir, 'brain.html', viewerHtml(slimForViewer(index, fresh, { evidence: listEvidence(dir), repo: config.repo }), clean(`SourceCompass — ${index.manifest.source.name}`)));
const ops = [...new Set(opLog)];
write(dir, 'build-receipt.json', JSON.stringify(redactDeep({ sha: index.manifest.source.sha, builtAt: index.builtAt, ms: Date.now() - t0, stats: index.stats, freshness: fresh.state, gitOperations: ops, outputsUnder: dir }), null, 1));
if (!quiet) console.log(`built ${name} @${index.manifest.source.sha.slice(0, 8)} in ${Date.now() - t0} ms: ${JSON.stringify(index.stats.bySurface)} destinations, ${index.stats.deadLinks} dead links, ${index.stats.tables} tables; freshness ${fresh.state}\n→ ${dir}`);
return { index, fresh, dir };
}
// The context of one answer: the index, the checkout it is checked against (the caller's, or the one
// named with --repo; never a default instance across repositories), source freshness against that
// checkout, and the design-input freshness.
function answer(name, { explicitIn, repoOpt } = {}) {
const config = loadConfig(name);
const { index, texts } = loadIndex(name);
const checkout = bindCheckout({ config, index, instance: name, explicitIn, repoOpt, cwd: process.cwd() });
const fr = freshness({ ...config, repo: checkout.root }, index, { toolSha256: toolFingerprint(ROOT) });
fr.intent = inputFreshness(index, instanceDir(name), config);
fr.checkout = checkout;
return { config, index, texts, fr, checkout };
}
const sourceMoved = (fr) => fr.staleAll || (fr.against && fr.indexSha !== fr.against);
// The requirements as the design files hold them now (for proposals, when the design moved since the build).
// A design file that does not parse leaves staleness UNKNOWN rather than reading every requirement as gone.
function designNow(name, config, index) {
const dir = instanceDir(name);
const bad = listInputs(dir, config).filter((inp) => {
if (inp.sha256 === null) return false;
try { JSON.parse(fs.readFileSync(path.join(dir, inp.rel), 'utf8').replace(/^\uFEFF/, '')); return false; } catch { return true; }
});
if (bad.length) return { unknown: `${bad.map((x) => x.rel).join(', ')} cannot be parsed` };
const at = (key, file) => assertInside(dir, path.join(dir, config[key] || path.join('intent', file)));
return { current: { architecture: loadArchitecture(at('architectureFile', 'architecture.json'), index.catalog, index.moduleMap), platform: loadPlatform(at('platformFile', 'platform.json')) } };
}
// Before the first build there is no index to bind against: bind against the configured checkout's
// repository, and refuse to build for another repository or from another checkout.
function bindBeforeBuild(name, { explicitIn, repoOpt }) {
const config = loadConfig(name);
const b = bindCheckout({ config, index: null, want: rootCommitOfRepo(config.repo), instance: name, explicitIn, repoOpt, cwd: process.cwd() });
if (!b.configured) throw new RefuseError(`no map yet for ${name}; build it from the configured checkout (${config.repo}) with eb build --in ${name}`);
return b;
}
// Why a map is stale, first the design inputs, then the source changes behind the stale records.
function staleReasons(fr) {
const out = [];
if (fr.intent && fr.intent.state !== 'FRESH') out.push(...(fr.intent.changes?.length ? fr.intent.changes : [`design inputs ${fr.intent.state}`]));
if (!String(fr.state).startsWith('FRESH')) for (const s of fr.stale || []) out.push(...s.reasons);
if (!out.length) out.push(String(fr.state));
return [...new Set(out)];
}
// A copy of the saved page with a fixed banner right after <body>.
function staleView(html, index, reasons) {
const msg = clean(`STALE: this page describes ${index.manifest.source.sha.slice(0, 8)} (built ${index.builtAt}); the checkout has changed (${reasons.join('; ')}). Do not rely on it; rebuild with eb open.`);
const banner = `<div role="alert" style="position:sticky;top:0;z-index:2147483647;background:#b00020;color:#fff;font:700 16px/1.4 system-ui,sans-serif;padding:12px 16px;border-bottom:3px solid #fff">${escapeHtml(msg)}</div>`;
return /<body[^>]*>/.test(html) ? html.replace(/<body[^>]*>/, (m) => `${m}\n${banner}`) : banner + html;
}
// The state an export was made from travels with it.
function exportStateText(index, fr) {
return [`# Export state`, '', `- Source: ${index.manifest.source.name} @${index.manifest.source.sha}`, `- Built: ${index.builtAt}`, `- Source freshness when exported: ${fr.state}`,
`- Design inputs: ${fr.intent.state}${fr.intent.changes.length ? ` (${fr.intent.changes.join('; ')})` : ''}`, `- Checked against: ${fr.checkout.label}`, '', 'Derived, not authority. Re-check the source before relying on anything here.', ''].join('\n');
}
async function main() {
const explicitIn = argv.includes('--in');
const name = opt('in', null) || defaultInstance();
const bind = () => answer(name, { explicitIn, repoOpt: opt('repo') });
switch (cmd) {
case 'build': build(name, { ref: opt('ref') }); break;
case 'init': {
// Draft a starter config for a repository (read-only), then optionally build it.
const repoArg = opt('repo', argv[0]);
if (!repoArg) throw new Error('usage: eb init --repo <path-to-your-git-repo> [--name <name>] [--build] [--force]');
const repo = path.resolve(repoArg);
if (!fs.existsSync(path.join(repo, '.git'))) throw new Error(`not a git repository (no .git): ${repo}`);
const iname = opt('name') || path.basename(repo).toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^-+/, '').slice(0, 60) || 'my-app';
if (iname === 'demo') throw new Error('the name "demo" is reserved; pass --name <something-else>');
const dir = assertOutputRoot(ROOT, instanceDir(iname), repo);
assertOutputRoot(ROOT, path.join(ROOT, '.cache', 'instances', iname), repo); // the build cache, checked before any write
const cfgFile = path.join(dir, 'brain.config.json');
if (fs.existsSync(cfgFile) && !flag('force')) throw new Error(`${cfgFile} exists; pass --force to replace it`);
const doBuild = flag('build');
const { config, report } = draftConfig(repo, { name: iname });
write(dir, 'brain.config.json', JSON.stringify(config, null, 1) + '\n');
const say = (s) => console.log(clean(s)); // the report shows repo paths: redact them like any shared output
say(`wrote ${cfgFile}\n repo ${repo} @${report.sha.slice(0, 8)} · ${report.files} files · ${report.routeFiles} route file(s)`);
for (const s of config.surfaces) say(` screens: ${s.label} from ${s.routeFile}${s.navConfig ? ` + ${s.navConfig.length} nav config file(s)` : ''}`);
if (config.dataRoots.length) say(` data folders: ${config.dataRoots.slice(0, 6).join(', ')}${config.dataRoots.length > 6 ? ' …' : ''}`);
if (config.consumerRoots.length) say(` tests: ${config.consumerRoots.join(', ')}`);
for (const n of report.notes) say(` note: ${n}`);
say(`these are guesses: check ${path.relative(process.cwd(), cfgFile) || cfgFile}, then`);
if (doBuild) build(iname);
else console.log(` node ${path.relative(process.cwd(), path.join(ROOT, 'eb.mjs')) || 'eb.mjs'} build --in ${iname}`);
break;
}
case 'agent-snippet': {
const target = opt('for', 'claude');
if (!['claude', 'codex', 'any'].includes(target)) throw new Error('--for must be claude, codex or any');
loadConfig(name); // the instance must exist
const { text, hint } = agentSnippet({ eb: path.join(ROOT, 'eb.mjs'), name, target });
console.error(hint);
process.stdout.write(text);
break;
}
case 'q': case 'query': {
const noFresh = flag('no-fresh');
const { index, texts, fr } = bind();
const fresh = noFresh ? null : fr;
// Options are taken out of argv before the remaining words become the query.
const surface = opt('surface');
const n = Number(opt('n', 3));
const asJson = flag('json');
const r = query(index, texts, argv.join(' '), { surface, n, fresh });
console.log(formatQuery(index, r, { json: asJson }));
break;
}
case 'show': {
const { index, fr } = bind();
const id = argv[0];
if (id.includes('#ctl:')) { const t = controlText(index, id); console.log(t ? `${envelope(index, fr)}
${t}` : `no control ${id}`); if (!t) process.exitCode = 1; break; }
const d = index.destinations.find((x) => x.id === id);
if (!d) { console.log(`no destination ${id}. Try: q "<words>"`); process.exitCode = 1; break; }
console.log(`${envelope(index, fr)}\n${formatDest(d, index, fr, { full: true })}`);
break;
}
case 'impact': {
const { index, fr } = bind();
console.log(`${envelope(index, fr)}\n${formatImpact(index, argv[0].replace(/\\/g, '/'), fr)}`);
break;
}
case 'status': {
const { fr } = bind();
console.log(JSON.stringify({ ...fr, stale: fr.stale?.slice(0, 25), incompleteRecords: fr.incompleteRecords?.slice(0, 25) }, null, 1));
break;
}
case 'which-build': {
const { index } = loadIndex(name);
console.log(JSON.stringify(whichBuild(loadConfig(name), index, argv[0]), null, 1));
break;
}
case 'demo': {
const { makeDemoRepo } = await import('./demo/make-demo.mjs');
makeDemoRepo(ROOT);
const publish = flag('publish-docs');
const docsDir = opt('docs-dir', path.join(ROOT, 'docs'));
// The page that will be published names the demo checkout neutrally, never this machine's path.
const { dir } = build('demo', publish ? { checkoutLabel: 'configured demo checkout' } : {});
if (publish) {
const target = assertInside(ROOT, path.resolve(docsDir, 'demo', 'index.html'));
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(dir, 'brain.html'), target);
console.log(`copied viewer → ${path.relative(ROOT, target).split(path.sep).join('/')}`);
}
console.log(`open ${path.join(dir, 'brain.html')}`);
break;
}
case 'context': {
// The diagnostic: it reports where the caller is relative to the map and never refuses.
const { index } = loadIndex(name);
const config = loadConfig(name);
const intent = inputFreshness(index, instanceDir(name), config);
console.log(JSON.stringify(agentContext(config, index, opt('repo', process.cwd()), { intent, toolSha256: toolFingerprint(ROOT) }), null, 1));
break;
}
case 'brief': {
// Planning capsule. Rebuilds first when the repo has new commits (read-only, seconds);
// uncommitted edits cannot be built and are reported per screen instead.
const noRefresh = flag('no-refresh');
const repoOpt = opt('repo');
const has = fs.existsSync(path.join(instanceDir(name), 'index.json'));
if (!has) bindBeforeBuild(name, { explicitIn, repoOpt });
const first = has ? answer(name, { explicitIn, repoOpt }) : null;
const before = first?.fr || null;
let refreshed = '';
const needs = !before || sourceMoved(before) || before.intent.state !== 'FRESH';
if (needs && first && !first.checkout.configured) refreshed = ' · NOT refreshed (your checkout is not the one this map is built from; this brief is checked against your checkout, so re-read its sources)';
else if (!noRefresh && needs) {
try {
const r = build(name, { ref: 'HEAD', quiet: true, skipIfCurrent: true });
refreshed = r.reused ? ' · another build refreshed it while this brief waited' : ' · rebuilt from HEAD for this brief';
} catch (e) {
if (!(e instanceof LockError || e instanceof BuildRefused) || !has) throw e;
refreshed = ` · NOT refreshed (${e.message}); this brief uses the older map, so re-read its sources`;
}
}
const { config, index, texts, fr, checkout } = answer(name, { explicitIn, repoOpt });
const repo = checkout.root;
const head = fr.against || index.manifest.source.sha;
const ledger = config.ledger?.file ? readLedger(repo, head, config.ledger.file, config.policy) : null;
const headPaths = new Set(lsTree(repo, head).map((e) => e.path));
const asOf = index.lifecycle?.asOfSha;
const boardSha = asOf && /^[0-9a-f]{7,40}$/i.test(asOf) && commitExists(repo, asOf) ? revParse(repo, asOf) : null;
const changedSinceBoard = boardSha ? new Set(diffTree(repo, boardSha, head).flatMap((c) => [c.path, c.from].filter(Boolean))) : null;
console.log(`${envelope(index, fr)}${refreshed}\n${intentWarning(fr, name)}${brief(index, texts, argv.join(' '), { fresh: fr, ledger, headPaths, changedSinceBoard, boardSha })}`);
break;
}
case 'platform': {
const { index, fr } = bind();
console.log(`${envelope(index, fr)}\n${intentWarning(fr, name)}${platformText(index, argv[0])}`);
break;
}
case 'arch': case 'place': {
const section = flag('full') ? 'all' : opt('section', null);
const page = Number(opt('page', 1)) || 1;
const { index, fr } = bind();
const body = intentWarning(fr, name) + (cmd === 'arch' ? architectureText(index, argv[0], { section, page }) : placeText(index, argv.join(' ')));
console.log(`${envelope(index, fr)}
${body}`);
break;
}
case 'example': {
const { index, fr } = bind();
console.log(`${envelope(index, fr)}\n${intentWarning(fr, name)}${exampleText(index, argv[0])}`);
break;
}
case 'req': {
// One requirement in full: a module done-when item, a shared rule or a platform done-when item.
const { index, fr } = bind();
const id = argv[0];
const loc = locateRequirement(index, id);
const text = loc && requirementText(index, id, { evidence: evidenceFor(instanceDir(name), loc.r, evidenceCtx(index, name)) });
if (!text) { console.log(`no requirement "${id}". Ids look like <module>.DW<n>, R<nn> or <port>.DW<n>; eb arch <module> lists them.`); process.exitCode = 1; break; }
console.log(`${envelope(index, fr)}\n${intentWarning(fr, name)}${text}`);
break;
}
case 'propose': {
// A revision-checked proposal to change one requirement. It never edits the design files.
const id = opt('id');
const expectRev = Number(opt('expect-rev'));
const file = opt('file');
const why = opt('why', '');
const by = opt('by', 'unnamed agent');
if (!id || !Number.isInteger(expectRev) || !file) throw new Error('usage: eb propose --in <name> --id <requirement id> --expect-rev <n> --file <new text file> --why "<reason>" [--by <who>]');
// Refused unless the design inputs are FRESH against the build: there is no --allow-stale for a proposal.
const { index, fr } = bind();
const r = propose({ dir: instanceDir(name), index, intent: fr.intent, id, expectRev, text: fs.readFileSync(path.resolve(file), 'utf8').trim(), why, by });
console.log(`proposed ${id} r${expectRev + 1} (${path.basename(r.file)})${r.competing ? ` · COMPETING with ${r.competing} other proposal${r.competing > 1 ? 's' : ''} for the same revision: a person resolves them` : ''}. Nothing in the design changed; the design's owner applies an accepted proposal.`);
break;
}
case 'proposals': {
// Each proposal is checked against the requirement as the design holds it now, even before a rebuild.
// `proposals resolve` records how one ended; `proposals` lists the pending ones (--all: the resolved too).
const resolving = argv[0] === 'resolve' ? argv.shift() : '';
const all = flag('all');
const rfile = resolving ? opt('file') : null;
const as = resolving ? opt('as') : null;
const by = resolving ? opt('by') : null;
const why = resolving ? opt('why', '') : '';
const byProposal = resolving ? opt('by-proposal', '') : '';
if (resolving && (!rfile || !as || !by)) throw new Error('usage: eb proposals resolve --in <name> --file <proposal file name> --as accepted|rejected|superseded --by <who> [--why "<reason>"] [--by-proposal <file>]');
const { config, index, fr } = bind();
const now = fr.intent.state === 'FRESH' ? {} : designNow(name, config, index);
if (resolving) {
const r = resolveProposal({ dir: instanceDir(name), index, current: now.current || null, unknown: now.unknown || '', file: rfile, as, by, why, byProposal });
console.log(`resolved ${r.record.id} r${r.record.fromRev} → r${r.record.toRev} (${path.basename(r.file)}) as ${as} by ${r.record.resolvedBy}. The proposal record is kept; the design files were not touched.${r.warning ? `\nWARNING: ${r.warning}` : ''}`);
break;
}
const warn = fr.intent.state === 'FRESH' ? '' : `WARNING: design inputs changed since the build (${fr.intent.changes.join('; ')}); proposals are checked against the design files as they are now. Run \`eb build --in ${name}\` before proposing.\n`;
console.log(`${warn}${listProposals({ dir: instanceDir(name), index, current: now.current || null, unknown: now.unknown || '', all })}`);
break;
}
case 'evidence': {
// Evidence records: one file per record under the instance's evidence folder. Not a design input.
const sub = argv.shift();
if (sub === 'add') {
const o = { reqId: opt('req'), expectRev: Number(opt('expect-rev')), status: opt('status'), layer: opt('layer'), env: opt('env'), sourceSha: opt('source-sha'), receipt: opt('receipt'), evalId: opt('eval', ''), by: opt('by', 'unnamed agent') };
if (!o.reqId || !Number.isInteger(o.expectRev) || !o.status || !o.layer || !o.env || !o.sourceSha || !o.receipt) throw new Error(EVIDENCE_USAGE);
// Refused unless the design inputs are FRESH against the build, like a proposal; evidence itself never makes the map stale.
const { index, fr } = bind();
const r = addEvidence({ dir: instanceDir(name), index, intent: fr.intent, repo: loadConfig(name).repo, ...o });
console.log(`recorded ${r.record.status} at layer ${r.record.layer} for ${o.reqId} r${r.record.reqRev} (${path.basename(r.file)}). It shows only that layer of that revision; it edited no requirement.`);
} else if (sub === 'list') {
const reqId = opt('req', '');
bind();
const { records, unreadable } = listEvidence(instanceDir(name), reqId);
const L = records.map((x) => `${x.reqId} r${x.reqRev} ${recordLine(x)}`);
if (unreadable.length) L.push(`${unreadable.length} evidence file(s) cannot be read: ${unreadable.slice(0, 5).join(', ')}`);
console.log(L.length ? L.join('\n') : 'no evidence recorded');
} else throw new Error(EVIDENCE_USAGE);
break;
}
case 'findings': {
// Review findings traced to requirement revisions, evaluations, evidence and owners.
const source = opt('source', '');
const disposition = opt('disposition', '');
const { index, fr } = bind();
const dir = instanceDir(name);
console.log(`${envelope(index, fr)}\n${intentWarning(fr, name)}${findingsText(index, { id: argv[0] || '', source, disposition }, { locate: locateRequirement, evidenceFor: (r) => evidenceFor(dir, r, evidenceCtx(index, name)) })}`);
break;
}
case 'agent-setup': {
// Install, update or remove the agent instructions in a file the user names. --dry-run writes nothing.
const target = opt('for', 'claude');
if (!['claude', 'codex', 'any'].includes(target)) throw new Error('--for must be claude, codex or any');
const file = opt('file');
if (!file) throw new Error('usage: eb agent-setup --in <name> --for claude|codex|any --file <path> [--dry-run] [--remove]');
loadConfig(name);
const dry = flag('dry-run');
const remove = flag('remove');
const abs = path.resolve(file);
// Every existing folder on the way must be a plain folder (no symlink or junction), and the file
// itself must not be a link, dangling or not: nothing is read, replaced or deleted through a link.
for (let d = path.dirname(abs); ; d = path.dirname(d)) {
if (fs.existsSync(d)) {
if (fs.lstatSync(d).isSymbolicLink() || path.resolve(fs.realpathSync.native(d)).toLowerCase() !== path.resolve(d).toLowerCase()) throw new Error(`${d} is a link or junction; agent-setup writes only through plain folders`);
}
if (path.dirname(d) === d) break;
}
let lst = null;
try { lst = fs.lstatSync(abs); } catch { /* absent */ }
if (lst && lst.isSymbolicLink()) throw new Error(`${abs} is a link; agent-setup writes only a plain file`);
let current = null;
if (lst) current = fs.readFileSync(abs, 'utf8');
const r = agentSetup({ eb: path.join(ROOT, 'eb.mjs'), name, target, current, remove });
if (r.action === 'refuse') throw new Error(`${abs}: ${r.reason}`);
const verb = { none: 'no change needed', add: dry ? 'would add' : 'added', update: dry ? 'would update' : 'updated', remove: dry ? 'would remove the block from' : 'removed the block from', delete: dry ? 'would delete' : 'deleted' }[r.action];
if (!dry && (r.action === 'add' || r.action === 'update' || r.action === 'remove')) {
fs.mkdirSync(path.dirname(abs), { recursive: true });
const tmp = `${abs}.tmp-${process.pid}`;
fs.writeFileSync(tmp, r.next, { flag: 'wx' });
fs.renameSync(tmp, abs);
}
if (!dry && r.action === 'delete') fs.rmSync(abs);
process.stdout.write(`${verb} ${abs}\n`);
if (dry && r.next !== undefined) process.stdout.write(`--- the file would read ---\n${r.next}`);
break;
}
case 'map': case 'module': case 'workflow': case 'lifecycle': {
const { index, fr } = bind();
// `workflow` shows a step-level workflow when one has that id, otherwise the catalog entry (or the catalog list).
const legacy = (index.workflows || []).some((w) => w.id === argv[0]);
const body = cmd === 'map' ? agentMap(index) : cmd === 'module' ? moduleText(index, argv.join(' ')) : cmd === 'lifecycle' ? lifecycleText(index, argv[0])
: legacy || (!index.catalog && argv[0]) ? workflowText(index, argv[0]) : catalogText(index.catalog, argv[0]);
console.log(`${envelope(index, fr)}
${intentWarning(fr, name)}${body}`);
break;
}
case 'export-obsidian': {
const dir = instanceDir(name);
const allowStale = flag('allow-stale');
const { index, fr } = bind();
const staleNow = !String(fr.state).startsWith('FRESH') || fr.intent.state !== 'FRESH';
if (staleNow && !allowStale) throw new Error(`the map is ${fr.state.split(' ')[0]} (design inputs ${fr.intent.state}); rebuild first, or pass --allow-stale to export it with its warnings`);
const r = exportObsidian(index, assertInside(dir, path.join(dir, 'obsidian')));
write(dir, 'obsidian/STATE.md', exportStateText(index, fr));
console.log(`wrote ${r.files} generated notes and canvases under ${r.root} (your notes folder is untouched), with STATE.md. Open ${r.root} as a vault in Obsidian.`);
break;
}
case 'export-ua': {
const dir = instanceDir(name);
const allowStale = flag('allow-stale');
const { index, fr } = bind();
const staleNow = !String(fr.state).startsWith('FRESH') || fr.intent.state !== 'FRESH';
if (staleNow && !allowStale) throw new Error(`the map is ${fr.state.split(' ')[0]} (design inputs ${fr.intent.state}); rebuild first, or pass --allow-stale to export it with its warnings`);
const r = exportUa(index, assertInside(dir, path.join(dir, 'ua-projection')));
write(dir, 'ua-projection/STATE.md', exportStateText(index, fr));
console.log(JSON.stringify(r, null, 1));
break;
}
case 'open': {
// Refresh if the map no longer matches the code, then open the viewer.
let dir = instanceDir(name);
const noRebuild = flag('no-rebuild');
flag('allow-stale'); // accepted, but it never opens a stale map for another checkout (below)
const repoOpt = opt('repo');
const has = fs.existsSync(path.join(dir, 'index.json'));
if (!has) bindBeforeBuild(name, { explicitIn, repoOpt });
const cur = has ? answer(name, { explicitIn, repoOpt }) : null;
const fr = cur ? cur.fr : { state: 'MISSING', intent: { state: 'MISSING' } };
const stale = !String(fr.state).startsWith('FRESH') || fr.intent.state !== 'FRESH';
// The viewer is a saved page of the configured checkout's map, and its saved state says so. It cannot be
// checked against another checkout, so it is never opened for one it is stale for, whatever the flags.
if (stale && cur && !cur.checkout.configured) {
throw new RefuseError(`the map is ${fr.state.split(' ')[0]} for your checkout (${cur.checkout.root}). The viewer describes the configured checkout (${cur.config.repo}), not yours, so it is not opened, even with --allow-stale. Use \`eb brief <topic> --in ${name}\` or \`eb q "<words>" --in ${name}\` from here (each answer is checked against your checkout), or run \`eb open --in ${name}\` from ${cur.config.repo}.`);
}
if (stale && !noRebuild) {
console.log(`map is ${fr.state.split(' ')[0]} (design inputs ${fr.intent.state}); rebuilding from current HEAD…`);
({ dir } = build(name, { ref: 'HEAD' }));
}
let file = path.join(dir, 'brain.html');
// --no-rebuild on a stale map: never a plain saved page. Say so here, and open a copy that carries a
// banner nobody can miss; the saved brain.html itself is not touched.
if (stale && noRebuild && cur) {
if (!fs.existsSync(file)) throw new Error(`no saved page at ${file}; run eb build --in ${name}`);
const why = staleReasons(fr).slice(0, 3);
console.log(`WARNING: the map is STALE (${why.join('; ')}). It describes ${cur.index.manifest.source.sha.slice(0, 8)}, built ${cur.index.builtAt}; the checkout has changed. Do not rely on it. Opening a marked copy (brain.stale-view.html); run \`eb open --in ${name}\` without --no-rebuild to refresh.`);
file = write(dir, 'brain.stale-view.html', staleView(fs.readFileSync(file, 'utf8'), cur.index, why));
}
// EB_NO_LAUNCH: everything up to the launch runs, then nothing is started (tests, headless use).
if (process.env.EB_NO_LAUNCH) { console.log(`EB_NO_LAUNCH is set: not launching ${file}`); break; }
const [cmdName, args] = process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', file]] : process.platform === 'darwin' ? ['open', [file]] : ['xdg-open', [file]];
spawn(cmdName, args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
console.log(`opened ${file}`);
break;
}
default:
console.log(`usage: eb <init|build|open|context|brief|map|q|show|impact|module|workflow|lifecycle|arch|place|req|findings|evidence|propose|proposals|status|which-build|agent-snippet|agent-setup|demo> [--in <instance>] [--repo <checkout>]
init --repo <path> [--name n] [--build] draft a config for your repo (read-only), optionally build it
agent-snippet --in <name> [--for claude|codex|any] print instructions to paste for your coding agent
agent-setup --in <name> --for claude|codex|any --file <path> [--dry-run] [--remove] install or remove those instructions as one managed block
context which repo/worktree am I in; does the index match? (run first)
brief <topic words> planning capsule: board status, plan-file status, what to re-verify, files to read
map ~1k-token high-level map (only when orientation is needed)
module <id> / workflow <id> module intent, gaps and flows / workflow steps
arch [<module>] [--section ${CARD_SECTIONS.join('|')}] [--full] target modules; a module card is compact unless you ask for a section
req <id> one requirement in full: status, approval, history, planned evaluations, evidence, findings
propose --id <id> --expect-rev <n> --file <text> --why <reason> a revision-checked change proposal (never edits the design)
proposals [--all] pending proposals, with competing and stale ones flagged (--all: the resolved ones too)
proposals resolve --file <proposal file> --as accepted|rejected|superseded --by <who> [--why <reason>] [--by-proposal <file>] record how a proposal ended (accepted only once the design holds its exact text)
evidence add --req <id> --expect-rev <n> --status PASS|FAIL|NOT_RUN|N/A --layer <l> --env <e> --source-sha <sha> --receipt <ref> [--eval <id>] [--by <who>] record a check against one requirement revision (never edits the design)
evidence list [--req <id>] the recorded evidence
findings [<finding id>] [--source <id>] [--disposition <d>] review findings: coverage and unresolved, or one finding traced to its source, requirement, evaluations, evidence and owner
example [<id>] a worked example: who does what, where, what travels, what is built today
place <feature words> where a new feature belongs: module, surface, layers, links, rules
platform [<port|flow|stage>|decisions|ssot] platform contracts, growth stages, decisions, SSOT coverage
open refresh the map if stale, then open it in your browser
q "<product words>" [--surface <id>] [--n 3] [--json] short cited answer for agents
show <ID> full record impact <repo/path> what depends on a file
status freshness vs repo which-build <sha|env> is the index about that build?`);
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
// Every terminal message (reports, paths, errors) goes through the same redaction as the files
// it writes. The one exception is agent-snippet's text on stdout: it must carry the exact path.
const raw = { log: console.log, error: console.error, warn: console.warn };
for (const k of Object.keys(raw)) console[k] = (...a) => raw[k](...a.map((x) => (typeof x === 'string' ? clean(x) : x)));
main().catch((e) => { console.error(`eb: ${e instanceof RefuseError ? 'refused: ' : ''}${e.message}`); process.exitCode = 1; });
}