doctrina-cli 0.15.0 → 0.16.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 (85) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -199
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +7 -75
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +121 -160
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +109 -6
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +210 -24
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +238 -11
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/contract.md.template +7 -0
  82. package/templates/hooks/pre-commit.sample +6 -2
  83. package/templates/playbooks/bootstrap.md.template +43 -0
  84. package/templates/playbooks/chore.md.template +19 -0
  85. package/templates/playbooks/work.md.template +76 -0
@@ -2,12 +2,15 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { readdirSync } from "node:fs";
5
- import { spawnSync } from "node:child_process";
5
+ import { wantsJson } from "../lib/json-out.js";
6
+ import { spawn, spawnSync } from "node:child_process";
6
7
  import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
7
8
  import { flagBool, flagString } from "../lib/args.js";
8
9
  import { today } from "../lib/dates.js";
9
10
  import { c } from "../lib/colors.js";
11
+ import { loadSignoffs, saveSignoffs, recordSignoff, signoffState, short, SIGNOFF_REL } from "../lib/signoff.js";
10
12
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
13
+ import { collectReproducibility } from "../lib/reproducibility.js";
11
14
 
12
15
  // The build/verify gate `validate` is not. `validate` checks the shape of
13
16
  // the artifact tree; it never runs the project, so a repo that does not
@@ -18,7 +21,7 @@ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
18
21
  // `.doctrina/verify.json`, and `verify` runs exactly that, in order.
19
22
 
20
23
  const CONFIG_REL = ".doctrina/verify.json";
21
- const SIGNOFF_REL = ".doctrina/verify.signoffs.json";
24
+
22
25
 
23
26
  const STARTER = {
24
27
  $comment:
@@ -53,7 +56,7 @@ export async function run(_positional, flags) {
53
56
  // fresh `npm install` differ from the dirty machine, without doing a real
54
57
  // (slow, package-manager-specific) clean install.
55
58
  if (flagBool(flags, "clean", false)) {
56
- return reproducibilityLint(projectRoot);
59
+ return renderReproducibility(projectRoot);
57
60
  }
58
61
 
59
62
  const configPath = path.join(projectRoot, CONFIG_REL);
@@ -117,12 +120,11 @@ export async function run(_positional, flags) {
117
120
  return 1;
118
121
  }
119
122
 
120
- // Sign-off store for manual checks: { "<check name>": { date, note } }.
121
- const signoffPath = path.join(projectRoot, SIGNOFF_REL);
122
- let signoffs = {};
123
- if (isFile(signoffPath)) {
124
- try { signoffs = JSON.parse(read(signoffPath)) ?? {}; } catch { signoffs = {}; }
125
- }
123
+ // Sign-off store for manual checks. Since change 0039 a record carries what
124
+ // it covered and the commit it covered it at, so the signature can expire
125
+ // (lib/signoff.js); a pre-expiry record is read unchanged and reported as
126
+ // unverifiable rather than silently trusted.
127
+ const signoffs = loadSignoffs(projectRoot);
126
128
 
127
129
  // --signoff "<name>=<note>" records a manual check's human/eval sign-off
128
130
  // (date stamped today) and exits. The name must be a declared manual check.
@@ -140,9 +142,22 @@ export async function run(_positional, flags) {
140
142
  : `add one: { "name": "${name || "quality"}", "type": "manual", "rubric": "<the question>" }`));
141
143
  return 1;
142
144
  }
143
- signoffs[name] = { date: today(), note };
144
- write(signoffPath, JSON.stringify(signoffs, null, 2) + "\n", { force: true });
145
- console.log(c.green("signed off") + ` ${name} on ${today()}${note ? `: ${note}` : ""}`);
145
+ signoffs[name] = recordSignoff(projectRoot, target, note);
146
+ saveSignoffs(projectRoot, signoffs);
147
+ const stamped = signoffs[name];
148
+ console.log(c.green("signed off") + ` ${name} on ${stamped.date}${note ? `: ${note}` : ""}`);
149
+ // Say what the signature is anchored to, so its expiry is not a surprise
150
+ // later: an unanchored signature is one nothing can hold to the code.
151
+ if (stamped.sha && stamped.paths) {
152
+ console.log(c.gray(` covers ${stamped.paths.join(", ")} as of ${short(stamped.sha)}`));
153
+ console.log(c.gray(" expires when one of those paths changes"));
154
+ } else if (!stamped.paths) {
155
+ console.log(c.yellow("warn:") + ` "${name}" declares no "paths" — the sign-off cannot expire, ` +
156
+ "so `verify` will report it as unverifiable rather than passing");
157
+ console.log(c.gray(` fix: add "paths": ["src/..."] to the check in ${CONFIG_REL}`));
158
+ } else {
159
+ console.log(c.yellow("warn:") + " not a git repository — nothing can tell when this signature goes stale");
160
+ }
146
161
  return 0;
