@copperbox/why 1.0.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 (48) hide show
  1. package/README.md +168 -0
  2. package/dist/anchor.d.ts +112 -0
  3. package/dist/anchor.js +697 -0
  4. package/dist/anchors.d.ts +101 -0
  5. package/dist/anchors.js +223 -0
  6. package/dist/audit.d.ts +120 -0
  7. package/dist/audit.js +483 -0
  8. package/dist/blame.d.ts +91 -0
  9. package/dist/blame.js +294 -0
  10. package/dist/bundle.d.ts +78 -0
  11. package/dist/bundle.js +188 -0
  12. package/dist/capture.d.ts +76 -0
  13. package/dist/capture.js +462 -0
  14. package/dist/cli.d.ts +11 -0
  15. package/dist/cli.js +641 -0
  16. package/dist/dig-state.d.ts +46 -0
  17. package/dist/dig-state.js +146 -0
  18. package/dist/dig.d.ts +125 -0
  19. package/dist/dig.js +380 -0
  20. package/dist/discover.d.ts +11 -0
  21. package/dist/discover.js +47 -0
  22. package/dist/doctor.d.ts +135 -0
  23. package/dist/doctor.js +263 -0
  24. package/dist/evidence.d.ts +73 -0
  25. package/dist/evidence.js +425 -0
  26. package/dist/export.d.ts +67 -0
  27. package/dist/export.js +106 -0
  28. package/dist/find-symbol.d.ts +19 -0
  29. package/dist/find-symbol.js +267 -0
  30. package/dist/git.d.ts +17 -0
  31. package/dist/git.js +51 -0
  32. package/dist/init.d.ts +24 -0
  33. package/dist/init.js +100 -0
  34. package/dist/lint.d.ts +40 -0
  35. package/dist/lint.js +161 -0
  36. package/dist/serve-assets.d.ts +10 -0
  37. package/dist/serve-assets.js +55 -0
  38. package/dist/serve.d.ts +53 -0
  39. package/dist/serve.js +226 -0
  40. package/dist/trace-range.d.ts +22 -0
  41. package/dist/trace-range.js +216 -0
  42. package/package.json +44 -0
  43. package/ui/app.js +323 -0
  44. package/ui/graph.js +164 -0
  45. package/ui/highlight.js +202 -0
  46. package/ui/story-panel.d.ts +9 -0
  47. package/ui/story-panel.js +125 -0
  48. package/ui/style.css +229 -0
