doctrina-cli 0.16.0 → 0.18.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 (71) hide show
  1. package/README.md +31 -73
  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 +86 -80
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +136 -34
  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 +2 -1
  19. package/src/commands/prime.js +4 -5
  20. package/src/commands/report.js +9 -20
  21. package/src/commands/review.js +47 -8
  22. package/src/commands/skill.js +8 -139
  23. package/src/commands/spec.js +13 -7
  24. package/src/commands/status.js +39 -20
  25. package/src/commands/templates.js +4 -10
  26. package/src/commands/upgrade.js +31 -6
  27. package/src/commands/validate.js +32 -8
  28. package/src/commands/verify.js +59 -6
  29. package/src/commands/work.js +63 -8
  30. package/src/index.js +34 -6
  31. package/src/lib/actions.js +38 -28
  32. package/src/lib/args.js +14 -0
  33. package/src/lib/change-ops.js +95 -1
  34. package/src/lib/commands.js +96 -15
  35. package/src/lib/coverage-model.js +40 -1
  36. package/src/lib/doc-model.js +47 -6
  37. package/src/lib/docs-impact.js +74 -0
  38. package/src/lib/ears.js +24 -1
  39. package/src/lib/gates.js +53 -9
  40. package/src/lib/git.js +59 -2
  41. package/src/lib/index-json.js +40 -5
  42. package/src/lib/intake-model.js +71 -2
  43. package/src/lib/lexicon.js +38 -1
  44. package/src/lib/metrics-model.js +30 -0
  45. package/src/lib/playbook.js +1 -1
  46. package/src/lib/project.js +49 -1
  47. package/src/lib/runtime.js +6 -0
  48. package/src/lib/scan.js +139 -14
  49. package/src/lib/signoff.js +24 -0
  50. package/src/lib/skill-candidates.js +162 -0
  51. package/src/lib/snapshot.js +61 -2
  52. package/src/lib/spec-ops.js +141 -6
  53. package/src/lib/templates-model.js +35 -8
  54. package/src/lib/validation-model.js +117 -35
  55. package/src/lib/verify-cache.js +76 -0
  56. package/src/lib/views.js +10 -2
  57. package/src/lib/work-model.js +57 -12
  58. package/templates/AGENTS.md.template +3 -4
  59. package/templates/README.md +2 -0
  60. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  61. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  62. package/templates/change/proposal.md.template +15 -0
  63. package/templates/change/spec-delta.md.template +2 -0
  64. package/templates/change/tasks.md.template +3 -8
  65. package/templates/contract.md.template +2 -2
  66. package/templates/hooks/pre-commit.sample +10 -0
  67. package/templates/playbooks/bootstrap.md.template +6 -2
  68. package/templates/playbooks/chore.md.template +9 -4
  69. package/templates/playbooks/consolidate.md.template +28 -0
  70. package/templates/playbooks/work.md.template +13 -3
  71. package/src/commands/constitution.js +0 -68
package/src/lib/gates.js CHANGED
@@ -24,10 +24,10 @@ import { appendLedgerLine, forcedLine } from "./ledger.js";
24
24
  // A gate is a named precondition returning human-readable blockers.
