doctrina-cli 0.16.0 → 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 (60) hide show
  1. package/README.md +27 -72
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +3 -2
  4. package/src/commands/analyze.js +8 -0
  5. package/src/commands/change.js +28 -77
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +65 -10
  8. package/src/commands/completion.js +8 -2
  9. package/src/commands/context.js +6 -4
  10. package/src/commands/decision.js +12 -0
  11. package/src/commands/doctor.js +1 -1
  12. package/src/commands/hooks.js +6 -1
  13. package/src/commands/index-rebuild.js +52 -2
  14. package/src/commands/init.js +19 -4
  15. package/src/commands/intake.js +55 -7
  16. package/src/commands/intent.js +14 -1
  17. package/src/commands/metrics.js +13 -5
  18. package/src/commands/next.js +1 -1
  19. package/src/commands/prime.js +4 -5
  20. package/src/commands/report.js +9 -20
  21. package/src/commands/skill.js +8 -15
  22. package/src/commands/spec.js +13 -7
  23. package/src/commands/status.js +39 -20
  24. package/src/commands/templates.js +4 -10
  25. package/src/commands/upgrade.js +31 -6
  26. package/src/commands/validate.js +32 -8
  27. package/src/commands/work.js +10 -5
  28. package/src/index.js +34 -6
  29. package/src/lib/actions.js +30 -24
  30. package/src/lib/args.js +14 -0
  31. package/src/lib/change-ops.js +3 -1
  32. package/src/lib/commands.js +88 -15
  33. package/src/lib/coverage-model.js +40 -1
  34. package/src/lib/doc-model.js +47 -6
  35. package/src/lib/docs-impact.js +74 -0
  36. package/src/lib/ears.js +24 -1
  37. package/src/lib/gates.js +53 -9
  38. package/src/lib/index-json.js +40 -5
  39. package/src/lib/intake-model.js +71 -2
  40. package/src/lib/lexicon.js +38 -1
  41. package/src/lib/metrics-model.js +30 -0
  42. package/src/lib/project.js +49 -1
  43. package/src/lib/scan.js +126 -14
  44. package/src/lib/snapshot.js +23 -1
  45. package/src/lib/spec-ops.js +24 -5
  46. package/src/lib/templates-model.js +35 -8
  47. package/src/lib/validation-model.js +116 -34
  48. package/src/lib/views.js +4 -1
  49. package/src/lib/work-model.js +25 -0
  50. package/templates/AGENTS.md.template +3 -4
  51. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  52. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  53. package/templates/change/proposal.md.template +15 -0
  54. package/templates/change/tasks.md.template +3 -8
  55. package/templates/contract.md.template +2 -2
  56. package/templates/hooks/pre-commit.sample +10 -0
  57. package/templates/playbooks/bootstrap.md.template +6 -2
  58. package/templates/playbooks/chore.md.template +9 -4
  59. package/templates/playbooks/work.md.template +10 -3
  60. package/src/commands/constitution.js +0 -68
@@ -64,10 +64,45 @@ export function blank(projectName, date) {
64
64
  };
65
65
  }
66
66
 
67
+ // INSERT WHERE A REBUILD WOULD HAVE PUT IT.
68
+ //
69
+ // Every category here is written twice: incrementally, by the command that
70
+ // creates the artifact, and wholesale by `index rebuild`, which walks the
71
+ // directory — `readdirSync(...).sort()` — and writes what it finds, in that
72
+ // order. The two agreed only by luck: appending matches a sorted walk exactly
73
+ // when the new entry happens to sort last.
74
+ //
75
+ // It usually does, which is why this survived. `doctrina spec new alpha` after
76
+ // `spec new zebra` does not, and neither does closing change 0138 after 0139 —
77
+ // that one shipped, and turned all six test legs of CI red on a tree whose own
78
+ // `close` had just reported green.
79
+ //
80
+ // `validate` cannot see it: drift of this kind is only visible by rebuilding
81
+ // and comparing, which is `index rebuild --check`, a different gate. So the
82
+ // tree looked healthy from inside and failed from outside.
83
+ //
84
+ // Each entry's `path` embeds the on-disk name the rebuild sorts by, and the
85
+ // parent directory is constant within a category — so ordering by `path`
86
+ // reproduces the walk exactly, without this module having to know how any
87
+ // category is laid out.
88
+ // The comparison has to be the SAME one: `readdirSync(dir).sort()` takes the
89
+ // default comparator, which orders by UTF-16 code unit, and `localeCompare`
90
+ // does not — it ignores or reweights punctuation, so `0100-a` and `0100_a`
91
+ // can come out in the opposite order. Matching the walk means comparing the
92
+ // way the walk compares.
93
+ function insertInRebuildOrder(list, entry) {
94
+ list.push(entry);
95
+ list.sort((a, b) => {
96
+ const x = String(a.path ?? ""), y = String(b.path ?? "");
97
+ return x < y ? -1 : x > y ? 1 : 0;
98
+ });
99
+ return list;
100
+ }
101
+
67
102
  export function addSkill(index, entry) {
68
103
  if (!index.artifacts.skills) index.artifacts.skills = [];
69
104
  if (!index.artifacts.skills.some((s) => s.id === entry.id)) {
70
- index.artifacts.skills.push(entry);
105
+ insertInRebuildOrder(index.artifacts.skills, entry);
71
106
  }
72
107
  return index;
73
108
  }