package/dist/cli.js ADDED
@@ -0,0 +1,641 @@
1
+ #!/usr/bin/env node
2
+ // `why` CLI entry point. DESIGN.md is the source of truth for what each
3
+ // subcommand must do.
4
+ import { realpathSync } from "node:fs";
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { basename, dirname, join, resolve } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { parseArgs } from "node:util";
9
+ import { AnchorError, renderAnchorReport, resolveAnchors, writeAnchorUpdates } from "./anchor.js";
10
+ import { CACHE_DIRNAME, ensureSelfIgnoringDir, loadAnchorIndex } from "./anchors.js";
11
+ import { AuditError, auditBundle, parseAnswers, renderAuditReport } from "./audit.js";
12
+ import { BlameTargetError, buildBlameReport, parseBlameTarget, renderBlameReport, } from "./blame.js";
13
+ import { isOneOf, loadBundle } from "./bundle.js";
14
+ import { CaptureError, captureCommit, capturePr, promoteDraft } from "./capture.js";
15
+ import { BundleNotFoundError, resolveBundleRoot } from "./discover.js";
16
+ import { DigError, extractEpisodes, plural, renderEpisodesReport } from "./dig.js";
17
+ import { DigStateError, withDigState } from "./dig-state.js";
18
+ import { buildDoctorReport, renderDoctorReport } from "./doctor.js";
19
+ import { buildEvidencePack, EvidenceError, readEpisodes } from "./evidence.js";
20
+ import { buildGraph, buildUiIndex, EXPORT_TARGETS, ExportError } from "./export.js";
21
+ import { findRepoRoot, InitError, scaffoldBundle, writeCaptureSnippet } from "./init.js";
22
+ import { lintBundle, renderFindings } from "./lint.js";
23
+ import { ServeError, startWhyServer } from "./serve.js";
24
+ import { AssetError } from "./serve-assets.js";
25
+ export const COMMANDS = ["init", "lint", "blame", "anchor", "doctor", "dig", "audit", "capture", "export", "serve"];
26
+ const CONSOLE_IO = { out: console.log, err: console.error };
27
+ const BUNDLE_OPTIONS = {
28
+ bundle: { type: "string" },
29
+ };
30
+ export function usage() {
31
+ return [
32
+ "why — decision archaeology for codebases",
33
+ "",
34
+ "Usage: why <command> [options]",
35
+ "",
36
+ "Commands:",
37
+ " init scaffold a .why/ bundle in the current repo",
38
+ " lint check the bundle against the why schema (DESIGN.md §3)",
39
+ " blame show the decision story behind a file or line range",
40
+ " anchor re-resolve code anchors against HEAD",
41
+ " doctor report lost anchors and stale constraints",
42
+ " dig reconstruct decisions from git/PR history",
43
+ " audit re-verify constraints; flag expired ones",
44
+ " capture draft a concept from a merged PR while the why is fresh",
45
+ " export emit versioned UI-contract JSON (docs/ui-contract.md)",
46
+ " serve browse blame gutter, stories, and graph in a local read-only UI",
47
+ "",
48
+ "Options:",
49
+ " --bundle <path> bundle root to use instead of the nearest .why/",
50
+ " (lint also takes the path as a positional: why lint <path>)",
51
+ " --capture-snippet (init) add the knowledge-capture block to CLAUDE.md",
52
+ " --json (blame, lint, doctor, dig, audit) emit the results as JSON",
53
+ " --check (anchor) CI mode — resolve, write nothing, exit 1 on drift",
54
+ " --concept <id> (anchor) re-anchor a single concept",
55
+ " --episodes (dig) extract commit episodes + tells from git history",
56
+ " --from <rev> (dig --episodes) dig from <rev> instead of the recorded high-water mark",
57
+ " --full (dig --episodes) re-dig all history, ignoring the high-water mark",
58
+ " --evidence <file> (dig) assemble evidence packs from an --episodes JSON file",
59
+ " --evidence-dir <dir> (dig) merge in local exported context (postmortems, chats)",
60
+ " --max-chars <n> (dig) total size budget per evidence pack",
61
+ " --out <file|dir> (dig) write the JSON report to a file (--episodes) or pack output dir (--evidence, default <bundle>/.cache/evidence)",
62
+ " --questions-out <file> (audit) export method:ask constraints as an agent questionnaire",
63
+ " --answers <file> (audit) apply a filled-in questionnaire; no-longer-true expires the constraint",
64
+ " --pr <n> (capture) draft from a merged/closed PR via gh into .why/.drafts/",
65
+ " --commit <sha> (capture) gh-free fallback — draft from a local commit",
66
+ " --promote <draft> (capture) lint-gate a draft and move it into its type directory",
67
+ " --out <file> (export) write the payload to a file instead of stdout",
68
+ " --port <n> (serve) port to bind on 127.0.0.1 (default: a random free port)",
69
+ ].join("\n");
70
+ }
71
+ /** `why init` — scaffold `.why/` at the repo root (DESIGN.md §1, §8). */
72
+ async function runInit({ values, cwd, io }) {
73
+ try {
74
+ const repoRoot = findRepoRoot(cwd);
75
+ const root = await scaffoldBundle(repoRoot);
76
+ const name = basename(repoRoot);
77
+ io.out(`Initialized empty why bundle at ${root}`);
78
+ if (values["capture-snippet"] === true) {
79
+ io.out(`CLAUDE.md: capture snippet ${await writeCaptureSnippet(repoRoot)}`);
80
+ }
81
+ io.out("");
82
+ io.out("Next steps:");
83
+ io.out(` - serve it to agents: npx -y @copperbox/okf-mcp --bundle ${name}=.why --writable`);
84
+ io.out(" - recover the backstory: why dig (see docs/digging.md)");
85
+ return 0;
86
+ }
87
+ catch (e) {
88
+ if (e instanceof InitError) {
89
+ io.err(e.message);
90
+ return 1;
91
+ }
92
+ throw e;
93
+ }
94
+ }
95
+ /** `why lint` — schema checks on top of OKF validation (DESIGN.md §3). */
96
+ async function runLint({ values, bundle, io }) {
97
+ const findings = await lintBundle(bundle);
98
+ if (values.json === true) {
99
+ io.out(JSON.stringify({ root: bundle.root, findings }, null, 2));
100
+ }
101
+ else {
102
+ for (const line of renderFindings(bundle, findings))
103
+ io.out(line);
104
+ }
105
+ return findings.some((f) => f.severity === "error") ? 1 : 0;
106
+ }
107
+ /** `why blame` — the story behind a file or line range (DESIGN.md §7, static). */
108
+ async function runBlame({ values, positionals, bundle, io }) {
109
+ if (positionals.length !== 1) {
110
+ io.err("why blame: expected exactly one target — usage: why blame <path>[:line[-line]]");
111
+ return 2;
112
+ }
113
+ try {
114
+ const target = parseBlameTarget(positionals[0]);
115
+ const { index } = await loadAnchorIndex(bundle);
116
+ const report = buildBlameReport(bundle, target, index);
117
+ if (values.json === true) {
118
+ io.out(JSON.stringify(report, null, 2));
119
+ }
120
+ else {
121
+ for (const line of renderBlameReport(report))
122
+ io.out(line);
123
+ }
124
+ return 0;
125
+ }
126
+ catch (e) {
127
+ if (e instanceof BlameTargetError) {
128
+ io.err(`why blame: ${e.message} — usage: why blame <path>[:line[-line]]`);
129
+ return 2;
130
+ }
131
+ throw e;
132
+ }
133
+ }
134
+ /** `why anchor` — re-resolve every anchor claim against HEAD (DESIGN.md §4). */
135
+ async function runAnchor({ values, positionals, bundle, io }) {
136
+ if (positionals.length > 0) {
137
+ io.err("why anchor: takes no positional arguments — usage: why anchor [--check [--allow-drift]] [--concept <id>]");
138
+ return 2;
139
+ }
140
+ const check = values.check === true;
141
+ const allowDrift = values["allow-drift"] === true;
142
+ if (allowDrift && !check) {
143
+ io.err("why anchor: --allow-drift only means something with --check — usage: why anchor --check --allow-drift");
144
+ return 2;
145
+ }
146
+ try {
147
+ const report = await resolveAnchors(bundle, { concept: values.concept });
148
+ const written = check ? [] : await writeAnchorUpdates(bundle, report);
149
+ for (const line of renderAnchorReport(report, { check, written, allowDrift }))
150
+ io.out(line);
151
+ if (!check)
152
+ return 0;
153
+ // --allow-drift is the PR gate's question (DESIGN.md §4, docs/ci.md): drift
154
+ // is expected on a branch and gets re-stamped from main after the merge, so
155
+ // only an anchor this change *destroys* is worth blocking on.
156
+ return report.results.some((r) => (allowDrift ? r.outcome === "lost" && r.changed : r.changed)) ? 1 : 0;
157
+ }
158
+ catch (e) {
159
+ if (e instanceof AnchorError) {
160
+ io.err(`why anchor: ${e.message}`);
161
+ return 1;
162
+ }
163
+ throw e;
164
+ }
165
+ }
166
+ /** `why doctor` — bundle health report (DESIGN.md §4, §8). Read-only. */
167
+ async function runDoctor({ values, positionals, bundle, io }) {
168
+ if (positionals.length > 0) {
169
+ io.err("why doctor: takes no positional arguments — usage: why doctor [--json]");
170
+ return 2;
171
+ }
172
+ const report = await buildDoctorReport(bundle);
173
+ if (values.json === true) {
174
+ io.out(JSON.stringify(report, null, 2));
175
+ }
176
+ else {
177
+ for (const line of renderDoctorReport(report))
178
+ io.out(line);
179
+ }
180
+ return report.healthy ? 0 : 1;
181
+ }
182
+ const DIG_USAGE = "usage: why dig --episodes [--json] [--from <rev>|--full] [--out <file>] | " +
183
+ "--evidence <episodes.json> [--evidence-dir <dir>] [--max-chars <n>] [--out <dir>]";
184
+ /**
185
+ * `why dig` — either `--episodes` (deterministic episode extraction + tells,
186
+ * DESIGN.md §6 step 1) or `--evidence <episodes.json>` (assemble one evidence
187
+ * pack per episode, DESIGN.md §6 step 2).
188
+ */
189
+ async function runDigEpisodes({ values, bundle, cwd, io }) {
190
+ if (values.from !== undefined && values.full === true) {
191
+ io.err("why dig: pass --from <rev> or --full, not both");
192
+ return 2;
193
+ }
194
+ const repo = dirname(bundle.root);
195
+ const emitReport = async (range) => {
196
+ const report = extractEpisodes(repo, range.from === undefined ? { to: range.head } : { from: range.from, to: range.head });
197
+ const json = JSON.stringify(report, null, 2);
198
+ const out = values.out === undefined ? undefined : resolve(cwd, values.out);
199
+ if (out !== undefined)
200
+ await writeFile(out, json + "\n", "utf8");
201
+ if (values.json === true) {
202
+ io.out(json);
203
+ }
204
+ else if (out !== undefined) {
205
+ io.out(`wrote ${plural(report.episodes.length, "episode")} to ${out}`);
206
+ }
207
+ else {
208
+ for (const line of renderEpisodesReport(report))
209
+ io.out(line);
210
+ }
211
+ };
212
+ try {
213
+ // Range and high-water mark live in dig-state.ts (docs/digging.md): mark →
214
+ // HEAD unless --from/--full override, mark advanced only after emitReport
215
+ // succeeds, unreadable state or an unverifiable mark an explicit error.
216
+ const overrides = {};
217
+ if (values.from !== undefined)
218
+ overrides.from = values.from;
219
+ if (values.full === true)
220
+ overrides.full = true;
221
+ const result = await withDigState(repo, bundle.root, overrides, emitReport);
222
+ // Nothing new since the mark: still emit the (empty) report so --json and
223
+ // --out consumers always get one. The state file is untouched.
224
+ if (!result.emitted)
225
+ await emitReport(result);
226
+ return 0;
227
+ }
228
+ catch (e) {
229
+ if (e instanceof DigError || e instanceof DigStateError) {
230
+ io.err(`why dig: ${e.message}`);
231
+ return 1;
232
+ }
233
+ throw e;
234
+ }
235
+ }
236
+ async function runDig({ values, positionals, bundle, cwd, io }) {
237
+ if (positionals.length > 0) {
238
+ io.err(`why dig: takes no positional arguments — ${DIG_USAGE}`);
239
+ return 2;
240
+ }
241
+ if (values.episodes !== true && (values.from !== undefined || values.full === true)) {
242
+ io.err("why dig: --from/--full set the --episodes range — pass --episodes too");
243
+ return 2;
244
+ }
245
+ if (values.episodes === true && values.evidence !== undefined) {
246
+ io.err(`why dig: --episodes and --evidence are separate modes, pass one — ${DIG_USAGE}`);
247
+ return 2;
248
+ }
249
+ if (values.episodes === true) {
250
+ return runDigEpisodes({ values, positionals, bundle, cwd, io });
251
+ }
252
+ const episodesFile = values.evidence;
253
+ if (episodesFile === undefined) {
254
+ io.err(`why dig: pass a mode — ${DIG_USAGE}`);
255
+ return 2;
256
+ }
257
+ let maxChars;
258
+ if (values["max-chars"] !== undefined) {
259
+ maxChars = Number(values["max-chars"]);
260
+ if (!Number.isInteger(maxChars) || maxChars <= 0) {
261
+ io.err(`why dig: --max-chars must be a positive integer, got "${values["max-chars"]}"`);
262
+ return 2;
263
+ }
264
+ }
265
+ let raw;
266
+ try {
267
+ raw = await readFile(episodesFile, "utf8");
268
+ }
269
+ catch {
270
+ io.err(`why dig: cannot read episodes file ${episodesFile}`);
271
+ return 1;
272
+ }
273
+ try {
274
+ const episodes = readEpisodes(raw, episodesFile);
275
+ const bundleRoot = bundle.root;
276
+ const repo = dirname(bundleRoot);
277
+ const outOverride = values.out;
278
+ const outDir = outOverride ?? join(bundleRoot, CACHE_DIRNAME, "evidence");
279
+ // Packs are derived state; the default location self-ignores like the
280
+ // anchor-index cache. An explicit --out is the user's directory to manage.
281
+ if (outOverride === undefined)
282
+ await ensureSelfIgnoringDir(join(bundleRoot, CACHE_DIRNAME));
283
+ await mkdir(outDir, { recursive: true });
284
+ for (const episode of episodes) {
285
+ const pack = await buildEvidencePack(episode, {
286
+ repo,
287
+ maxChars,
288
+ evidenceDir: values["evidence-dir"],
289
+ });
290
+ const file = join(outDir, `${pack.episodeId.replace(/[^A-Za-z0-9._-]+/g, "-")}.md`);
291
+ await writeFile(file, pack.markdown, "utf8");
292
+ io.out(`wrote ${file} (${pack.markdown.length} chars)`);
293
+ for (const note of pack.unavailable)
294
+ io.out(` unavailable: ${note}`);
295
+ for (const note of pack.clipped)
296
+ io.out(` clipped: ${note}`);
297
+ }
298
+ io.out(`${episodes.length} evidence pack(s) in ${outDir}`);
299
+ return 0;
300
+ }
301
+ catch (e) {
302
+ if (e instanceof EvidenceError) {
303
+ io.err(`why dig: ${e.message}`);
304
+ return 1;
305
+ }
306
+ throw e;
307
+ }
308
+ }
309
+ /**
310
+ * `why audit` — re-verify active constraints; expiry propagates downstream
311
+ * (DESIGN.md §5). Exit 1 when anything newly expired: the CI signal for
312
+ * "the archive learned something".
313
+ */
314
+ async function runAudit({ values, positionals, bundle, cwd, io }) {
315
+ if (positionals.length > 0) {
316
+ io.err("why audit: takes no positional arguments — usage: why audit [--json] [--questions-out <file>] [--answers <file>]");
317
+ return 2;
318
+ }
319
+ try {
320
+ let answers;
321
+ if (values.answers !== undefined) {
322
+ const answersPath = resolve(cwd, values.answers);
323
+ let text;
324
+ try {
325
+ text = await readFile(answersPath, "utf8");
326
+ }
327
+ catch {
328
+ io.err(`why audit: cannot read answers file ${answersPath}`);
329
+ return 1;
330
+ }
331
+ answers = parseAnswers(text);
332
+ }
333
+ const options = {};
334
+ if (answers !== undefined)
335
+ options.answers = answers;
336
+ if (values["questions-out"] !== undefined) {
337
+ options.questionsOut = resolve(cwd, values["questions-out"]);
338
+ }
339
+ const report = await auditBundle(bundle, options);
340
+ if (values.json === true) {
341
+ io.out(JSON.stringify(report, null, 2));
342
+ }
343
+ else {
344
+ for (const line of renderAuditReport(report))
345
+ io.out(line);
346
+ }
347
+ return report.expired.length > 0 ? 1 : 0;
348
+ }
349
+ catch (e) {
350
+ if (e instanceof AuditError) {
351
+ io.err(`why audit: ${e.message}`);
352
+ return 1;
353
+ }
354
+ throw e;
355
+ }
356
+ }
357
+ const CAPTURE_USAGE = "usage: why capture --pr <n> | --commit <sha> | --promote <draft>";
358
+ /**
359
+ * `why capture` — merge-time capture (DESIGN.md open problem #5): draft a
360
+ * concept from a PR (or a commit, gh-free) into `.why/.drafts/`, and promote
361
+ * drafts out editorially, gated on lint. Drafts are never served.
362
+ */
363
+ async function runCapture({ values, positionals, bundle, cwd, io }) {
364
+ if (positionals.length > 0) {
365
+ io.err(`why capture: takes no positional arguments — ${CAPTURE_USAGE}`);
366
+ return 2;
367
+ }
368
+ const modes = ["pr", "commit", "promote"].filter((mode) => values[mode] !== undefined);
369
+ if (modes.length !== 1) {
370
+ io.err(`why capture: pass exactly one mode — ${CAPTURE_USAGE}`);
371
+ return 2;
372
+ }
373
+ try {
374
+ if (values.pr !== undefined) {
375
+ const n = Number(values.pr);
376
+ if (!Number.isInteger(n) || n <= 0) {
377
+ io.err(`why capture: --pr must be a PR number, got "${values.pr}"`);
378
+ return 2;
379
+ }
380
+ return renderCapture(await capturePr(bundle, n), io);
381
+ }
382
+ if (values.commit !== undefined) {
383
+ return renderCapture(await captureCommit(bundle, values.commit), io);
384
+ }
385
+ const result = await promoteDraft(bundle, values.promote, cwd);
386
+ if (!result.promoted) {
387
+ io.err(`why capture: promotion refused — ${result.path} fails lint, draft kept:`);
388
+ for (const f of result.findings)
389
+ io.err(` ${f.severity.padEnd(7)} ${f.rule} ${f.message}`);
390
+ return 1;
391
+ }
392
+ io.out(`promoted → ${result.path}`);
393
+ for (const f of result.findings)
394
+ io.out(` ${f.severity.padEnd(7)} ${f.rule} ${f.message}`);
395
+ return 0;
396
+ }
397
+ catch (e) {
398
+ if (e instanceof CaptureError) {
399
+ io.err(`why capture: ${e.message}`);
400
+ return 1;
401
+ }
402
+ throw e;
403
+ }
404
+ }
405
+ const EXPORT_USAGE = "usage: why export <ui-index|graph> [--out <file>]";
406
+ /**
407
+ * `why export` — the UI data contract payloads (docs/ui-contract.md):
408
+ * `ui-index` (per-file coverage map from the anchor index at HEAD) and
409
+ * `graph` (the bundle as nodes/typed edges). The story payload is
410
+ * `why blame --json`.
411
+ */
412
+ async function runExport({ values, positionals, bundle, cwd, io }) {
413
+ const target = positionals.length === 1 ? positionals[0] : undefined;
414
+ if (!isOneOf(EXPORT_TARGETS, target)) {
415
+ io.err(`why export: pass what to export — ${EXPORT_USAGE}`);
416
+ return 2;
417
+ }
418
+ try {
419
+ let payload;
420
+ if (target === "ui-index") {
421
+ const { index } = await loadAnchorIndex(bundle);
422
+ payload = buildUiIndex(bundle, index);
423
+ }
424
+ else {
425
+ payload = buildGraph(bundle);
426
+ }
427
+ const json = JSON.stringify(payload, null, 2);
428
+ if (values.out === undefined) {
429
+ io.out(json);
430
+ }
431
+ else {
432
+ const out = resolve(cwd, values.out);
433
+ await writeFile(out, json + "\n", "utf8");
434
+ io.out(`wrote ${target} to ${out}`);
435
+ }
436
+ return 0;
437
+ }
438
+ catch (e) {
439
+ if (e instanceof ExportError) {
440
+ io.err(`why export: ${e.message}`);
441
+ return 1;
442
+ }
443
+ throw e;
444
+ }
445
+ }
446
+ /**
447
+ * `why serve` — the standalone local UI (DESIGN.md §8, issue 502). Localhost
448
+ * only, read-only, self-contained assets; blocks until the server closes
449
+ * (Ctrl-C). Endpoints are thin wrappers over the same library calls the other
450
+ * subcommands make.
451
+ */
452
+ async function runServe({ values, positionals, bundle, io }) {
453
+ if (positionals.length > 0) {
454
+ io.err("why serve: takes no positional arguments — usage: why serve [--port <n>]");
455
+ return 2;
456
+ }
457
+ let port = 0;
458
+ if (values.port !== undefined) {
459
+ port = Number(values.port);
460
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
461
+ io.err(`why serve: --port must be a port number (1-65535), got "${values.port}"`);
462
+ return 2;
463
+ }
464
+ }
465
+ try {
466
+ const running = await startWhyServer(bundle.root, { port });
467
+ io.out(`why serve: ${running.url}`);
468
+ io.out(` bundle ${bundle.root} — read-only, 127.0.0.1 only; Ctrl-C to stop`);
469
+ await new Promise((resolve) => running.server.once("close", resolve));
470
+ return 0;
471
+ }
472
+ catch (e) {
473
+ if (e instanceof ServeError || e instanceof AssetError) {
474
+ io.err(`why serve: ${e.message}`);
475
+ return 1;
476
+ }
477
+ throw e;
478
+ }
479
+ }
480
+ function renderCapture(result, io) {
481
+ io.out(`drafted ${result.type}: ${result.draftPath}`);
482
+ io.out(` evidence pack: ${result.evidencePath}`);
483
+ io.out(` ${plural(result.candidateCount, "rationale candidate")}, ${plural(result.anchorCount, "anchor")}`);
484
+ for (const note of result.notes)
485
+ io.out(` note: ${note}`);
486
+ io.out(`drafts are not served — edit, then: why capture --promote ${basename(result.draftPath)}`);
487
+ return 0;
488
+ }
489
+ // `init` creates the bundle, so it takes no --bundle and skips discovery.
490
+ const COMMAND_SPECS = {
491
+ init: {
492
+ options: { "capture-snippet": { type: "boolean" } },
493
+ needsBundle: false,
494
+ run: runInit,
495
+ },
496
+ lint: {
497
+ options: { ...BUNDLE_OPTIONS, json: { type: "boolean" } },
498
+ needsBundle: true,
499
+ positionalBundle: true,
500
+ run: runLint,
501
+ },
502
+ blame: {
503
+ options: { ...BUNDLE_OPTIONS, json: { type: "boolean" } },
504
+ needsBundle: true,
505
+ run: runBlame,
506
+ },
507
+ anchor: {
508
+ options: {
509
+ ...BUNDLE_OPTIONS,
510
+ check: { type: "boolean" },
511
+ "allow-drift": { type: "boolean" },
512
+ concept: { type: "string" },
513
+ },
514
+ needsBundle: true,
515
+ run: runAnchor,
516
+ },
517
+ doctor: {
518
+ options: { ...BUNDLE_OPTIONS, json: { type: "boolean" } },
519
+ needsBundle: true,
520
+ run: runDoctor,
521
+ },
522
+ dig: {
523
+ options: {
524
+ ...BUNDLE_OPTIONS,
525
+ episodes: { type: "boolean" },
526
+ json: { type: "boolean" },
527
+ from: { type: "string" },
528
+ full: { type: "boolean" },
529
+ evidence: { type: "string" },
530
+ "evidence-dir": { type: "string" },
531
+ "max-chars": { type: "string" },
532
+ out: { type: "string" },
533
+ },
534
+ needsBundle: true,
535
+ run: runDig,
536
+ },
537
+ audit: {
538
+ options: {
539
+ ...BUNDLE_OPTIONS,
540
+ json: { type: "boolean" },
541
+ "questions-out": { type: "string" },
542
+ answers: { type: "string" },
543
+ },
544
+ needsBundle: true,
545
+ run: runAudit,
546
+ },
547
+ capture: {
548
+ options: {
549
+ ...BUNDLE_OPTIONS,
550
+ pr: { type: "string" },
551
+ commit: { type: "string" },
552
+ promote: { type: "string" },
553
+ },
554
+ needsBundle: true,
555
+ run: runCapture,
556
+ },
557
+ export: {
558
+ options: { ...BUNDLE_OPTIONS, out: { type: "string" } },
559
+ needsBundle: true,
560
+ run: runExport,
561
+ },
562
+ serve: {
563
+ options: { ...BUNDLE_OPTIONS, port: { type: "string" } },
564
+ needsBundle: true,
565
+ run: runServe,
566
+ },
567
+ };
568
+ /** Exit codes: 0 ok, 1 operational error (e.g. no bundle), 2 usage error. */
569
+ export async function main(argv, cwd = process.cwd(), io = CONSOLE_IO) {
570
+ const cmd = argv[0];
571
+ if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
572
+ io.out(usage());
573
+ return 0;
574
+ }
575
+ if (!isOneOf(COMMANDS, cmd)) {
576
+ io.err(`why: unknown command "${cmd}"\n\n${usage()}`);
577
+ return 2;
578
+ }
579
+ const command = cmd;
580
+ const spec = COMMAND_SPECS[command];
581
+ let values;
582
+ let positionals;
583
+ try {
584
+ ({ values, positionals } = parseArgs({
585
+ args: argv.slice(1),
586
+ options: spec.options,
587
+ allowPositionals: true,
588
+ }));
589
+ }
590
+ catch (e) {
591
+ io.err(`why ${command}: ${e instanceof Error ? e.message : String(e)}`);
592
+ return 2;
593
+ }
594
+ const ctx = { values, positionals, cwd, io };
595
+ if (spec.needsBundle) {
596
+ let override = values.bundle;
597
+ if (spec.positionalBundle === true && positionals.length > 0) {
598
+ if (positionals.length > 1) {
599
+ io.err(`why ${command}: expected at most one bundle path`);
600
+ return 2;
601
+ }
602
+ if (override !== undefined) {
603
+ io.err(`why ${command}: pass the bundle as a positional path or with --bundle, not both`);
604
+ return 2;
605
+ }
606
+ override = positionals[0];
607
+ }
608
+ try {
609
+ const root = resolveBundleRoot(cwd, override);
610
+ ctx.bundle = await loadBundle(root);
611
+ }
612
+ catch (e) {
613
+ if (e instanceof BundleNotFoundError) {
614
+ io.err(e.message);
615
+ return 1;
616
+ }
617
+ throw e;
618
+ }
619
+ }
620
+ return spec.run(ctx);
621
+ }
622
+ // Are we the entry point, or imported (e.g. by tests)? `import.meta.url` is
623
+ // realpath-resolved, but `process.argv[1]` keeps the invoked path verbatim —
624
+ // so a symlinked launch (npm's local installs and every `node_modules/.bin`
625
+ // shim are symlinks) would never match a naive string compare, and `main()`
626
+ // would silently never run. Resolve argv[1]'s symlinks to the same realpath,
627
+ // and build the URL with pathToFileURL so odd characters compare correctly.
628
+ function isDirectRun() {
629
+ const entry = process.argv[1];
630
+ if (entry === undefined)
631
+ return false;
632
+ try {
633
+ return import.meta.url === pathToFileURL(realpathSync(entry)).href;
634
+ }
635
+ catch {
636
+ return false;
637
+ }
638
+ }
639
+ if (isDirectRun()) {
640
+ process.exit(await main(process.argv.slice(2)));
641
+ }
@@ -0,0 +1,46 @@
1
+ export declare const DIG_STATE_FILENAME = ".dig-state.json";
2
+ export declare const DIG_STATE_VERSION = 1;
3
+ export declare class DigStateError extends Error {
4
+ }
5
+ /** Last commit a successful emission fully processed on a branch (full sha). */
6
+ export interface BranchMark {
7
+ lastProcessed: string;
8
+ }
9
+ export interface DigState {
10
+ version: number;
11
+ branches: Record<string, BranchMark>;
12
+ }
13
+ export declare function digStatePath(bundleRoot: string): string;
14
+ /** Parse the state file; absent → undefined, unreadable → DigStateError. */
15
+ export declare function readDigState(bundleRoot: string): Promise<DigState | undefined>;
16
+ /** Write the state atomically (temp + same-directory rename), branches sorted. */
17
+ export declare function writeDigState(bundleRoot: string, state: DigState): Promise<void>;
18
+ export interface DigRangeOverrides {
19
+ /** `--from <rev>`: dig from this commit (exclusive), ignoring the mark. */
20
+ from?: string;
21
+ /** `--full`: re-dig all history, ignoring the mark. */
22
+ full?: boolean;
23
+ }
24
+ export interface DigRange {
25
+ branch: string;
26
+ /** Full sha of HEAD — where the mark lands after a successful emission. */
27
+ head: string;
28
+ /** Full sha of the exclusive range start; absent means full history. */
29
+ from?: string;
30
+ /** Commits in from..head, oldest first, full shas. */
31
+ commits: string[];
32
+ }
33
+ /** Resolve the range a dig run should process: mark → HEAD unless overridden. */
34
+ export declare function resolveDigRange(repo: string, state: DigState | undefined, overrides?: DigRangeOverrides): DigRange;
35
+ export type EmitEpisodes = (range: DigRange) => void | Promise<void>;
36
+ export interface DigRunResult extends DigRange {
37
+ /** False when there was nothing new: emit was not called, state untouched. */
38
+ emitted: boolean;
39
+ }
40
+ /**
41
+ * The only-on-success wrapper around an episode emission (the seam
42
+ * `why dig --episodes` extraction plugs into): resolve the range, call `emit`
43
+ * if there is anything in it, and advance the mark to HEAD only after `emit`
44
+ * returns. A throwing `emit` propagates with the state file untouched.
45
+ */
46
+ export declare function withDigState(repo: string, bundleRoot: string, overrides: DigRangeOverrides, emit: EmitEpisodes): Promise<DigRunResult>;