25
25
  export const GATES = {
26
26
  // Structural validity: the change's own files parse and point at real
27
- // targets. The same checks `doctrina analyze` reports.
27
+ // targets. The same checks `doctrina change check` reports first.
28
28
  structure: {
29
29
  label: "structure",
30
- rerun: (id) => `doctrina analyze ${id}`,
30
+ rerun: (id) => `doctrina change check ${id}`,
31
31
  blockers(projectRoot, changeDir) {
32
32
  return collectAnalysis(projectRoot, changeDir)
33
33
  .filter((r) => r.kind === "fail")
@@ -49,7 +49,7 @@ export const GATES = {
49
49
  // history recorded six hollow proposals reaching the archive.
50
50
  integrity: {
51
51
  label: "integrity",
52
- rerun: (id) => `doctrina analyze ${id}`,
52
+ rerun: (id) => `doctrina change check ${id}`,
53
53
  blockers(projectRoot, changeDir) {
54
54
  return collectAnalysis(projectRoot, changeDir)
55
55
  .filter((r) => r.kind === "fail" && r.scope !== "pre-apply")
@@ -58,8 +58,9 @@ export const GATES = {
58
58
  },
59
59
 
60
60
  // Verification: the work is claimed complete. Every checkbox in
61
- // tasks.md (closing steps included) and in the proposal's
62
- // "## Verification" section.
61
+ // tasks.md and in the proposal's "## Verification" section — except a
62
+ // legacy "## Closing steps" list, which names what the close itself does
63
+ // (change 0195).
63
64
  verification: {
64
65
  label: "verification",
65
66
  rerun: (id) => `doctrina change tick ${id}`,
@@ -72,7 +73,7 @@ export const GATES = {
72
73
  const tasksPath = path.join(changeDir, "tasks.md");
73
74
  if (isFile(tasksPath)) {
74
75
  const n = countUnchecked(read(tasksPath));
75
- if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md (closing steps count)`);
76
+ if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md`);
76
77
  }
77
78
  const proposalPath = path.join(changeDir, "proposal.md");
78
79
  if (isFile(proposalPath)) {
@@ -158,7 +159,11 @@ export const SEQUENCES = {
158
159
  // The closing sequence. `close` runs these in order, in-process where it
159
160
  // can, stopping at the first blocking failure with the step's rerun line.
160
161
  close: [
161
- { id: "analyze", label: "analyze", level: "blocking", argv: ["analyze", "<id>"] },
162
+ // The structural pre-flight. Its id stays "analyze" (the module it runs
163
+ // in-process), but a reader sees "structure", the gate it holds: change
164
+ // 0182 deprecated `doctrina analyze`, and the command a person reruns is
165
+ // `change check`, which reports the same checks first (change 0183).
166
+ { id: "analyze", label: "structure", level: "blocking", argv: ["analyze", "<id>"], rerun: "doctrina change check <id>" },
162
167
  { id: "adr-checkpoint", label: "ADR checkpoint (advisory)", level: "advisory", argv: ["decision", "list"] },
163
168
  // Conformance review (audit finding F3). It is the richest analysis the
164
169
  // project has — capabilities whose code moved while their spec did not,
@@ -179,6 +184,20 @@ export const SEQUENCES = {
179
184
  { id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
180
185
  { id: "docs", label: "docs", level: "forceable", argv: null, rerun: "document the change, then rerun" },
181
186
  { id: "archive", label: "archive", level: "blocking", argv: ["change", "archive", "<id>"] },
187
+ // AFTER the archive, because the archive is the last step that WRITES the
188
+ // index, and a gate placed before the step it guards cannot guard it.
189
+ //
190
+ // The drift check already ran, inside `verify` — and that is exactly why
191
+ // it missed: `verify` is five steps earlier, so it certified an index the
192
+ // archive had not yet rewritten. Change 0138 closed green on a tree whose
193
+ // index had drifted, the commit shipped, and all six test legs of CI went
194
+ // red on the next push. The close was not wrong about what it checked; it
195
+ // checked before the damage.
196
+ //
197
+ // `validate` cannot stand in for it: drift of that kind is only visible by
198
+ // rebuilding the index and comparing, which validate deliberately does not
199
+ // do.
200
+ { id: "index-drift", label: "index drift", level: "blocking", argv: ["index", "rebuild", "--check"] },
182
201
  { id: "validate", label: "validate", level: "blocking", argv: ["validate"] },
183
202
  ],
184
203
 
@@ -191,7 +210,7 @@ export const SEQUENCES = {
191
210
  { id: "coverage", label: "coverage", level: "advisory", argv: ["coverage"] },
192
211
  { id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
193
212
  { id: "clean-checkout", label: "clean-checkout", level: "blocking", argv: ["verify", "--clean"] },
194
- { id: "templates", label: "templates", level: "advisory", argv: ["templates", "check"] },
213
+ { id: "templates", label: "templates", level: "advisory", argv: ["upgrade"] },
195
214
  { id: "runtime", label: "runtime", level: "blocking", argv: ["contract", "check"] },
196
215
  { id: "local-env", label: "local .env", level: "blocking", argv: null, rerun: "doctrina doctor --env", flag: "env" },
197
216
  { id: "verify-config", label: "verify config", level: "blocking", argv: ["verify", "--init"] },
@@ -265,6 +284,31 @@ export function sequence(name) {
265
284
  return steps;
266
285
  }
267
286
 
287
+ /**
288
+ * The sequence as a reader sees it: each step's label, in order, with the
289
+ * forceable ones marked the way the advisory ones already are in their own
290
+ * labels.
291
+ *
292
+ * Every surface that SHOWS the sequence renders it from here. Four copies of
293
+ * the close sequence were written out by hand — `close --help`, two adapter
294
+ * command files, and the flow page in both languages — and all four had gone
295
+ * stale, each to a different list: one named four steps of thirteen, another
296
+ * eleven. A sequence a reader can count is a sequence a reader will trust, so
297
+ * it has exactly one author.
298
+ */
299
+ export function sequenceLabels(name) {
300
+ // The marker comes from `level`, never from the label text: labels had
301
+ // already drifted among themselves — `trace` is advisory and did not say so
302
+ // while `ADR checkpoint` did — so any marker already written in is stripped
303
+ // and re-derived. One rule, applied to every step.
304
+ return sequence(name).map((s) => {
305
+ const base = s.label.replace(/\s*\((?:advisory|forceable)\)\s*$/, "");
306
+ if (s.level === "advisory") return `${base} (advisory)`;
307
+ if (s.level === "forceable") return `${base} (forceable)`;
308
+ return base;
309
+ });
310
+ }
311
+
268
312
  /**
269
313
  * The command that runs one step on its own — what a surface prints when it
270
314
  * tells the operator how to clear the gate. Derived from `argv` so the
@@ -272,7 +316,7 @@ export function sequence(name) {
272
316
  * not a single command.
273
317
  */
274
318
  export function stepRerun(step, changeId = "<id>") {
275
- if (step.rerun) return step.rerun;
319
+ if (step.rerun) return step.rerun.replace(/<id>/g, changeId);
276
320
  if (!step.argv) return `doctrina ${step.id}`;
277
321
  return `doctrina ${step.argv.map((a) => (a === "<id>" ? changeId : a)).join(" ")}`;
278
322
  }
package/src/lib/git.js CHANGED
@@ -1,5 +1,10 @@
1
1
  // @ts-check
2
2
  import { spawnSync } from "node:child_process";
3
+ import { copyFileSync, existsSync, rmSync } from "node:fs";
4
+ import { createHash } from "node:crypto";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import process from "node:process";
3
8
 
4
9
  // One place that knows how to ask git a question and how to interpret not
5
10
  // getting an answer (audit item C8).
@@ -32,8 +37,13 @@ export const GIT_STATE = Object.freeze({
32
37
 
33
38
  // Run a git command. Never throws. Returns
34
39
  // { state, stdout, lines, stderr, code }.
35
- export function git(cwd, args) {
36
- const r = spawnSync("git", args, { cwd, encoding: "utf8" });
40
+ /**
41
+ * @param {string} cwd
42
+ * @param {string[]} args
43
+ * @param {{ env?: Record<string, string> }} [opts]
44
+ */
45
+ export function git(cwd, args, { env } = {}) {
46
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", ...(env ? { env: { ...process.env, ...env } } : {}) });
37
47
  if (r.error) {
38
48
  // ENOENT here means the binary is missing, not that the command failed.
39
49
  return { state: GIT_STATE.ABSENT, stdout: "", lines: [], stderr: String(r.error.message ?? ""), code: null };
@@ -227,3 +237,50 @@ export function lastTagDate(cwd) {
227
237
  const date = tags[0] ?? "";
228
238
  return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
229
239
  }
240
+
241
+ /**
242
+ * A content fingerprint of the working tree under `cwd` — tracked AND
243
+ * untracked files, .gitignore respected, uncommitted edits included — with
244
+ * the top-level entries named in `exclude` left out (change 0203). Null when
245
+ * git cannot answer.
246
+ *
247
+ * Built from git's own object model rather than by reading files: the real
248
+ * index is copied to a scratch one, `git add -A` stages the whole tree into
249
+ * the copy (reusing the stat cache, so only changed files are hashed), and
250
+ * `git write-tree` names the result. The real index, HEAD and the working
251
+ * tree are never touched; the only side effect is loose objects for changed
252
+ * files, which `git gc` reclaims.
253
+ *
254
+ * @param {string} cwd
255
+ * @param {{ exclude?: string[] }} [opts]
256
+ * @returns {string | null}
257
+ */
258
+ export function worktreeFingerprint(cwd, { exclude = [] } = {}) {
259
+ const top = git(cwd, ["rev-parse", "--show-toplevel"]);
260
+ const dir = git(cwd, ["rev-parse", "--absolute-git-dir"]);
261
+ if (top.state !== GIT_STATE.OK || dir.state !== GIT_STATE.OK) return null;
262
+ const root = top.stdout.trim();
263
+ const scratch = path.join(os.tmpdir(), `doctrina-fingerprint-${process.pid}-${Date.now()}`);
264
+ try {
265
+ const real = path.join(dir.stdout.trim(), "index");
266
+ if (existsSync(real)) copyFileSync(real, scratch);
267
+ const env = { GIT_INDEX_FILE: scratch };
268
+ if (git(root, ["add", "-A", "--", "."], { env }).state !== GIT_STATE.OK) return null;
269
+ const tree = git(root, ["write-tree"], { env });
270
+ if (tree.state !== GIT_STATE.OK) return null;
271
+ // Asked of git, never computed with path.relative: on macOS the tmpdir
272
+ // is `/var/…`, a symlink to `/private/var/…`, and git answers with the
273
+ // resolved toplevel — the relative path between the two spellings is
274
+ // `../..`, which names nothing in the tree (the 0.18 macOS CI run).
275
+ const prefixed = git(cwd, ["rev-parse", "--show-prefix"]);
276
+ if (prefixed.state !== GIT_STATE.OK) return null;
277
+ const prefix = prefixed.stdout.trim().replace(/\/$/, "");
278
+ const listing = git(root, ["ls-tree", prefix ? `${tree.stdout.trim()}:${prefix}` : tree.stdout.trim()]);
279
+ if (listing.state !== GIT_STATE.OK) return null;
280
+ const skip = new Set(exclude);
281
+ const kept = listing.lines.filter((l) => !skip.has(l.split("\t")[1]));
282
+ return createHash("sha256").update(kept.join("\n")).digest("hex");
283
+ } finally {
284
+ rmSync(scratch, { force: true });
285
+ }
286
+ }
@@ -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
  *
@@ -94,4 +94,4 @@ export function printPlaybookTemplate(projectRoot, name, tokens = {}) {
94
94
  }
95
95
 
96
96
  /** The playbooks the CLI ships and `templates check` expects to resolve. */
97
- export const PLAYBOOKS = ["work", "chore", "bootstrap"];
97
+ export const PLAYBOOKS = ["work", "chore", "bootstrap", "consolidate"];
@@ -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
  *
@@ -631,6 +631,12 @@ export function expandBraces(glob) {
631
631
  // `{a,b}` alternates. Enough for the "where do my selectors live" and "which
632
632
  // code is mine" declarations, with no dependency and no surprises.
633
633
  export function globToRegExp(glob) {
634
+ // A pattern ending in `/` names a DIRECTORY, and a directory claims what
635
+ // is under it (change 0199). Compiled as written it matched only the
636
+ // literal path `src/health/` — which no file has — so the most natural way
637
+ // to declare a module's ownership claimed nothing, and `review` reported
638
+ // every file in it as ownerless.
639
+ if (/\/$/.test(String(glob))) glob = `${glob}**`;
634
640
  // Always compile the EXPANSION, never the original. Using it only when it
635
641
  // produced more than one alternative left `src/{a}.js` compiling with its
636
642
  // braces as literal characters, so a one-element group matched nothing