doctrina-cli 0.15.1 → 0.17.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 (93) hide show
  1. package/README.md +27 -72
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +27 -3
  4. package/src/commands/analyze.js +16 -212
  5. package/src/commands/change.js +144 -267
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +65 -21
  8. package/src/commands/close.js +349 -45
  9. package/src/commands/completion.js +8 -2
  10. package/src/commands/context.js +284 -111
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +66 -7
  14. package/src/commands/doctor.js +293 -130
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +17 -3
  17. package/src/commands/index-rebuild.js +55 -44
  18. package/src/commands/init.js +88 -16
  19. package/src/commands/intake.js +85 -99
  20. package/src/commands/intent.js +14 -1
  21. package/src/commands/metrics.js +65 -88
  22. package/src/commands/next.js +105 -147
  23. package/src/commands/prime.js +23 -91
  24. package/src/commands/report.js +40 -109
  25. package/src/commands/review.js +68 -36
  26. package/src/commands/search.js +18 -4
  27. package/src/commands/show.js +24 -9
  28. package/src/commands/skill.js +23 -39
  29. package/src/commands/spec.js +38 -11
  30. package/src/commands/status.js +69 -171
  31. package/src/commands/templates.js +35 -148
  32. package/src/commands/trace.js +36 -71
  33. package/src/commands/triage.js +4 -82
  34. package/src/commands/upgrade.js +31 -6
  35. package/src/commands/validate.js +44 -877
  36. package/src/commands/verify.js +60 -138
  37. package/src/commands/why.js +2 -2
  38. package/src/commands/work.js +243 -267
  39. package/src/index.js +139 -12
  40. package/src/lib/actions.js +490 -0
  41. package/src/lib/adapters.js +0 -11
  42. package/src/lib/adr-guard.js +19 -0
  43. package/src/lib/agent-changelog.js +133 -0
  44. package/src/lib/analysis.js +264 -0
  45. package/src/lib/args.js +14 -0
  46. package/src/lib/change-ops.js +133 -0
  47. package/src/lib/commands.js +135 -10
  48. package/src/lib/config.js +206 -0
  49. package/src/lib/constitution-model.js +102 -0
  50. package/src/lib/coverage-model.js +431 -0
  51. package/src/lib/criteria.js +23 -0
  52. package/src/lib/doc-model.js +370 -3
  53. package/src/lib/docs-impact.js +270 -27
  54. package/src/lib/ears.js +24 -1
  55. package/src/lib/flag-catalog.js +0 -16
  56. package/src/lib/fs-ops.js +58 -1
  57. package/src/lib/gates.js +231 -29
  58. package/src/lib/git.js +132 -0
  59. package/src/lib/index-json.js +42 -24
  60. package/src/lib/intake-model.js +155 -0
  61. package/src/lib/json-out.js +100 -2
  62. package/src/lib/ledger.js +192 -0
  63. package/src/lib/lexicon.js +228 -0
  64. package/src/lib/metrics-model.js +189 -0
  65. package/src/lib/names.js +42 -0
  66. package/src/lib/playbook.js +97 -0
  67. package/src/lib/project.js +105 -0
  68. package/src/lib/reproducibility.js +137 -0
  69. package/src/lib/runtime.js +191 -5
  70. package/src/lib/scan.js +328 -25
  71. package/src/lib/signoff.js +147 -0
  72. package/src/lib/snapshot.js +231 -0
  73. package/src/lib/spec-ops.js +72 -8
  74. package/src/lib/templates-model.js +289 -0
  75. package/src/lib/trace-model.js +95 -0
  76. package/src/lib/triage-model.js +121 -0
  77. package/src/lib/usage.js +6 -4
  78. package/src/lib/validation-model.js +1381 -0
  79. package/src/lib/version.js +22 -0
  80. package/src/lib/views.js +413 -0
  81. package/src/lib/work-model.js +102 -0
  82. package/templates/AGENTS.md.template +21 -28
  83. package/templates/README.md +35 -0
  84. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  85. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  86. package/templates/change/proposal.md.template +16 -0
  87. package/templates/change/tasks.md.template +3 -8
  88. package/templates/contract.md.template +2 -2
  89. package/templates/hooks/pre-commit.sample +16 -2
  90. package/templates/playbooks/bootstrap.md.template +47 -0
  91. package/templates/playbooks/chore.md.template +24 -0
  92. package/templates/playbooks/work.md.template +83 -0
  93. package/src/commands/constitution.js +0 -145
@@ -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 { wantsJson } from "../lib/json-out.js";
5
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
  }
@@ -205,7 +236,11 @@ export async function run(_positional, flags) {
205
236
  // slower. Reading and showing are independent: the tee streams every
206
237
  // chunk as it arrives AND accumulates it for the match.
207
238
  const expectation = parseExpectation(ch);
208
- const res = expectation
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)
209
244
  ? await runTee(ch.run, ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot)
