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
package/src/lib/scan.js CHANGED
@@ -1,11 +1,16 @@
1
1
  // @ts-check
2
- import { getHeader, getSection } from "./doc-model.js";
2
+ import { getHeader, getSection, parseChangeTitle } from "./doc-model.js";
3
+ import { projectName } from "./project.js";
3
4
  import path from "node:path";
4
5
  import { readdirSync } from "node:fs";
5
6
  import { isDir, isFile, read, walk } from "./fs-ops.js";
6
7
  import { today } from "./dates.js";
7
- import { parseFrontmatter } from "../commands/skill.js";
8
- import { parseOperation, parseCapabilityFromDelta } from "../commands/change.js";
8
+ import { cliVersion, newestVersion } from "./version.js";
9
+ import { load } from "./index-json.js";
10
+ import { parseFrontmatter, isScaffoldValue } from "./doc-model.js";
11
+ import { parseCapabilityFromDelta } from "./doc-model.js";
12
+ import { parseOperation } from "./doc-model.js";
13
+ import { git, gitLines, isRepo, GIT_STATE } from "./git.js";
9
14
 
10
15
  // Header reading lives in ONE place now (lib/doc-model.js, audit item M3).
11
16
  // These two names survive because dozens of call sites use them and the
@@ -25,6 +30,44 @@ export function parseDependsOn(text) {
25
30
  return raw.match(/[a-z][a-z0-9][a-z0-9-]*/g) ?? [];
26
31
  }
27
32
 