@@ -75,7 +110,7 @@ export function addSkill(index, entry) {
75
110
  export function addContract(index, entry) {
76
111
  if (!index.artifacts.contracts) index.artifacts.contracts = [];
77
112
  if (!index.artifacts.contracts.some((s) => s.id === entry.id)) {
78
- index.artifacts.contracts.push(entry);
113
+ insertInRebuildOrder(index.artifacts.contracts, entry);
79
114
  }
80
115
  return index;
81
116
  }
@@ -87,14 +122,14 @@ export function touch(index, date) {
87
122
 
88
123
  export function addSpec(index, entry) {
89
124
  if (!index.artifacts.specs.some((s) => s.id === entry.id)) {
90
- index.artifacts.specs.push(entry);
125
+ insertInRebuildOrder(index.artifacts.specs, entry);
91
126
  }
92
127
  return index;
93
128
  }
94
129
 
95
130
  export function addDecision(index, entry) {
96
131
  if (!index.artifacts.decisions.some((d) => d.id === entry.id)) {
97
- index.artifacts.decisions.push(entry);
132
+ insertInRebuildOrder(index.artifacts.decisions, entry);
98
133
  }
99
134
  return index;
100
135
  }
@@ -110,7 +145,7 @@ export function updateDecision(index, id, mutator) {
110
145
  export function moveChangeToArchive(index, id, archiveEntry) {
111
146
  index.artifacts.changes = index.artifacts.changes.filter((c) => c.id !== id);
112
147
  if (!index.artifacts.changes_archive.some((c) => c.id === id)) {
113
- index.artifacts.changes_archive.push(archiveEntry);
148
+ insertInRebuildOrder(index.artifacts.changes_archive, archiveEntry);
114
149
  }
115
150
  return index;
116
151
  }
@@ -10,6 +10,8 @@ import { exists, isFile, read, relPath, write } from "./fs-ops.js";
10
10
  import { assessBrief } from "./clarity.js";
11
11
  import { c } from "./colors.js";
12
12
  import { printPlaybookTemplate } from "./playbook.js";
13
+ import { getHeader, setHeader } from "./doc-model.js";
14
+ import { stateWord } from "./spec-ops.js";
13
15
  // Clarification gate (review Topic A). A description too thin to spec from is
14
16
  // the moment to ask the user, not to let the agent invent requirements. Prints
15
17
  // the specific gaps before the bootstrap playbook; advisory, never blocking —
@@ -39,6 +41,30 @@ export function looksLikePath(value) {
39
41
  return false;
40
42
  }
41
43
 
44
+ // What `init` tells a person to do next (change 0173). The first run used to
45
+ // contradict itself three ways: `init` said "edit AGENTS.md and product.md",
46
+ // `next` asked for the description again as an intake, and AGENTS.md said the
47
+ // agent runs the commands while the human stays passive. One answer now,
48
+ // the same one `next` and AGENTS.md give.
49
+ //
50
+ // `intakeFrom` is "tty" when the description was typed at init's question
51
+ // (it is stored as the intake), or null when init got no intake at all.
52
+ export function nextStepAfterInit(intakeFrom) {
53
+ if (intakeFrom === "tty") {
54
+ return [
55
+ "Next: open your AI agent in this repository and tell it:",
56
+ " read AGENTS.md and run doctrina next",
57
+ "It turns your description into product.md and specs; you review and approve.",
58
+ ];
59
+ }
60
+ return [
61
+ "Next: give your AI agent the whole project description. It runs",
62
+ " doctrina intake --text \"<description>\"",
63
+ "and turns it into product.md and specs (AGENTS.md, \"Working from intent\");",
64
+ "for an existing codebase, `doctrina work --from-diff` backfills them from the code.",
65
+ ];
66
+ }
67
+
42
68
  export function warnIfThinIntake(body) {
43
69
  const assessment = assessBrief(body, { kind: "intake" });
44
70
  if (!assessment.thin) return;
@@ -47,8 +73,51 @@ export function warnIfThinIntake(body) {
47
73
  console.log("");
48
74
  }
49
75
 
76
+ // THE INTAKE'S STATUS IS A CONTROL VALUE, SO IT GETS AN ENUM AND AN OWNER.
77
+ //
78
+ // `next` branches on it: pending means the bootstrap is unfinished, and every
79
+ // other artifact's status in this framework is both written by a command and
80
+ // checked against an enum — `spec set` refuses "Status: nonsense", and
81
+ // `validate` calls it an error. The intake was the one header the playbook
82
+ // told an agent to edit BY HAND, and the one nothing read back.
83
+ //
84
+ // So any token that was not exactly "converted" meant pending, in silence:
85
+ // "convertido" typed in a Portuguese project, "done", or an empty value left
86
+ // by a botched edit. `validate` exited 0 on all of them and `next` went on
87
+ // asking for a bootstrap that had already happened.
88
+ export const INTAKE_STATUSES = ["pending", "converted"];
89
+
90
+ /** The intake's status word, note stripped and folded. Defaults to pending. */
91
+ export function intakeStatus(text) {
92
+ const raw = getHeader(String(text ?? ""), "Status");
93
+ if (raw === null) return "pending";
94
+ return stateWord(raw) || "";
95
+ }
96
+
97
+ /** null when the intake's status is a declared value; otherwise the error. */
98
+ export function intakeStatusError(text) {
99
+ const word = intakeStatus(text);
100
+ if (INTAKE_STATUSES.includes(word)) return null;
101
+ const got = getHeader(String(text ?? ""), "Status");
102
+ return `Status must be one of ${INTAKE_STATUSES.join("|")} `
103
+ + `(got "${String(got ?? "").trim()}") — every other value reads as pending, in silence`;
104
+ }
105
+
106
+ /**
107
+ * Flip the stored intake to `converted`. Returns the path written, or null
108
+ * when there is no intake (or no Status header) to flip.
109
+ */
110
+ export function markIntakeConverted(projectRoot) {
111
+ const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
112
+ if (!exists(intakePath)) return null;
113
+ const next = setHeader(read(intakePath), "Status", "converted");
114
+ if (next === null) return null;
115
+ write(intakePath, next, { force: true });
116
+ return intakePath;
117
+ }
118
+
50
119
  // Shared with `init --intake`. Writes the intake verbatim under a small
51
- // status header; the agent flips Status to converted at the end of the
120
+ // status header; `doctrina intake --converted` flips it at the end of the
52
121
  // playbook, which is how `next` knows the bootstrap is done.
53
122
  export function writeIntakeFile(projectRoot, { body, source, projectName, date, force = false }) {
54
123
  const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
@@ -61,7 +130,7 @@ export function writeIntakeFile(projectRoot, { body, source, projectName, date,
61
130
  "",
62
131
  "<!-- Raw project intent, stored verbatim. The bootstrap playbook",
63
132
  " (doctrina intake) converts it into product.md and capability",
64
- " specs, then flips Status to converted. After conversion the",
133
+ " specs. Close it with `doctrina intake --converted`; after that the",
65
134
  " specs are the only source of truth; never edit this file to",
66
135
  " change requirements. -->",
67
136
  "",
@@ -183,9 +183,46 @@ export const CONFIDENT_MARGIN = 10;
183
183
  // content words, no interpretation (ADR 0005).
184
184
  export const SLUG_WORDS = 4;
185
185
 
186
+ // A number is content in an id (change 0191). `terms` serves retrieval, where
187
+ // a bare "24" matches everything, so it keeps only words that start with a
188
+ // letter — and the slug inherited that: "cortar a 0.17.0" became `cortar`
189
+ // and "migrar para Node 24" `migrar-node`. The slug reads its own tokens: a
190
+ // version or a number is one word, its dots folded into hyphens.
191
+ function slugTerms(prompt) {
192
+ const words = (fold(String(prompt ?? "")).match(/[a-z0-9][a-z0-9.-]*/g) ?? [])
193
+ .map((w) => w.replace(/[.-]+$/g, "").replace(/\./g, "-"))
194
+ .filter((w) => (w.length > 1 || /^\d$/.test(w)) && !STOPWORDS.has(w));
195
+ return [...new Set(words)];
196
+ }
197
+
186
198
  export function slugFromPrompt(prompt, { words = SLUG_WORDS } = {}) {
187
- const content = terms(prompt).slice(0, words);
199
+ const content = slugTerms(prompt).slice(0, words);
188
200
  const slug = slugify(content.join(" "));
189
201
  // A prompt of nothing but stopwords ("do it now") still needs an id.
190
202
  return slug || slugify(prompt).split("-").slice(0, words).join("-");
191
203
  }
204
+
205
+ // A SKILL SLUG IS FOLDED, NOT SHREDDED.
206
+ //
207
+ // `skill suggest` names a lesson from text a person wrote — a change id, a
208
+ // commit subject, the seed tokens of an error. That name became a filename,
209
+ // and it was built by stripping everything outside `[a-z0-9-]` from the raw
210
+ // text. On Portuguese input the strip is destructive rather than normalising:
211
+ // every capital is DELETED and every accented letter becomes a hyphen, so
212
+ // "Corrigir A Validação" came out "orrigir-alida-o" and a real run wrote
213
+ // `.doctrina/skills/o-modelo-de-documento-n-o-l-cabe-alho-de.md`.
214
+ //
215
+ // The tell was already in this module: SLUG_NOISE lists "nao", a token the
216
+ // old pipeline could never produce. So the slug goes through `slugify`, the
217
+ // same fold every other slug in the tree uses.
218
+ //
219
+ // The leading numeric run goes with it. The source is often a change id
220
+ // ("0049-fix-tela-preta"), the digits identify nothing about the lesson, and
221
+ // the skills spec requires a filename slug to start with a letter.
222
+ export const SKILL_SLUG_MAX = 40;
223
+
224
+ export function skillSlug(text, { max = SKILL_SLUG_MAX } = {}) {
225
+ const slug = slugify(text).replace(/^\d+(?:-|$)/, "");
226
+ const capped = slug.length > max ? slug.slice(0, max).replace(/-+$/g, "") : slug;
227
+ return capped || "lesson";
228
+ }
@@ -83,6 +83,36 @@ export function round(x) {
83
83
  }
84
84
 
85
85
 
86
+ const WINDOW_UNIT = /^(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago$/i;
87
+
88
+ /**
89
+ * The git `--since` value for a `--since` flag, or null when the flag is not
90
+ * a window this can check.
91
+ *
92
+ * git never refuses a date: "abc" parses as NOW (an empty window, reported
93
+ * as "nothing to measure") and "2026-13-45" as some other day. Handing it
94
+ * free text turned a typo into a confident, wrong answer, so only the forms
95
+ * whose meaning is certain pass: a day count, a calendar date
96
+ * (YYYY-MM-DD), or "<n> <unit>s ago".
97
+ *
98
+ * @param {string} raw
99
+ * @returns {string | null}
100
+ */
101
+ export function sinceWindow(raw) {
102
+ const s = String(raw ?? "").trim();
103
+ if (/^\d+$/.test(s)) return Number(s) > 0 ? `${Number(s)} days ago` : null;
104
+ const date = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
105
+ if (date) {
106
+ const [y, m, d] = date.slice(1).map(Number);
107
+ const t = new Date(Date.UTC(y, m - 1, d));
108
+ const real = t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d;
109
+ return real ? s : null;
110
+ }
111
+ const rel = s.match(WINDOW_UNIT);
112
+ if (rel) return Number(rel[1]) > 0 ? s.replace(/\s+/g, " ") : null;
113
+ return null;
114
+ }
115
+
86
116
  /**
87
117
  * A snapshot for a window, or null when there is no history to read.
88
118
  *
@@ -1,7 +1,9 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import { exists } from "./fs-ops.js";
4
- import { notADoctrinaProject } from "./exit-codes.js";
4
+ import { EXIT, notADoctrinaProject } from "./exit-codes.js";
5
+ import { c } from "./colors.js";
6
+ import { load } from "./index-json.js";
5
7
 
6
8
  // The one precondition every command shares: this is a Doctrina project.
7
9
  // Six command modules carried an identical private copy of it; a lib
@@ -13,6 +15,31 @@ export function ensureDoctrinaProject(projectRoot) {
13
15
  }
14
16
  }
15
17
 
18
+ /**
19
+ * What this project is CALLED — the name it recorded at `init`, with the
20
+ * directory as the fallback for a tree that has none.
21
+ *
22
+ * Four modules asked this privately and one of them answered differently:
23
+ * `adapter` read the directory and never the record, so every adapter file
24
+ * installed after `doctrina init --project-name "Minha Carteira"` greeted the
25
+ * agent with the folder's name instead. `init --agent`, holding the name it
26
+ * had just been given, got it right — the same templates, two callers, two
27
+ * answers.
28
+ *
29
+ * Pass `index` when you already hold it; the helper then reads nothing.
30
+ *
31
+ * @param {string} projectRoot
32
+ * @param {any} [index] an already-loaded index.json, when the caller has one
33
+ */
34
+ export function projectName(projectRoot, index) {
35
+ if (index !== undefined) return index?.project ?? path.basename(projectRoot);
36
+ try {
37
+ return load(projectRoot)?.project ?? path.basename(projectRoot);
38
+ } catch {
39
+ return path.basename(projectRoot);
40
+ }
41
+ }
42
+
16
43
  // The grammar a change id obeys: lowercase letters, digits and hyphens,
17
44
  // opening on a letter or a digit — `0042-short-slug` is the shape `work`
18
45
  // derives. Deliberately NOT the capability grammar, which additionally
@@ -31,6 +58,27 @@ export function isChangeId(id) {
31
58
  return typeof id === "string" && CHANGE_ID.test(id);
32
59
  }
33
60
 
61
+ // A reference to an EXISTING change is looser than the id `change new`
62
+ // accepts — a project may hold a change named before that grammar — but it
63
+ // is always a folder NAME under .doctrina/changes/, never a path. Nothing
64
+ // checked that: `change archive ../../victim --force` moved the project's
65
+ // own `victim/` directory into the archive and wrote it into the ledger and
66
+ // the index (change 0196). `archive` is the archive's own folder, not a
67
+ // change.
68
+ export function isChangeRef(id) {
69
+ return typeof id === "string" && id.length > 0 && id !== "." && id !== ".."
70
+ && id !== "archive" && !/[\/\\\0]/.test(id);
71
+ }
72
+
73
+ // The one refusal every command that takes a change reference prints: the
74
+ // USAGE class, before anything is resolved against the filesystem.
75
+ export function refuseChangeRef(id) {
76
+ if (isChangeRef(id)) return null;
77
+ console.error(c.red("error:") + ` "${id}" is not a change — a change is named by its folder under .doctrina/changes/, never by a path`);
78
+ console.error(c.gray("hint: ") + "open changes: `doctrina next`");
79
+ return EXIT.USAGE;
80
+ }
81
+
34
82
  /**
35
83
  * Resolve `segments` under `projectRoot` and refuse anything that escapes it.
36
84
  *
package/src/lib/scan.js CHANGED
@@ -1,14 +1,16 @@
1
1
  // @ts-check
2
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
8
  import { cliVersion, newestVersion } from "./version.js";
8
9
  import { load } from "./index-json.js";
9
- import { parseFrontmatter } from "./doc-model.js";
10
+ import { parseFrontmatter, isScaffoldValue } from "./doc-model.js";
10
11
  import { parseCapabilityFromDelta } from "./doc-model.js";
11
12
  import { parseOperation } from "./doc-model.js";
13
+ import { git, gitLines, isRepo, GIT_STATE } from "./git.js";
12
14
 
13
15
  // Header reading lives in ONE place now (lib/doc-model.js, audit item M3).
14
16
  // These two names survive because dozens of call sites use them and the
@@ -168,6 +170,23 @@ export function changeEntry(proposal, id, prev, date) {
168
170
  }
169
171
 
170
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
+ }
189
+
171
190
  // Regenerate the index object from the artifacts on disk. The files are
172
191
  // the source of truth; fields with no on-disk source (project name,
173
192
  // framework_version, product metadata) are carried over from `current`.
@@ -178,7 +197,7 @@ export function deriveIndex(projectRoot, current) {
178
197
 
179
198
  const out = {
180
199
  $schema_version: current?.$schema_version ?? "0.1.0",
181
- project: current?.project ?? path.basename(projectRoot),
200
+ project: projectName(projectRoot, current),
182
201
  framework_version: current?.framework_version ?? "0.0.0",
183
202
  last_updated: current?.last_updated ?? date,
184
203
  // Project settings, carried over verbatim. `deriveIndex` rebuilds the
@@ -284,14 +303,9 @@ export function deriveIndex(projectRoot, current) {
284
303
  specsAffected.push({ capability, operation: parseOperation(text) ?? "MODIFIED" });
285
304
  }
286
305
  }
287
- out.artifacts.changes_archive.push({
288
- id: m[2],
289
- title: title ?? prev?.title ?? m[2],
290
- path: `.doctrina/changes/archive/${name}`,
291
- status: "applied",
292
- applied: m[1],
293
- specs_affected: specsAffected,
294
- });
306
+ out.artifacts.changes_archive.push(archivedChangeEntry(proposal, {
307
+ id: m[2], archiveName: name, applied: m[1], specsAffected, prev, title,
308
+ }));
295
309
  }
296
310
 
297
311
  // Contracts — the integration/runtime surface (ports, env, interfaces)
@@ -310,18 +324,26 @@ export function deriveIndex(projectRoot, current) {
310
324
  });
311
325
  }
312
326
 
313
- // 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.
314
332
  const skillsDir = path.join(dot, "skills");
315
333
  for (const f of walk(skillsDir)) {
316
334
  if (!f.endsWith(".md")) continue;
317
335
  const id = path.basename(f, ".md");
318
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>";
319
342
  out.artifacts.skills.push({
320
343
  id,
321
344
  path: `.doctrina/skills/${id}.md`,
322
- description: parseFrontmatter(read(f), "description")
323
- ?? prev?.description ?? "<edit me — one-sentence summary>",
324
- last_updated: prev?.last_updated ?? date,
345
+ description,
346
+ last_updated: prev && prev.description === description ? prev.last_updated ?? date : date,
325
347
  });
326
348
  }
327
349
 
@@ -487,3 +509,93 @@ export function dependentsOf(projectRoot, caps) {
487
509
  }
488
510
  return out;
489
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
+ }
@@ -2,6 +2,7 @@
2
2
  import path from "node:path";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { isDir, isFile, read, walk } from "./fs-ops.js";
5
+ import { projectName } from "./project.js";
5
6
  import * as idx from "./index-json.js";
6
7
  import { deriveIndex, indexesMatch, specHeader, listHeader } from "./scan.js";
7
8
  import { cliVersion } from "./version.js";
@@ -12,6 +13,8 @@ import { computeActions } from "./actions.js";
12
13
  import { readLedger } from "./ledger.js";
13
14
  import { parseChangeTitle, checklistProgress } from "./doc-model.js";
14
15
  import { summarizeSignoffs } from "./signoff.js";
16
+ import { gitWindow, historyState, windowCutoff } from "./git.js";
17
+ import { collectMetrics } from "./metrics-model.js";
15
18
 
16
19
  // ONE collector, several views (audit finding F7).
17
20
  //
@@ -57,7 +60,7 @@ export function collectSnapshot(projectRoot, { actions = true } = {}) {
57
60
  const verify = readVerifyConfig(projectRoot);
58
61
 
59
62
  return {
60
- project: index?.project ?? path.basename(projectRoot),
63
+ project: projectName(projectRoot, index),
61
64
  stamp: index?.framework_version ?? null,
62
65
  cli: cliVersion(),
63
66
  indexState,
@@ -207,3 +210,22 @@ function countSkills(projectRoot) {
207
210
  if (!isDir(skillsDir)) return 0;
208
211
  return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
209
212
  }
213
+
214
+ /**
215
+ * Everything the period digest reads beyond the snapshot, for a window of
216
+ * `days`. ONE collector for every door that renders the digest: `report`
217
+ * passed the metrics and `status --view report` did not, so the view that
218
+ * promised "never different numbers" printed two lines fewer (change 0176).
219
+ *
220
+ * @param {string} projectRoot
221
+ * @param {number} days
222
+ */
223
+ export function collectReportWindow(projectRoot, days) {
224
+ return {
225
+ days,
226
+ cutoffIso: windowCutoff(days),
227
+ git: gitWindow(projectRoot, days),
228
+ gitState: historyState(projectRoot),
229
+ metrics: collectMetrics(projectRoot, `${days} days ago`),
230
+ };
231
+ }
@@ -256,7 +256,10 @@ export function setHeader(text, name, value) {
256
256
  // has a lifecycle of its own (proposed -> applied, accepted, superseded).
257
257
  const listForm = /^\s*-/.test(text.match(re)?.[1] ?? "");
258
258
  const domain = listForm ? null : headerValueError(name, value);
259
- if (domain) return { error: `set-header: ${domain}` };
259
+ // `cause: "argument"` marks an error about the value supplied, not about
260
+ // the document: `spec set` answers it as USAGE, a delta as a defect of
261
+ // the work (ADR 0018).
262
+ if (domain) return { error: `set-header: ${domain}`, cause: "argument" };
260
263
  const next = text.replace(re, (_m, prefix) => `${prefix} ${value}`.replace(/\s+$/, ""));
261
264
  return { text: next, summary: `set ${name}: ${value}` };
262
265
  }
@@ -309,10 +312,10 @@ export function setCriterionMark(text, n, markRaw) {
309
312
  const loc = locateCriteria(text);
310
313
  if (!loc) return { error: "set-criterion: spec has no '## Acceptance criteria' section" };
311
314
  const item = loc.items.find((it) => it.n === n);
312
- if (!item) return { error: `set-criterion: no criterion #${n} found` };
315
+ if (!item) return { error: `set-criterion: no criterion #${n} found`, cause: "argument" };
313
316
  const mark = markRaw.replace(/^\[|\]$/g, "").trim();
314
317
  const domain = criterionMarkError(mark);
315
- if (domain) return { error: `set-criterion: ${domain}` };
318
+ if (domain) return { error: `set-criterion: ${domain}`, cause: "argument" };
316
319
  const line = loc.lines[item.lineIndex];
317
320
  let next;
318
321
  if (/\[[^\]]*\]/.test(line)) {
@@ -481,8 +484,24 @@ export function replaceRequirement(text, section, n, value) {
481
484
  if (n > real.length) {
482
485
  return { error: `replace-requirement: section "${section}" has ${real.length} requirement${real.length === 1 ? "" : "s"}, no #${n}` };
483
486
  }
484
- const indent = loc.lines[real[n - 1]].match(/^(\s*)/)[1];
485
- loc.lines[real[n - 1]] = `${indent}- ${value}`;
487
+ // REPLACE THE WHOLE ITEM, continuation lines included.
488
+ //
489
+ // Writing only the first line leaves the old bullet's wrapped prose sitting
490
+ // under the new one, indented, reading as part of it. That is not cosmetic:
491
+ // the orphan is the PREVIOUS version of the requirement, so the spec ends up
492
+ // stating a contract and then contradicting it two lines later, and a spec
493
+ // is the one artifact this framework asks everyone to trust.
494
+ //
495
+ // It had already happened four times in this repository's own tree before
496
+ // anyone noticed — twice in `gates` (a closing sequence missing its review
497
+ // and runtime steps, a doctor set missing its runtime row) and once in
498
+ // `insight`, all three describing older behaviour beside the current one.
499
+ //
500
+ // `append-requirement` learned this in 0.13.0 and got `endOfItem`; this verb
501
+ // was written afterwards and never picked it up.
502
+ const start = real[n - 1];
503
+ const indent = loc.lines[start].match(/^(\s*)/)[1];
504
+ loc.lines.splice(start, endOfItem(loc.lines, start) - start + 1, `${indent}- ${value}`);
486
505
  return { text: loc.lines.join("\n"), summary: `replace-requirement ${section} ${n}` };
487
506
  }
488
507