210
245
  : spawnSync(ch.run, {
211
246
  cwd: ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot,
@@ -346,62 +381,19 @@ export function judgeOutput(expectation, output) {
346
381
  return { ok: true };
347
382
  }
348
383
 
349
- // Static reproducibility lint: walk the project's package.json files and
350
- // report the two "works on my machine" footguns the review hit — a package
351
- // whose entry points live in a build-output dir with nothing that builds it
352
- // on install, and a codegen dependency (Prisma) with no install hook that
353
- // generates. Returns 0 when clean, 1 when any risk is found. Pure read.
354
- function reproducibilityLint(projectRoot) {
355
- 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);
356
389
  console.log(c.bold("doctrina verify --clean") + c.gray(" — reproducibility lint (static, not a real fresh install)"));
357
390
  console.log("");
358
-
359
- if (pkgPaths.length === 0) {
391
+ if (packages === 0) {
360
392
  console.log(c.gray("no package.json found — nothing this lint can check (it knows Node/npm footguns)"));
361
393
  return 0;
362
394
  }
363
-
364
- const findings = [];
365
- for (const pkgPath of pkgPaths) {
366
- const rel = relPath(projectRoot, pkgPath);
367
- let pkg;
368
- try {
369
- pkg = JSON.parse(read(pkgPath));
370
- } catch (err) {
371
- findings.push(`${rel}: not valid JSON (${err.message})`);
372
- continue;
373
- }
374
- const scripts = pkg.scripts ?? {};
375
- const hasInstallBuild = typeof scripts.prepare === "string" || typeof scripts.prepack === "string";
376
-
377
- // 1. Entry point into a build output with nothing to build it on install.
378
- const built = entryIntoBuildDir(pkg);
379
- if (built && !hasInstallBuild) {
380
- findings.push(
381
- `${rel}: ${built.field} → \`${built.value}\` is a build output, but no "prepare"/"prepack" ` +
382
- `script builds it on install — a fresh install/clone won't have it (add a prepare script, or commit the output)`,
383
- );
384
- }
385
-
386
- // 2. Codegen dependency with no install hook that generates.
387
- const deps = {
388
- ...pkg.dependencies, ...pkg.devDependencies,
389
- ...pkg.peerDependencies, ...pkg.optionalDependencies,
390
- };
391
- for (const [dep, gen] of Object.entries(CODEGEN_DEPS)) {
392
- if (!(dep in deps)) continue;
393
- const installHook = `${scripts.postinstall ?? ""} ${scripts.prepare ?? ""}`;
394
- if (!gen.test(installHook)) {
395
- findings.push(
396
- `${rel}: depends on "${dep}" but no "postinstall"/"prepare" runs its codegen ` +
397
- `(\`${gen.source.replace(/\\s\+/g, " ")}\`) — a fresh install has no generated output`,
398
- );
399
- }
400
- }
401
- }
402
-
403
395
  if (findings.length === 0) {
404
- 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`);
405
397
  return 0;
406
398
  }
407
399
  for (const f of findings) console.log(` ${c.yellow("!")} ${f}`);
@@ -410,76 +402,6 @@ function reproducibilityLint(projectRoot) {
410
402
  return 1;
411
403
  }
412
404
 
413
- // Known codegen dependencies → the command an install hook must run so a
414
- // fresh install produces their generated output.
415
- const CODEGEN_DEPS = {
416
- "@prisma/client": /prisma\s+generate/,
417
- "prisma": /prisma\s+generate/,
418
- };
419
-
420
- const BUILD_DIR_RE = /(?:^|\/)(?:dist|build|out)\//;
421
-
422
- // The first entry-point field that points into a build-output directory, or
423
- // null. Scans main/module/types/typings, bin (string or map), and exports
424
- // (recursively, string leaves only).
425
- function entryIntoBuildDir(pkg) {
426
- for (const field of ["main", "module", "types", "typings"]) {
427
- if (typeof pkg[field] === "string" && BUILD_DIR_RE.test(pkg[field])) {
428
- return { field, value: pkg[field] };
429
- }
430
- }
431
- if (typeof pkg.bin === "string" && BUILD_DIR_RE.test(pkg.bin)) return { field: "bin", value: pkg.bin };
432
- if (pkg.bin && typeof pkg.bin === "object") {
433
- for (const v of Object.values(pkg.bin)) {
434
- if (typeof v === "string" && BUILD_DIR_RE.test(v)) return { field: "bin", value: v };
435
- }
436
- }
437
- const fromExports = scanExports(pkg.exports);
438
- if (fromExports) return { field: "exports", value: fromExports };
439
- return null;
440
- }
441
-
442
- function scanExports(node) {
443
- if (typeof node === "string") return BUILD_DIR_RE.test(node) ? node : null;
444
- if (node && typeof node === "object") {
445
- for (const v of Object.values(node)) {
446
- const hit = scanExports(v);
447
- if (hit) return hit;
448
- }
449
- }
450
- return null;
451
- }
452
-
453
- // Bounded walk for package.json files: skip dependency, build, and VCS
454
- // directories so the lint stays fast and ignores vendored manifests.
455
- const LINT_SKIP_DIRS = new Set([
456
- ".git", "node_modules", "dist", "build", "out", "vendor", ".next",
457
- "coverage", ".venv", "venv", "__pycache__", "target", ".doctrina",
458
- ]);
459
-
460
- function findPackageJsons(projectRoot) {
461
- const found = [];
462
- const stack = [projectRoot];
463
- while (stack.length) {
464
- const dir = stack.pop();
465
- let entries;
466
- try {
467
- entries = readdirSync(dir, { withFileTypes: true });
468
- } catch {
469
- continue;
470
- }
471
- for (const entry of entries) {
472
- const full = path.join(dir, entry.name);
473
- if (entry.isDirectory()) {
474
- if (!LINT_SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) stack.push(full);
475
- } else if (entry.name === "package.json") {
476
- found.push(full);
477
- }
478
- }
479
- }
480
- return found.sort();
481
- }
482
-
483
405
  export const help = `
484
406
  Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--signoff "<name>=<note>"] [--force]
485
407
 
@@ -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);