33
+ /**
34
+ * The source globs a spec DECLARES as its own, from the optional
35
+ * `**Source:**` header — comma-separated, `*` inside a segment and `**`
36
+ * across directories (the same minimal glob the contract's Selectors use).
37
+ *
38
+ * Doctrina infers nothing about which code belongs to which capability
39
+ * (ADR 0027, and the same principle ADR 0023 applies to the runtime
40
+ * surface). Before this header the only signals were the capability name
41
+ * appearing as a path segment and the spec happening to cite a filename,
42
+ * which left 80 of this repository's 92 source files owned by nobody — so
43
+ * `review`, the gate that asks whether the spec kept up with the code,
44
+ * could not see the code it was reviewing.
45
+ *
46
+ * Returns [] when the header is absent, "n/a", or "—".
47
+ */
48
+ export function parseSourceGlobs(text) {
49
+ const raw = specHeader(text, "Source");
50
+ if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
51
+ // Split on commas at brace depth ZERO: a comma inside `{a,b}` separates
52
+ // alternatives of ONE glob, not two globs, and splitting on it blindly
53
+ // shreds every grouped declaration into fragments that match nothing.
54
+ const out = [];
55
+ let cur = "";
56
+ let depth = 0;
57
+ for (const ch of raw) {
58
+ if (ch === "{") depth += 1;
59
+ else if (ch === "}") depth = Math.max(0, depth - 1);
60
+ if (ch === "," && depth === 0) {
61
+ out.push(cur);
62
+ cur = "";
63
+ continue;
64
+ }
65
+ cur += ch;
66
+ }
67
+ out.push(cur);
68
+ return out.map((g) => g.trim().replace(/^`|`$/g, "").trim()).filter(Boolean);
69
+ }
70
+
28
71
  function dirEntries(dir) {
29
72
  if (!isDir(dir)) return [];
30
73
  return readdirSync(dir).filter((e) => !e.startsWith(".")).sort();
@@ -95,6 +138,54 @@ export function decisionEntry(text, basename, prev, date) {
95
138
  return entry;
96
139
  }
97
140
 
141
+ /**
142
+ * The index entry for ONE open change, derived from its proposal. Same
143
+ * discipline as `decisionEntry` above and for the same reason: `deriveIndex`
144
+ * builds the whole list from it, and `doctrina work` / `change new` register
145
+ * a new change THROUGH it instead of assembling a look-alike by hand.
146
+ *
147
+ * Two constructors for one record shape is how the `lane` field (change 0042)
148
+ * — added to the deriver and not to the writer — turned every freshly opened
149
+ * change into index drift the moment it was written: `doctrina work` followed
150
+ * by `doctrina validate` errored on a tree nobody had touched.
151
+ *
152
+ * @param {string} proposal Text of the change's proposal.md ("" when absent).
153
+ * @param {string} id The change id (its directory name).
154
+ * @param {object|null} prev The entry already in the index, when there is one.
155
+ * @param {string} date Fallback for a proposal carrying no Date header.
156
+ */
157
+ export function changeEntry(proposal, id, prev, date) {
158
+ const title = parseChangeTitle(proposal);
159
+ return {
160
+ id,
161
+ title: title ?? prev?.title ?? id,
162
+ path: `.doctrina/changes/${id}`,
163
+ status: listHeader(proposal, "Status") ?? prev?.status ?? "proposed",
164
+ opened: listHeader(proposal, "Date") ?? prev?.opened ?? date,
165
+ // The lane the change was born in (change 0042). Optional: a change
166
+ // opened before the field existed simply has none, and every consumer
167
+ // treats its absence as "unknown" rather than as a lane.
168
+ ...laneOf(proposal, prev),
169
+ };
170
+ }
171
+
172
+
173
+ // An archived change, as an index entry — the one constructor both the
174
+ // rebuild and `change archive` use. They each built their own, and neither
175
+ // carried the lane: an archived change lost the lane it was born in, so the
176
+ // report's "Lanes" section counted the whole history as unknown (change
177
+ // 0192), which is the question change 0042 recorded the lane to answer.
178
+ export function archivedChangeEntry(proposal, { id, archiveName, applied, specsAffected, prev = null, title = undefined }) {
179
+ return {
180
+ id,
181
+ title: (title === undefined ? parseChangeTitle(proposal) : title) ?? prev?.title ?? id,
182
+ path: `.doctrina/changes/archive/${archiveName}`,
183
+ status: "applied",
184
+ applied,
185
+ specs_affected: specsAffected,
186
+ ...laneOf(proposal, prev),
187
+ };
188
+ }
98
189
 
99
190
  // Regenerate the index object from the artifacts on disk. The files are
100
191
  // the source of truth; fields with no on-disk source (project name,
@@ -106,7 +197,7 @@ export function deriveIndex(projectRoot, current) {
106
197
 
107
198
  const out = {
108
199
  $schema_version: current?.$schema_version ?? "0.1.0",
109
- project: current?.project ?? path.basename(projectRoot),
200
+ project: projectName(projectRoot, current),
110
201
  framework_version: current?.framework_version ?? "0.0.0",
111
202
  last_updated: current?.last_updated ?? date,
112
203
  // Project settings, carried over verbatim. `deriveIndex` rebuilds the
@@ -166,6 +257,10 @@ export function deriveIndex(projectRoot, current) {
166
257
  // dependents of a touched capability.
167
258
  const depends = specHeader(text, "Depends on") !== null ? parseDependsOn(text) : prev?.depends_on;
168
259
  if (depends && depends.length) entry.depends_on = depends;
260
+ // Source: the code this capability owns (change 0077). Declared, never
261
+ // inferred — the same discipline ADR 0023 applies to the runtime surface.
262
+ const source = specHeader(text, "Source") !== null ? parseSourceGlobs(text) : prev?.source;
263
+ if (source && source.length) entry.source = source;
169
264
  out.artifacts.specs.push(entry);
170
265
  }
171
266
 
@@ -187,14 +282,7 @@ export function deriveIndex(projectRoot, current) {
187
282
  const prev = (cur.changes ?? []).find((c) => c.id === id);
188
283
  const proposalPath = path.join(changesDir, id, "proposal.md");
189
284
  const proposal = isFile(proposalPath) ? read(proposalPath) : "";
190
- const titleMatch = proposal.match(/^#\s+Change\s+\S+\s*[—-]\s*(.+)$/m);
191
- out.artifacts.changes.push({
192
- id,
193
- title: titleMatch ? titleMatch[1].trim() : prev?.title ?? id,
194
- path: `.doctrina/changes/${id}`,
195
- status: listHeader(proposal, "Status") ?? prev?.status ?? "proposed",
196
- opened: listHeader(proposal, "Date") ?? prev?.opened ?? date,
197
- });
285
+ out.artifacts.changes.push(changeEntry(proposal, id, prev, date));
198
286
  }
199
287
 
200
288
  // Archived changes — folder name carries the applied date and id.
@@ -205,7 +293,7 @@ export function deriveIndex(projectRoot, current) {
205
293
  const prev = (cur.changes_archive ?? []).find((c) => c.path?.endsWith(name));
206
294
  const proposalPath = path.join(archiveDir, name, "proposal.md");
207
295
  const proposal = isFile(proposalPath) ? read(proposalPath) : "";
208
- const titleMatch = proposal.match(/^#\s+Change\s+\S+\s*[—-]\s*(.+)$/m);
296
+ const title = parseChangeTitle(proposal);
209
297
  const specsAffected = [];
210
298
  for (const deltaPath of walk(path.join(archiveDir, name, "specs"))) {
211
299
  if (!deltaPath.endsWith("delta.md")) continue;
@@ -215,14 +303,9 @@ export function deriveIndex(projectRoot, current) {
215
303
  specsAffected.push({ capability, operation: parseOperation(text) ?? "MODIFIED" });
216
304
  }
217
305
  }
218
- out.artifacts.changes_archive.push({
219
- id: m[2],
220
- title: titleMatch ? titleMatch[1].trim() : prev?.title ?? m[2],
221
- path: `.doctrina/changes/archive/${name}`,
222
- status: "applied",
223
- applied: m[1],
224
- specs_affected: specsAffected,
225
- });
306
+ out.artifacts.changes_archive.push(archivedChangeEntry(proposal, {
307
+ id: m[2], archiveName: name, applied: m[1], specsAffected, prev, title,
308
+ }));
226
309
  }
227
310
 
228
311
  // Contracts — the integration/runtime surface (ports, env, interfaces)
@@ -241,18 +324,26 @@ export function deriveIndex(projectRoot, current) {
241
324
  });
242
325
  }
243
326
 
244
- // Skills — frontmatter description wins.
327
+ // Skills — a WRITTEN frontmatter description wins. The rebuild is the one
328
+ // reconciliation (change 0177 retired `skill sync`, which did a subset of
329
+ // this), so it carries sync's rule: a description still in the scaffold's
330
+ // `<...>` form never overwrites one somebody wrote (change 0111), and the
331
+ // entry's date moves when its description does.
245
332
  const skillsDir = path.join(dot, "skills");
246
333
  for (const f of walk(skillsDir)) {
247
334
  if (!f.endsWith(".md")) continue;
248
335
  const id = path.basename(f, ".md");
249
336
  const prev = (cur.skills ?? []).find((s) => s.id === id);
337
+ const written = parseFrontmatter(read(f), "description");
338
+ const kept = prev?.description && !isScaffoldValue(prev.description) ? prev.description : null;
339
+ const description = written && !isScaffoldValue(written)
340
+ ? written
341
+ : kept ?? written ?? "<edit me — one-sentence summary>";
250
342
  out.artifacts.skills.push({
251
343
  id,
252
344
  path: `.doctrina/skills/${id}.md`,
253
- description: parseFrontmatter(read(f), "description")
254
- ?? prev?.description ?? "<edit me — one-sentence summary>",
255
- last_updated: prev?.last_updated ?? date,
345
+ description,
346
+ last_updated: prev && prev.description === description ? prev.last_updated ?? date : date,
256
347
  });
257
348
  }
258
349
 
@@ -296,3 +387,215 @@ export function indexesMatch(a, b) {
296
387
  };
297
388
  return stableStringify(normalize(a)) === stableStringify(normalize(b));
298
389
  }
390
+
391
+ // The lane recorded in a proposal header (change 0042), as an index field —
392
+ // or nothing at all. A change opened before the field existed, or one whose
393
+ // header is still the empty scaffold, has no lane, and "unknown" is the
394
+ // honest answer rather than a default that would poison the mix.
395
+ function laneOf(proposal, prev) {
396
+ const raw = (listHeader(proposal, "Lane") ?? "").trim();
397
+ if (!raw) return prev?.lane ? { lane: prev.lane } : {};
398
+ return { lane: raw };
399
+ }
400
+
401
+ /**
402
+ * Is the on-disk index still what the tree derives to, and if not, how does
403
+ * it differ?
404
+ *
405
+ * `index rebuild` renders this (and writes the derived index when asked);
406
+ * `doctor` reports it as one row. Before this it was the command's private
407
+ * business, so `doctor` answered "has the index drifted?" by spawning the
408
+ * CLI again and reading an exit code (audit finding F4).
409
+ *
410
+ * The framework stamp is migrated to the running CLI here rather than in the
411
+ * caller: deriveIndex carries the old value over so `next` does not nag on a
412
+ * version-only difference, and overriding it lets a stale stamp COUNT as
413
+ * drift, so `index rebuild` both reports and fixes it instead of
414
+ * short-circuiting on "nothing to do".
415
+ *
416
+ * @param {string} projectRoot
417
+ * @returns {{ok: boolean, drift: string[], derived: any, current: any, unreadable: string|null}}
418
+ */
419
+ export function collectIndexDrift(projectRoot) {
420
+ let current = null;
421
+ let unreadable = null;
422
+ try {
423
+ current = load(projectRoot);
424
+ } catch (err) {
425
+ unreadable = err.message;
426
+ }
427
+ const derived = deriveIndex(projectRoot, current);
428
+ // A stamp ahead of this CLI is not drift (change 0116): the tree is
429
+ // managed by a newer release, and `--check` in CI must not go red because
430
+ // one teammate's CLI is older than the one that last wrote the index.
431
+ derived.framework_version = newestVersion(current?.framework_version, cliVersion());
432
+ if (indexesMatch(derived, current)) {
433
+ return { ok: true, drift: [], derived, current, unreadable };
434
+ }
435
+ return { ok: false, drift: describeDrift(current, derived), derived, current, unreadable };
436
+ }
437
+
438
+ // Human-readable category-level drift between the on-disk index and the
439
+ // derived one: added / removed / changed entry ids.
440
+ function describeDrift(current, derived) {
441
+ const lines = [];
442
+ if (!current) return ["index.json missing or unreadable"];
443
+ if ((current.framework_version ?? null) !== (derived.framework_version ?? null)) {
444
+ lines.push(`framework_version: ${current.framework_version ?? "unset"} -> ${derived.framework_version}`);
445
+ }
446
+ const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
447
+ for (const cat of categories) {
448
+ const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
449
+ const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
450
+ for (const id of der.keys()) {
451
+ if (!cur.has(id)) lines.push(`${cat}: "${id}" on disk but not in index`);
452
+ else if (stableStringify(cur.get(id)) !== stableStringify(der.get(id))) {
453
+ lines.push(`${cat}: "${id}" metadata differs from the files`);
454
+ }
455
+ }
456
+ for (const id of cur.keys()) {
457
+ if (!der.has(id)) lines.push(`${cat}: "${id}" in index but not on disk`);
458
+ }
459
+ }
460
+ if (stableStringify(current.artifacts?.product ?? null) !== stableStringify(derived.artifacts.product)) {
461
+ lines.push("product: metadata differs");
462
+ }
463
+ if (lines.length === 0) lines.push("structural difference (key order or missing category)");
464
+ return lines;
465
+ }
466
+
467
+ /**
468
+ * The capabilities that actually have a spec on disk.
469
+ *
470
+ * The one list every command checks a named capability against. `coverage
471
+ * --only` refuses a name that is not in it (change 0090); `context <cap>`
472
+ * refuses the same way, because a scope filter that matches nothing is the
473
+ * same defect wherever it appears. Kept here rather than in either command
474
+ * so the two cannot disagree about which capabilities exist.
475
+ *
476
+ * @param {string} projectRoot
477
+ * @returns {string[]} directory names, in readdir order
478
+ */
479
+ export function knownCapabilities(projectRoot) {
480
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
481
+ if (!isDir(specsDir)) return [];
482
+ return readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md")));
483
+ }
484
+
485
+ /**
486
+ * Which capabilities declare a dependency on any of `caps`.
487
+ *
488
+ * The `**Depends on:**` header is the tree's only machine-readable statement
489
+ * that one capability builds on another, and two commands ask the same
490
+ * question of it: `review` notes a dependent whose ground moved, and `close`
491
+ * reports their coverage alongside the change's own. One definition, so they
492
+ * cannot disagree about who depends on what.
493
+ *
494
+ * @param {string} projectRoot
495
+ * @param {Iterable<string>} caps
496
+ * @returns {{capability: string, dependsOn: string[]}[]} sorted by capability
497
+ */
498
+ export function dependentsOf(projectRoot, caps) {
499
+ const wanted = new Set(caps);
500
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
501
+ if (wanted.size === 0 || !isDir(specsDir)) return [];
502
+ const out = [];
503
+ for (const cap of readdirSync(specsDir).sort()) {
504
+ if (wanted.has(cap)) continue;
505
+ const specPath = path.join(specsDir, cap, "spec.md");
506
+ if (!isFile(specPath)) continue;
507
+ const deps = parseDependsOn(read(specPath)).filter((d) => wanted.has(d));
508
+ if (deps.length > 0) out.push({ capability: cap, dependsOn: deps });
509
+ }
510
+ return out;
511
+ }
512
+
513
+ // ---------------------------------------------------------------- staged
514
+
515
+ /**
516
+ * The same question as `collectIndexDrift`, asked of the COMMIT instead of
517
+ * the disk.
518
+ *
519
+ * `index.json` is derived from the whole tree, so it cannot describe a
520
+ * subset of it. Stage some of `.doctrina/` and the index that rides along
521
+ * names artifacts the commit does not carry — and every gate that would
522
+ * catch it reads the working tree, where those artifacts are still present.
523
+ * Four commits of this repository shipped that way, each green locally and
524
+ * each red on checkout: `index.json references missing artifact`.
525
+ *
526
+ * Read with git plumbing, never from disk: `:<path>` is the staged blob and
527
+ * `ls-files --cached` is the file list the commit will have.
528
+ *
529
+ * @param {string} projectRoot
530
+ * @returns {{ok: boolean, applicable: boolean, missing: string[], unindexed: string[], reason: string|null}}
531
+ */
532
+ export function collectStagedIndexDrift(projectRoot) {
533
+ const nothing = { ok: true, applicable: false, missing: [], unindexed: [], reason: null };
534
+ if (!isRepo(projectRoot)) return { ...nothing, reason: "not a git repository" };
535
+
536
+ const blob = git(projectRoot, ["show", ":.doctrina/index.json"]);
537
+ if (blob.state !== GIT_STATE.OK || !blob.stdout.trim()) {
538
+ // Not staged at all — this commit does not carry an index, so it cannot
539
+ // carry one that disagrees with itself.
540
+ return { ...nothing, reason: "no staged .doctrina/index.json" };
541
+ }
542
+
543
+ let staged;
544
+ try {
545
+ staged = JSON.parse(blob.stdout);
546
+ } catch {
547
+ return { ok: false, applicable: true, missing: [], unindexed: [],
548
+ reason: "the staged .doctrina/index.json is not valid JSON" };
549
+ }
550
+
551
+ const files = new Set(gitLines(projectRoot, ["ls-files", "--cached", "--", ".doctrina"]));
552
+ // An entry's path is a file for specs, ADRs, skills and contracts, and a
553
+ // DIRECTORY for changes — a directory is in the commit when any file under
554
+ // it is.
555
+ const inCommit = (p) => files.has(p) || [...files].some((f) => f.startsWith(`${p}/`));
556
+
557
+ const artifacts = staged.artifacts ?? {};
558
+ const missing = [];
559
+ for (const [kind, entries] of Object.entries(artifacts)) {
560
+ if (!Array.isArray(entries)) continue;
561
+ for (const entry of entries) {
562
+ const p = String(entry?.path ?? "");
563
+ if (p && !inCommit(p)) missing.push(`${kind}: ${p}`);
564
+ }
565
+ }
566
+
567
+ // The mirror case: an artifact staged into the commit that the staged index
568
+ // never heard of. Same shapes `index rebuild` derives entries from.
569
+ const indexed = new Set();
570
+ for (const entries of Object.values(artifacts)) {
571
+ if (Array.isArray(entries)) for (const e of entries) indexed.add(String(e?.path ?? ""));
572
+ }
573
+ const unindexed = [];
574
+ for (const f of [...files].sort()) {
575
+ const owner = artifactPathOf(f);
576
+ if (owner && !indexed.has(owner)) unindexed.push(owner);
577
+ }
578
+
579
+ const unique = [...new Set(unindexed)];
580
+ return {
581
+ ok: missing.length === 0 && unique.length === 0,
582
+ applicable: true,
583
+ missing,
584
+ unindexed: unique,
585
+ reason: null,
586
+ };
587
+ }
588
+
589
+ // The index entry a staged file belongs to, or null when the file is not an
590
+ // artifact (templates, config.json, product.md, the index itself).
591
+ function artifactPathOf(file) {
592
+ let m = /^\.doctrina\/specs\/[^/]+\/spec\.md$/.exec(file);
593
+ if (m) return file;
594
+ m = /^\.doctrina\/(?:decisions|skills|contracts)\/[^/]+\.md$/.exec(file);
595
+ if (m) return file;
596
+ m = /^(\.doctrina\/changes\/archive\/[^/]+)\/proposal\.md$/.exec(file);
597
+ if (m) return m[1];
598
+ m = /^(\.doctrina\/changes\/(?!archive\/)[^/]+)\/proposal\.md$/.exec(file);
599
+ if (m) return m[1];
600
+ return null;
601
+ }
@@ -0,0 +1,147 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { isFile, read, write } from "./fs-ops.js";
4
+ import { today } from "./dates.js";
5
+ import { git, GIT_STATE, isRepo } from "./git.js";
6
+
7
+ // A manual sign-off has an EXPIRY (audit finding F12).
8
+ //
9
+ // `verify --signoff` recorded `{ date, note }`, that file was read by nothing
10
+ // but `verify` itself, and the signature held forever. So the one deliberate
11
+ // escape hatch from the build gate was also the one place a green gate could
12
+ // lie indefinitely: sign off "the error copy reads well", rewrite every
13
+ // message the next morning, and the gate still says yes.
14
+ //
15
+ // That is honest gates (ADR 0008) turned on the escape hatch itself. A
16
+ // signature is a statement about code at a moment; when that code moves, the
17
+ // statement is no longer evidence — it is history.
18
+ //
19
+ // The mechanism stays: a qualitative check is legitimate, and nothing here
20
+ // removes it. What changes is that the record now carries WHAT was signed
21
+ // (the covered paths, declared in verify.json like everything else — never
22
+ // inferred) and WHEN (the commit). `verify` compares the two against the
23
+ // working tree and reports one of four states.
24
+
25
+ export const SIGNOFF_REL = ".doctrina/verify.signoffs.json";
26
+
27
+ /**
28
+ * The state of a manual check's evidence.
29
+ *
30
+ * pending never signed off.
31
+ * fresh signed, and nothing it covers has moved since.
32
+ * expired signed, but a covered path changed after the signature.
33
+ * unverifiable signed in the pre-expiry format (no commit recorded), or
34
+ * signed outside a repository — so "has it moved?" has no
35
+ * answer. Not a pass: silence about whether evidence still
36
+ * holds is not evidence that it does.
37
+ *
38
+ * Only `fresh` passes. The other three are warnings by default and failures
39
+ * under --strict, which is exactly the rule `pending` already followed — a
40
+ * manual check has always been non-blocking locally and required in CI, and
41
+ * this keeps one rule rather than inventing a second.
42
+ */
43
+ export function signoffState(projectRoot, check, record) {
44
+ if (!record || !record.date) return { state: "pending" };
45
+ const paths = declaredPaths(check);
46
+
47
+ if (!record.sha) {
48
+ return {
49
+ state: "unverifiable",
50
+ why: "signed before sign-offs recorded a commit, so what it covered cannot be checked",
51
+ record,
52
+ };
53
+ }
54
+ if (!isRepo(projectRoot)) {
55
+ return { state: "unverifiable", why: "not a git repository, so a change since the signature cannot be detected", record };
56
+ }
57
+ if (paths.length === 0) {
58
+ return {
59
+ state: "unverifiable",
60
+ why: `the check declares no "paths", so there is nothing to hold the signature to`,
61
+ record,
62
+ };
63
+ }
64
+
65
+ const changed = changedSince(projectRoot, record.sha, paths);
66
+ if (changed === null) {
67
+ return { state: "unverifiable", why: `commit ${short(record.sha)} is not in this repository's history`, record };
68
+ }
69
+ if (changed.length > 0) return { state: "expired", changed, record };
70
+ return { state: "fresh", record };
71
+ }
72
+
73
+ /** The paths a check declares it covers. Declared, never inferred. */
74
+ export function declaredPaths(check) {
75
+ const raw = check?.paths;
76
+ if (Array.isArray(raw)) return raw.filter((p) => typeof p === "string" && p.trim() !== "");
77
+ if (typeof raw === "string" && raw.trim() !== "") return [raw];
78
+ return [];
79
+ }
80
+
81
+ /**
82
+ * Which of `paths` changed between `sha` and the working tree.
83
+ * Returns null when `sha` is not a commit this repository knows — an
84
+ * unanswerable question, reported as such rather than as "nothing changed".
85
+ */
86
+ export function changedSince(projectRoot, sha, paths) {
87
+ const known = git(projectRoot, ["cat-file", "-e", `${sha}^{commit}`]);
88
+ if (known.state !== GIT_STATE.OK) return null;
89
+ // Committed changes and the working tree both count: a signature is about
90
+ // the code as it stands, not as it was last committed.
91
+ const committed = git(projectRoot, ["diff", "--name-only", sha, "--", ...paths]);
92
+ const working = git(projectRoot, ["diff", "--name-only", "HEAD", "--", ...paths]);
93
+ if (committed.state !== GIT_STATE.OK) return null;
94
+ const out = new Set(committed.lines.filter(Boolean));
95
+ if (working.state === GIT_STATE.OK) for (const f of working.lines.filter(Boolean)) out.add(f);
96
+ return [...out].sort();
97
+ }
98
+
99
+ /** The commit a signature is anchored to, or null outside a repository. */
100
+ export function currentSha(projectRoot) {
101
+ const r = git(projectRoot, ["rev-parse", "HEAD"]);
102
+ return r.state === GIT_STATE.OK ? r.stdout.trim() : null;
103
+ }
104
+
105
+ export function short(sha) {
106
+ return String(sha ?? "").slice(0, 8);
107
+ }
108
+
109
+ export function loadSignoffs(projectRoot) {
110
+ const p = path.join(projectRoot, SIGNOFF_REL);
111
+ if (!isFile(p)) return {};
112
+ try {
113
+ return JSON.parse(read(p)) ?? {};
114
+ } catch {
115
+ return {};
116
+ }
117
+ }
118
+
119
+ export function saveSignoffs(projectRoot, signoffs) {
120
+ write(path.join(projectRoot, SIGNOFF_REL), JSON.stringify(signoffs, null, 2) + "\n", { force: true });
121
+ }
122
+
123
+ /** Record a signature against today's date, the current commit, and what it covers. */
124
+ export function recordSignoff(projectRoot, check, note) {
125
+ const entry = { date: today(), note };
126
+ const sha = currentSha(projectRoot);
127
+ if (sha) entry.sha = sha;
128
+ const paths = declaredPaths(check);
129
+ if (paths.length > 0) entry.paths = paths;
130
+ return entry;
131
+ }
132
+
133
+ /**
134
+ * How many of a project's manual checks are signed and still fresh — the
135
+ * number that lets a report say "executed proof" and "signed proof" apart
136
+ * instead of counting both as green.
137
+ */
138
+ export function summarizeSignoffs(projectRoot, checks) {
139
+ const signoffs = loadSignoffs(projectRoot);
140
+ const counts = { manual: 0, fresh: 0, expired: 0, unverifiable: 0, pending: 0 };
141
+ for (const ch of checks) {
142
+ if (ch?.type !== "manual") continue;
143
+ counts.manual += 1;
144
+ counts[signoffState(projectRoot, ch, signoffs[ch.name ?? "check"]).state] += 1;
145
+ }
146
+ return counts;
147
+ }