147
162
  }
148
163
 
@@ -178,13 +193,29 @@ export async function run(_positional, flags) {
178
193
  // can require the human/eval sign-off without blocking local runs.
179
194
  if (ch.type === "manual") {
180
195
  console.log(c.gray(`──── ${name}: `) + c.gray(`manual${ch.rubric ? " — " + ch.rubric : ""}`));
181
- const so = signoffs[name];
182
- if (so && so.date) {
183
- console.log(c.green(`✓ ${name}`) + c.gray(` — signed off ${so.date}${so.note ? `: ${so.note}` : ""}`));
184
- results.push({ name, ok: true, kind: "manual" });
196
+ // Four states, one rule: only a signature that still demonstrably
197
+ // covers the code passes. The other three warn by default and fail
198
+ // under --strict — which is what `pending` already did, so a manual
199
+ // check keeps ONE rule rather than gaining a second.
200
+ const verdict = signoffState(projectRoot, ch, signoffs[name]);
201
+ const so = verdict.record;
202
+ const resign = c.gray(` (doctrina verify --signoff "${name}=<note>")`);
203
+ if (verdict.state === "fresh") {
204
+ console.log(c.green(`✓ ${name}`) + c.gray(` — signed off ${so.date}${so.note ? `: ${so.note}` : ""}`) +
205
+ c.gray(` · still covers ${so.paths.join(", ")} at ${short(so.sha)}`));
206
+ results.push({ name, ok: true, kind: "manual", signoff: "fresh" });
207
+ } else if (verdict.state === "expired") {
208
+ console.log(c.yellow(`○ ${name} — sign-off expired`) +
209
+ c.gray(` (signed ${so.date} at ${short(so.sha)}; changed since: ${verdict.changed.slice(0, 3).join(", ")}` +
210
+ `${verdict.changed.length > 3 ? `, +${verdict.changed.length - 3} more` : ""})`) + resign);
211
+ results.push({ name, ok: false, kind: "manual", pending: true, signoff: "expired" });
212
+ } else if (verdict.state === "unverifiable") {
213
+ console.log(c.yellow(`○ ${name} — sign-off cannot be verified`) +
214
+ c.gray(` (signed ${so.date}; ${verdict.why})`) + resign);
215
+ results.push({ name, ok: false, kind: "manual", pending: true, signoff: "unverifiable" });
185
216
  } else {
186
- console.log(c.yellow(`○ ${name} — pending sign-off`) + c.gray(` (doctrina verify --signoff "${name}=<note>")`));
187
- results.push({ name, ok: false, kind: "manual", pending: true });
217
+ console.log(c.yellow(`○ ${name} — pending sign-off`) + resign);
218
+ results.push({ name, ok: false, kind: "manual", pending: true, signoff: "pending" });
188
219
  }
189
220
  continue;
190
221
  }
@@ -198,24 +229,24 @@ export async function run(_positional, flags) {
198
229
  // monorepo target a sub-package, e.g. { run: "node --test", cwd:
199
230
  // "packages/api" } — the multi-service shape the contract artifact
200
231
  // also serves.
201
- // A check that declares OUTPUT EXPECTATIONS must have its output read,
202
- // so it runs piped and is echoed straight through — the operator still
203
- // sees everything, just at the end of the check rather than streaming.
204
- // Only checks that opt in pay that cost.
232
+ // A check that declares OUTPUT EXPECTATIONS must have its output READ,
233
+ // which is not the same as withholding it. The first cut ran such a
234
+ // check piped and echoed the whole buffer at the end — forty seconds of
235
+ // blank terminal on this project's own suite, and worse on anything
236
+ // slower. Reading and showing are independent: the tee streams every
237
+ // chunk as it arrives AND accumulates it for the match.
205
238
  const expectation = parseExpectation(ch);
206
- const res = spawnSync(ch.run, {
207
- cwd: ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot,
208
- shell: true,
209
- stdio: expectation ? "pipe" : "inherit",
210
- encoding: expectation ? "utf8" : undefined,
211
- // A suite's output can be large; the default 1MB cap would truncate
212
- // the very line an expectation is looking for.
213
- maxBuffer: expectation ? 64 * 1024 * 1024 : undefined,
214
- });
215
- if (expectation) {
216
- if (res.stdout) process.stdout.write(res.stdout);
217
- if (res.stderr) process.stderr.write(res.stderr);
218
- }
239
+ // Under --json the child is TEED as well (change 0114): with
240
+ // `stdio: "inherit"` its bytes go straight to the file descriptors,
241
+ // past the envelope's capture, so a failing check's diagnostics landed
242
+ // on the real stderr while the JSON said `"stderr": []`.
243
+ const res = expectation || wantsJson(flags)
244
+ ? await runTee(ch.run, ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot)
245
+ : spawnSync(ch.run, {
246
+ cwd: ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot,
247
+ shell: true,
248
+ stdio: "inherit",
249
+ });
219
250
 
220
251
  let ok = !res.error && res.status === 0;
221
252
  /** @type {string | null} */
@@ -226,7 +257,9 @@ export async function run(_positional, flags) {
226
257
  // matched nothing prints "0 scenarios" and exits 0, and every gate
227
258
  // above this one calls that a pass. An expectation makes the run's
228
259
  // own output part of the verdict.
229
- const output = `${res.stdout ?? ""}\n${res.stderr ?? ""}`;
260
+ // The tee already interleaves both streams in the order they arrived,
261
+ // which is the order a human read them in.
262
+ const output = res.output ?? `${res.stdout ?? ""}\n${res.stderr ?? ""}`;
230
263
  const verdict = judgeOutput(expectation, output);
231
264
  if (!verdict.ok) {
232
265
  ok = false;
@@ -266,6 +299,47 @@ export async function run(_positional, flags) {
266
299
  return 1;
267
300
  }
268
301
 
302
+ // Run a command, streaming its output to the terminal AS IT ARRIVES while
303
+ // accumulating a copy for an expectation to match against.
304
+ //
305
+ // `spawnSync` cannot do this: it either inherits the streams (visible,
306
+ // unreadable) or pipes them (readable, invisible until it exits). The async
307
+ // spawn gives both, at the cost of this function existing.
308
+ //
309
+ // Both streams accumulate into ONE buffer in arrival order, so a pattern
310
+ // spanning them matches the same text the operator saw, and stderr is
311
+ // echoed to stderr so redirection still behaves.
312
+ //
313
+ // @returns {Promise<{status: number|null, output: string, error?: Error}>}
314
+ function runTee(command, cwd) {
315
+ return new Promise((resolve) => {
316
+ let child;
317
+ try {
318
+ child = spawn(command, { cwd, shell: true, stdio: ["inherit", "pipe", "pipe"] });
319
+ } catch (err) {
320
+ resolve({ status: null, output: "", error: /** @type {Error} */ (err) });
321
+ return;
322
+ }
323
+
324
+ let output = "";
325
+ const tee = (stream, sink) => {
326
+ if (!stream) return;
327
+ stream.setEncoding("utf8");
328
+ stream.on("data", (chunk) => {
329
+ output += chunk;
330
+ sink.write(chunk);
331
+ });
332
+ };
333
+ tee(child.stdout, process.stdout);
334
+ tee(child.stderr, process.stderr);
335
+
336
+ child.on("error", (err) => resolve({ status: null, output, error: err }));
337
+ // "close" rather than "exit": it fires once the streams are drained, so
338
+ // no trailing output is dropped from the match or the terminal.
339
+ child.on("close", (status) => resolve({ status, output }));
340
+ });
341
+ }
342
+
269
343
  // An `expect` block, normalised and compiled, or null when the check does
270
344
  // not declare one. Runner-agnostic by construction: Doctrina supplies no
271
345
  // patterns of its own and knows nothing about what the output means — the
@@ -307,62 +381,19 @@ export function judgeOutput(expectation, output) {
307
381
  return { ok: true };
308
382
  }
309
383
 
310
- // Static reproducibility lint: walk the project's package.json files and
311
- // report the two "works on my machine" footguns the review hit — a package
312
- // whose entry points live in a build-output dir with nothing that builds it
313
- // on install, and a codegen dependency (Prisma) with no install hook that
314
- // generates. Returns 0 when clean, 1 when any risk is found. Pure read.
315
- function reproducibilityLint(projectRoot) {
316
- const pkgPaths = findPackageJsons(projectRoot);
384
+
385
+ // The reproducibility lint, rendered. The lint itself is a collection in
386
+ // lib/reproducibility.js so `doctor` can ask for it in process.
387
+ function renderReproducibility(projectRoot) {
388
+ const { packages, findings } = collectReproducibility(projectRoot);
317
389
  console.log(c.bold("doctrina verify --clean") + c.gray(" — reproducibility lint (static, not a real fresh install)"));
318
390
  console.log("");
319
-
320
- if (pkgPaths.length === 0) {
391
+ if (packages === 0) {
321
392
  console.log(c.gray("no package.json found — nothing this lint can check (it knows Node/npm footguns)"));
322
393
  return 0;
323
394
  }
324
-
325
- const findings = [];
326
- for (const pkgPath of pkgPaths) {
327
- const rel = relPath(projectRoot, pkgPath);
328
- let pkg;
329
- try {
330
- pkg = JSON.parse(read(pkgPath));
331
- } catch (err) {
332
- findings.push(`${rel}: not valid JSON (${err.message})`);
333
- continue;
334
- }
335
- const scripts = pkg.scripts ?? {};
336
- const hasInstallBuild = typeof scripts.prepare === "string" || typeof scripts.prepack === "string";
337
-
338
- // 1. Entry point into a build output with nothing to build it on install.
339
- const built = entryIntoBuildDir(pkg);
340
- if (built && !hasInstallBuild) {
341
- findings.push(
342
- `${rel}: ${built.field} → \`${built.value}\` is a build output, but no "prepare"/"prepack" ` +
343
- `script builds it on install — a fresh install/clone won't have it (add a prepare script, or commit the output)`,
344
- );
345
- }
346
-
347
- // 2. Codegen dependency with no install hook that generates.
348
- const deps = {
349
- ...pkg.dependencies, ...pkg.devDependencies,
350
- ...pkg.peerDependencies, ...pkg.optionalDependencies,
351
- };
352
- for (const [dep, gen] of Object.entries(CODEGEN_DEPS)) {
353
- if (!(dep in deps)) continue;
354
- const installHook = `${scripts.postinstall ?? ""} ${scripts.prepare ?? ""}`;
355
- if (!gen.test(installHook)) {
356
- findings.push(
357
- `${rel}: depends on "${dep}" but no "postinstall"/"prepare" runs its codegen ` +
358
- `(\`${gen.source.replace(/\\s\+/g, " ")}\`) — a fresh install has no generated output`,
359
- );
360
- }
361
- }
362
- }
363
-
364
395
  if (findings.length === 0) {
365
- console.log(c.green("ok") + ` ${pkgPaths.length} package.json file${pkgPaths.length === 1 ? "" : "s"} — no reproducibility risks found`);
396
+ console.log(c.green("ok") + ` ${packages} package.json file${packages === 1 ? "" : "s"} — no reproducibility risks found`);
366
397
  return 0;
367
398
  }
368
399
  for (const f of findings) console.log(` ${c.yellow("!")} ${f}`);
@@ -371,76 +402,6 @@ function reproducibilityLint(projectRoot) {
371
402
  return 1;
372
403
  }
373
404
 
374
- // Known codegen dependencies → the command an install hook must run so a
375
- // fresh install produces their generated output.
376
- const CODEGEN_DEPS = {
377
- "@prisma/client": /prisma\s+generate/,
378
- "prisma": /prisma\s+generate/,
379
- };
380
-
381
- const BUILD_DIR_RE = /(?:^|\/)(?:dist|build|out)\//;
382
-
383
- // The first entry-point field that points into a build-output directory, or
384
- // null. Scans main/module/types/typings, bin (string or map), and exports
385
- // (recursively, string leaves only).
386
- function entryIntoBuildDir(pkg) {
387
- for (const field of ["main", "module", "types", "typings"]) {
388
- if (typeof pkg[field] === "string" && BUILD_DIR_RE.test(pkg[field])) {
389
- return { field, value: pkg[field] };
390
- }
391
- }
392
- if (typeof pkg.bin === "string" && BUILD_DIR_RE.test(pkg.bin)) return { field: "bin", value: pkg.bin };
393
- if (pkg.bin && typeof pkg.bin === "object") {
394
- for (const v of Object.values(pkg.bin)) {
395
- if (typeof v === "string" && BUILD_DIR_RE.test(v)) return { field: "bin", value: v };
396
- }
397
- }
398
- const fromExports = scanExports(pkg.exports);
399
- if (fromExports) return { field: "exports", value: fromExports };
400
- return null;
401
- }
402
-
403
- function scanExports(node) {
404
- if (typeof node === "string") return BUILD_DIR_RE.test(node) ? node : null;
405
- if (node && typeof node === "object") {
406
- for (const v of Object.values(node)) {
407
- const hit = scanExports(v);
408
- if (hit) return hit;
409
- }
410
- }
411
- return null;
412
- }
413
-
414
- // Bounded walk for package.json files: skip dependency, build, and VCS
415
- // directories so the lint stays fast and ignores vendored manifests.
416
- const LINT_SKIP_DIRS = new Set([
417
- ".git", "node_modules", "dist", "build", "out", "vendor", ".next",
418
- "coverage", ".venv", "venv", "__pycache__", "target", ".doctrina",
419
- ]);
420
-
421
- function findPackageJsons(projectRoot) {
422
- const found = [];
423
- const stack = [projectRoot];
424
- while (stack.length) {
425
- const dir = stack.pop();
426
- let entries;
427
- try {
428
- entries = readdirSync(dir, { withFileTypes: true });
429
- } catch {
430
- continue;
431
- }
432
- for (const entry of entries) {
433
- const full = path.join(dir, entry.name);
434
- if (entry.isDirectory()) {
435
- if (!LINT_SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) stack.push(full);
436
- } else if (entry.name === "package.json") {
437
- found.push(full);
438
- }
439
- }
440
- }
441
- return found.sort();
442
- }
443
-
444
405
  export const help = `
445
406
  Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--signoff "<name>=<note>"] [--force]
446
407
 
@@ -476,10 +437,10 @@ verdict:
476
437
 
477
438
  Doctrina supplies no patterns of its own and knows nothing about what the
478
439
  output means — the project declares the line that proves its run was real,
479
- so this works for any runner in any language. A check with an "expect"
480
- block runs piped (its output is echoed through when it finishes, rather
481
- than streaming); every other check still streams. An "expect" pattern that
482
- is not a valid regular expression fails at config time, not silently.
440
+ so this works for any runner in any language. An expect-carrying check
441
+ still streams: its output is teed to the terminal as it arrives while a
442
+ copy accumulates for the match. An "expect" pattern that is not a valid
443
+ regular expression fails at config time, not silently.
483
444
 
484
445
  Flags:
485
446
  --init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
@@ -9,7 +9,7 @@ import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
9
9
  import * as idx from "../lib/index-json.js";
10
10
  import { c } from "../lib/colors.js";
11
11
  import { suggest } from "../lib/suggest.js";
12
- import { notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
13
13
 
14
14
  // Provenance explainer (review 2026-06-27 passive-user feature #5): answer
15
15
  // "why does this capability exist / why was it built this way?" by assembling
@@ -51,7 +51,7 @@ export async function run(positional, _flags) {
51
51
  const guess = suggest(cap, known);
52
52
  if (guess) console.error(c.gray("hint: ") + `did you mean "${guess}"?`);
53
53
  else if (known.length) console.error(c.gray("known: ") + known.sort().join(", "));
54
- return 1;
54
+ return EXIT.USAGE;
55
55
  }
56
56
 
57
57
  const text = read(specPath);