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
@@ -0,0 +1,228 @@
1
+ // @ts-check
2
+ import { slugify } from "./dates.js";
3
+
4
+ // ONE lexicon (audit findings F5/F6).
5
+ //
6
+ // `work` ranked a prompt against the spec tree with one stop list, and
7
+ // `context --for` ranked the same prompt with a different one — while the
8
+ // work playbook tells the agent to run `context` immediately after `work`.
9
+ // Two rankers, in sequence, on the same text, free to disagree about which
10
+ // capability it is about. And `FIX_SHAPED` appeared byte for byte in two
11
+ // modules, so a term added to one classifier silently did not reach the
12
+ // other.
13
+ //
14
+ // What follows is the whole vocabulary the CLI uses to read natural
15
+ // language: how text is folded, what counts as a content word, and how
16
+ // strongly a document answers a query. Every consumer reads it from here.
17
+ //
18
+ // It is deliberately small and deterministic. The CLI does no natural-
19
+ // language interpretation (ADR 0005) — these functions rank, they never
20
+ // decide, and the playbook says so at the point the ranking is shown.
21
+
22
+ /**
23
+ * Fold to comparable ASCII-ish lowercase: strip combining marks so a
24
+ * Portuguese prompt and an ASCII spec match on the same word ("função" and
25
+ * "funcao"), and lowercase so case never decides relevance.
26
+ */
27
+ export function fold(text) {
28
+ return String(text ?? "").normalize("NFD").replace(/\p{M}+/gu, "").toLowerCase();
29
+ }
30
+
31
+ /**
32
+ * The connective tissue of a task description, in English and Portuguese.
33
+ *
34
+ * Deliberately conservative: it covers grammar and the verbs every prompt
35
+ * uses ("add", "create", "implementar"), and nothing domain-specific — a
36
+ * stop list that swallowed a real term would quietly make retrieval worse
37
+ * in exactly the cases it is supposed to help.
38
+ */
39
+ export const STOPWORDS = new Set([
40
+ // en — grammar
41
+ "a", "an", "and", "are", "as", "at", "be", "but", "by", "can", "do", "for",
42
+ "from", "has", "have", "how", "i", "if", "in", "into", "is", "it", "its",
43
+ "of", "on", "or", "should", "that", "the", "then", "this", "to", "up",
44
+ "was", "we", "what", "when", "where", "which", "why", "will", "with",
45
+ "shall", "must",
46
+ // en — the verbs and nouns every feature prompt carries
47
+ "make", "add", "new", "use", "create", "implement", "feature", "system",
48
+ "user", "users",
49
+ // pt (ASCII-folded) — grammar
50
+ "uma", "umas", "uns", "dos", "das", "nos", "nas", "por", "para", "com",
51
+ "que", "sem", "aos", "nao", "mais", "ser", "quando", "esta", "sao",
52
+ "pela", "pelo", "deve", "de", "do", "da", "no", "na", "em", "ao", "os",
53
+ "as", "um", "ou", "se", "eh", "pra",
54
+ // pt — the same verbs and nouns
55
+ "faca", "fazer", "criar", "crie", "novo", "nova", "adicionar", "adicione",
56
+ "implementar", "implemente", "funcionalidade", "sistema", "usuario",
57
+ "usuarios",
58
+ ]);
59
+
60
+ /**
61
+ * The content words of a query: folded, de-duplicated, stop words dropped.
62
+ *
63
+ * A term must be at least two characters, so an initial or a stray letter
64
+ * never becomes a search term — but "id", "ci" and "pt" survive, and those
65
+ * are real terms in this domain.
66
+ */
67
+ export function terms(query) {
68
+ if (query === undefined || query === null) return [];
69
+ const words = fold(query).match(/[a-z][a-z0-9-]+/g) ?? [];
70
+ return [...new Set(words.filter((w) => !STOPWORDS.has(w)))];
71
+ }
72
+
73
+ /**
74
+ * How strongly a document answers the query, as a comparable TUPLE rather
75
+ * than one blended number — so the tiebreak order is readable and no
76
+ * weighting constant has to be guessed:
77
+ *
78
+ * [ terms in the title/id, terms in the body, hits per 1000 chars ]
79
+ *
80
+ * Density, not raw hit count, breaks the final tie. Raw hits reward a
81
+ * document for being long: the 473-line `cli` spec out-scored `skills` on
82
+ * the query "write a skill from git history" purely on volume, which is the
83
+ * length bias that makes naive retrieval useless on a mature tree.
84
+ */
85
+ export function relevance(text, queryTerms, emphasis = "") {
86
+ if (queryTerms.length === 0) return [0, 0, 0];
87
+ const hay = fold(text);
88
+ const head = fold(emphasis);
89
+ let inTitle = 0;
90
+ let inBody = 0;
91
+ let hits = 0;
92
+ for (const term of queryTerms) {
93
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
94
+ const boundary = `(^|[^a-z0-9-])${escaped}`;
95
+ const n = (hay.match(new RegExp(boundary, "g")) ?? []).length;
96
+ if (n > 0) {
97
+ inBody += 1;
98
+ hits += n;
99
+ }
100
+ if (head && new RegExp(boundary).test(head)) inTitle += 1;
101
+ }
102
+ const density = hay.length > 0 ? Math.round((hits / hay.length) * 1000) : 0;
103
+ return [inTitle, inBody, density];
104
+ }
105
+
106
+ /**
107
+ * The same relevance, projected onto ONE comparable number.
108
+ *
109
+ * `work` shows a score in a column and `context` orders by the tuple; both
110
+ * must agree about which capability a prompt is about, so the scalar is a
111
+ * PROJECTION of the tuple rather than a second calculation. The weights only
112
+ * have to preserve the tuple's own ordering — title beats body beats
113
+ * density — which is what the lexicographic comparison already says.
114
+ */
115
+ export function score(text, queryTerms, emphasis = "") {
116
+ const [inTitle, inBody, density] = relevance(text, queryTerms, emphasis);
117
+ return inTitle * 100 + inBody * 10 + Math.min(density, 9);
118
+ }
119
+
120
+ /**
121
+ * A slug or identifier that reads as a FIX rather than a feature — the
122
+ * signal `skill suggest` and `next` both look for when deciding whether an
123
+ * archived change taught a lesson worth capturing.
124
+ *
125
+ * One definition: it lived byte for byte in two modules, so a term added to
126
+ * one classifier did not reach the other.
127
+ */
128
+ export const FIX_SHAPED =
129
+ /(?:^|-)(fix|bug|hotfix|patch|parse|parsing|tolerate|workaround|race|deadlock|flaky|retry|escape|sanitize|sanitise)(?:-|$)/;
130
+
131
+ /**
132
+ * The same idea applied to a commit subject. Narrower than FIX_SHAPED on
133
+ * purpose: a subject is prose, so it needs the conventional-commit shape or
134
+ * an explicit fix verb rather than a bare word match.
135
+ */
136
+ export const FIX_SHAPED_SUBJECT =
137
+ /^(fix|bugfix|hotfix|patch)(\(|:|!)|^(revert)\b|\b(fixes|fixed|regression|workaround)\b/i;
138
+
139
+ /**
140
+ * Which language a document is written in, by counting each lexicon's
141
+ * grammar words. Used by `clarify` to pick its smell lexicon when the
142
+ * project declares no language.
143
+ */
144
+ const PT_GRAMMAR = /\b(que|n[aã]o|para|uma|como|mais|ser|quando|est[aá]|s[aã]o|pela|pelo|dos|das|ou seja|deve)\b/gi;
145
+ const EN_GRAMMAR = /\b(the|and|that|with|shall|when|this|from|are|not|for|must)\b/gi;
146
+
147
+ export function detectLanguage(text) {
148
+ const pt = (String(text).match(PT_GRAMMAR) ?? []).length;
149
+ const en = (String(text).match(EN_GRAMMAR) ?? []).length;
150
+ return pt > en ? "pt" : "en";
151
+ }
152
+
153
+ /**
154
+ * The margin a ranked winner must hold over the runner-up before a caller
155
+ * may ACT on the ranking rather than merely display it.
156
+ *
157
+ * `triage` calls a verdict confident when the winning lane beats the
158
+ * runner-up by 2. Copying that number here would copy a unit, not the
159
+ * notion: on `score()` above, every signal is worth 10 or 100 and the
160
+ * density tie-breaker alone moves the number by up to 9. A gap of 2 can
161
+ * therefore mean the two specs matched exactly the same terms and one of
162
+ * them is shorter — the opposite of confidence.
163
+ *
164
+ * 10 is the smallest gap density CANNOT produce: it is one whole body
165
+ * term. Density is capped at 9 precisely so it can only break ties, so a
166
+ * margin at or above 10 says the winner led on term overlap itself.
167
+ */
168
+ export const CONFIDENT_MARGIN = 10;
169
+
170
+ // The SLUG_WORDS content words of a prompt, slugified — the short half of a
171
+ // change id (change 0070).
172
+ //
173
+ // The id used to be the whole prompt slugified, and the proposal's H1 is
174
+ // `# Change <id> — <title>` with the title defaulting to that same prompt: so
175
+ // a change opened on the default path said the same sentence twice, and every
176
+ // read surface printed both halves. `prime` spent 133 characters of one line
177
+ // on it — the command whose entire value is density.
178
+ //
179
+ // The id is what a person types and what sorts a backlog, so it wants to be
180
+ // short; the title is what a person reads, so it wants to be whole. Capping
181
+ // the SLUG rather than truncating the title gives both, and is exactly what
182
+ // `--title` did by hand. Deterministic: a shipped stopword list, first N
183
+ // content words, no interpretation (ADR 0005).
184
+ export const SLUG_WORDS = 4;
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
+
198
+ export function slugFromPrompt(prompt, { words = SLUG_WORDS } = {}) {
199
+ const content = slugTerms(prompt).slice(0, words);
200
+ const slug = slugify(content.join(" "));
201
+ // A prompt of nothing but stopwords ("do it now") still needs an id.
202
+ return slug || slugify(prompt).split("-").slice(0, words).join("-");
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
+ }
@@ -0,0 +1,189 @@
1
+ // @ts-check
2
+ // The adoption metrics themselves: what a snapshot IS, computed once.
3
+ //
4
+ // `metrics` renders a snapshot and saves it; `report` shows two of its rates
5
+ // for the reporting period. Before this they would each have had their own
6
+ // idea of "revert rate", which is the kind of divergence nobody notices until
7
+ // two numbers on two screens disagree and neither is wrong (ADR 0025).
8
+ //
9
+ // Everything here is derived from local git history. No network calls, no
10
+ // telemetry: the numbers stay in the repository, versioned like any other
11
+ // artifact. This is the tooling half of docs/en/validation.md.
12
+ import path from "node:path";
13
+ import { readdirSync } from "node:fs";
14
+ import { isDir, isFile, read } from "./fs-ops.js";
15
+ import { today } from "./dates.js";
16
+ import { GIT_STATE, git } from "./git.js";
17
+
18
+ export const REEDIT_WINDOW_DAYS = 21;
19
+ export const METRICS_REL = ".doctrina/metrics";
20
+
21
+ export function computeSnapshot(commits, since) {
22
+ const total = commits.length;
23
+ const reverts = commits.filter((cm) => /^Revert\b/.test(cm.subject)).length;
24
+ const fixes = commits.filter((cm) => /^fix[(:!]/.test(cm.subject)).length;
25
+
26
+ // File churn: commits per file, top offenders first.
27
+ const churn = new Map();
28
+ for (const cm of commits) {
29
+ for (const f of cm.files) churn.set(f, (churn.get(f) ?? 0) + 1);
30
+ }
31
+ const topChurn = [...churn.entries()]
32
+ .sort((a, b) => b[1] - a[1])
33
+ .slice(0, 5)
34
+ .map(([file, count]) => ({ file, commits: count }));
35
+
36
+ // Re-edit proxy: a commit counts when it touches a file another commit
37
+ // already touched in the previous REEDIT_WINDOW_DAYS. This is a proxy
38
+ // for rework, not a verdict — iterative work also trips it.
39
+ const windowSec = REEDIT_WINDOW_DAYS * 86400;
40
+ const lastTouch = new Map();
41
+ let reedits = 0;
42
+ for (const cm of [...commits].sort((a, b) => a.ts - b.ts)) {
43
+ const isReedit = cm.files.some((f) => {
44
+ const prev = lastTouch.get(f);
45
+ return prev !== undefined && cm.ts - prev <= windowSec;
46
+ });
47
+ if (isReedit) reedits += 1;
48
+ for (const f of cm.files) lastTouch.set(f, cm.ts);
49
+ }
50
+
51
+ return {
52
+ generated: today(),
53
+ window: since,
54
+ commits: total,
55
+ reverts,
56
+ revert_rate: round(reverts / total),
57
+ fix_share: round(fixes / total),
58
+ reedit_window_days: REEDIT_WINDOW_DAYS,
59
+ reedit_rate: round(reedits / total),
60
+ top_churn: topChurn,
61
+ };
62
+ }
63
+
64
+
65
+ export function parseLog(stdout) {
66
+ const commits = [];
67
+ let current = null;
68
+ for (const line of stdout.split("\n")) {
69
+ if (line.startsWith("@@")) {
70
+ const [hash, ts, ...rest] = line.slice(2).split("|");
71
+ current = { hash, ts: Number(ts), subject: rest.join("|"), files: [] };
72
+ commits.push(current);
73
+ } else if (line.trim() !== "" && current) {
74
+ current.files.push(line.trim());
75
+ }
76
+ }
77
+ return commits;
78
+ }
79
+
80
+
81
+ export function round(x) {
82
+ return Math.round(x * 1000) / 1000;
83
+ }
84
+
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
+
116
+ /**
117
+ * A snapshot for a window, or null when there is no history to read.
118
+ *
119
+ * @param {string} projectRoot
120
+ * @param {string} since a git --since value ("90 days ago", "2026-01-01")
121
+ */
122
+ export function collectMetrics(projectRoot, since) {
123
+ const log = git(projectRoot, [
124
+ "log", `--since=${since}`, "--date=unix",
125
+ "--pretty=format:@@%H|%ct|%s", "--name-only",
126
+ ]);
127
+ if (log.state !== GIT_STATE.OK) return null;
128
+ const commits = parseLog(log.stdout);
129
+ if (commits.length === 0) return null;
130
+ return computeSnapshot(commits, since);
131
+ }
132
+
133
+ /**
134
+ * Every saved snapshot, oldest first.
135
+ *
136
+ * The snapshots are a TIME SERIES, versioned in the repository — and for two
137
+ * releases the only thing that ever read them was a delta against the most
138
+ * recent one, so a year of measurement answered exactly one question: "is it
139
+ * better than last time?" (audit finding F15). Reading the series is what
140
+ * makes the saving worth doing.
141
+ */
142
+ export function readSeries(projectRoot) {
143
+ const dir = path.join(projectRoot, ...METRICS_REL.split("/"));
144
+ if (!isDir(dir)) return [];
145
+ const out = [];
146
+ for (const f of readdirSync(dir).sort()) {
147
+ if (!/^\d{4}-\d{2}-\d{2}\.json$/.test(f)) continue;
148
+ const file = path.join(dir, f);
149
+ if (!isFile(file)) continue;
150
+ try {
151
+ const snap = JSON.parse(read(file));
152
+ // The filename is the authority on when it was taken: `generated` can
153
+ // be absent in a hand-edited file, and a series ordered by a field
154
+ // that may be missing is not ordered at all.
155
+ out.push({ ...snap, generated: snap.generated ?? f.replace(/\.json$/, "") });
156
+ } catch { /* a malformed snapshot is skipped, never fatal */ }
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /** The most recent saved snapshot, excluding one basename. */
162
+ export function latestSnapshot(projectRoot, excludeBase = null) {
163
+ const series = readSeries(projectRoot).filter((s) => `${s.generated}.json` !== excludeBase);
164
+ return series.length > 0 ? series[series.length - 1] : null;
165
+ }
166
+
167
+ /**
168
+ * The direction each tracked rate moved across the WHOLE series.
169
+ *
170
+ * First to last, not last to previous: a rate that drifted up for six months
171
+ * and dipped once looks like an improvement from the delta and like what it
172
+ * is from the series.
173
+ *
174
+ * @param {ReturnType<typeof readSeries>} series
175
+ */
176
+ export function trend(series) {
177
+ const rates = ["revert_rate", "fix_share", "reedit_rate"];
178
+ if (series.length < 2) return { span: series.length, rows: [] };
179
+ const first = series[0];
180
+ const last = series[series.length - 1];
181
+ const rows = [];
182
+ for (const rate of rates) {
183
+ const a = first[rate];
184
+ const b = last[rate];
185
+ if (typeof a !== "number" || typeof b !== "number") continue;
186
+ rows.push({ rate, first: a, last: b, delta: round(b - a) });
187
+ }
188
+ return { span: series.length, from: first.generated, to: last.generated, rows };
189
+ }
@@ -0,0 +1,42 @@
1
+ // @ts-check
2
+ // One grammar for the NAME of a spec, a contract or a skill (change 0110).
3
+ //
4
+ // Three commands carried their own copy of `/^[a-z][a-z0-9-]*$/`, and none
5
+ // of them knew anything about a file system. Measured on Windows: `spec new
6
+ // nul` created `.doctrina/specs/nul/spec.md` — a directory Node could write
7
+ // and git could neither see nor remove (`could not open directory`, `failed
8
+ // to remove`), gone only through a `\\?\` path. `con`, `prn`, `aux`,
9
+ // `com1`-`com9` and `lpt1`-`lpt9` are device names on Windows, and this
10
+ // repository's own tree is Windows/CRLF. A 120-character name passed too
11
+ // (`Filename too long` from git), as did `trail-` and `a--b`.
12
+ //
13
+ // A name is a directory name on every file system the project will touch.
14
+ // The message names the rule that failed, so the fix is the invocation.
15
+
16
+ const RESERVED = new Set([
17
+ "con", "prn", "aux", "nul",
18
+ ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
19
+ ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
20
+ ]);
21
+
22
+ const NAME_MAX = 64;
23
+
24
+ /**
25
+ * null when `name` is a legal artifact name; otherwise the reason, ready to
26
+ * print after "error:".
27
+ *
28
+ * @param {string | undefined} name
29
+ * @param {string} kind "capability" | "contract name" | "skill name"
30
+ * @returns {string | null}
31
+ */
32
+ export function artifactNameError(name, kind) {
33
+ const value = String(name ?? "");
34
+ if (value === "") return `${kind} is required`;
35
+ if (!/^[a-z0-9-]+$/.test(value)) return `${kind} must be lowercase letters, digits, or hyphens (got "${value}")`;
36
+ if (!/^[a-z]/.test(value)) return `${kind} must start with a letter (got "${value}")`;
37
+ if (/-$/.test(value)) return `${kind} must not end with a hyphen (got "${value}")`;
38
+ if (/--/.test(value)) return `${kind} must not contain a doubled hyphen (got "${value}")`;
39
+ if (value.length > NAME_MAX) return `${kind} must be at most ${NAME_MAX} characters (got ${value.length})`;
40
+ if (RESERVED.has(value)) return `${kind} "${value}" is a reserved device name on Windows — a directory git cannot see or remove there; pick another`;
41
+ return null;
42
+ }
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+ import { c } from "./colors.js";
3
+ import { readTemplate, substitute } from "./templates.js";
4
+
5
+ // Playbooks are TEMPLATES, not code (audit finding F17).
6
+ //
7
+ // `printPlaybook()` was ~100 lines of literal `console.log` — including the
8
+ // documentation of the ops grammar — and the same procedure was written out
9
+ // again in AGENTS.md and twice more in docs/{en,pt}/workflow.md. Four homes
10
+ // for one fact, in the framework whose first design principle is that a fact
11
+ // has exactly one.
12
+ //
13
+ // Worse for adopters: a team whose process differs (an extra review step, a
14
+ // different close) had no way to say so. Every other scaffold resolves
15
+ // project-over-bundled per file (ADR 0019); the procedure the agent actually
16
+ // executes was the one thing hard-coded.
17
+ //
18
+ // So a playbook resolves through the same chain, and this module renders it.
19
+ //
20
+ // ---------------------------------------------------------------------------
21
+ // The markup
22
+ // ---------------------------------------------------------------------------
23
+ //
24
+ // Colour has to survive the move, or the output changes and the migration is
25
+ // not faithful. The template marks spans inline:
26
+ //
27
+ // [[c]]doctrina close 0001[[/c]] cyan — a command to run
28
+ // [[g]]...[[/g]] gray — commentary
29
+ // [[b]]...[[/b]] bold — a heading
30
+ // [[y]]...[[/y]] yellow — a warning
31
+ //
32
+ // Markup is expanded BEFORE token substitution, deliberately: a token's value
33
+ // can carry a user's prompt, and a prompt must never be able to inject colour
34
+ // into the playbook. Blocks the CLI computes (a capability ranking, a warning
35
+ // list) arrive already coloured through `c.*`, so they need no markup.
36
+ //
37
+ // Everything variable is a TOKEN, and the code pre-renders it. That answers
38
+ // the open question the change carried — extend the substituter, or
39
+ // pre-render? — in favour of pre-rendering: `substitute` stays a plain
40
+ // string map, and the template stays a document rather than becoming a
41
+ // programming language with conditionals and loops in it.
42
+
43
+ const MARKS = { c: c.cyan, g: c.gray, b: c.bold, y: c.yellow };
44
+
45
+ /** Expand `[[x]]...[[/x]]` colour spans. Innermost first, so spans nest. */
46
+ export function expandMarkup(text) {
47
+ let out = String(text);
48
+ let previous;
49
+ do {
50
+ previous = out;
51
+ out = out.replace(/\[\[([cgby])\]\]((?:(?!\[\[[cgby]\]\])[\s\S])*?)\[\[\/\1\]\]/g,
52
+ (_, mark, body) => MARKS[mark](body));
53
+ } while (out !== previous);
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Render a named playbook, resolved project-over-bundled.
59
+ *
60
+ * Returns the text; the caller prints it. Keeping the write at the edge is
61
+ * what lets a test assert the rendering without capturing stdout.
62
+ */
63
+ export function renderPlaybook(projectRoot, name, tokens = {}) {
64
+ const tpl = readTemplate(projectRoot, `playbooks/${name}.md.template`);
65
+ // A trailing newline in the file would print an extra blank line; the
66
+ // caller decides its own spacing.
67
+ return substitute(expandMarkup(dropEmptyBlocks(tpl.body, tokens)), tokens).replace(/\n$/, "");
68
+ }
69
+
70
+ /**
71
+ * A line holding NOTHING but one token, whose value is empty or absent, is
72
+ * removed rather than left as a blank line.
73
+ *
74
+ * That single rule is what lets the optional parts of a playbook — the
75
+ * thin-prompt warning, the capability hint, the backfill note — be plain
76
+ * tokens on their own line instead of conditionals in a template language.
77
+ * A block that IS present brings its own spacing in its value.
78
+ */
79
+ function dropEmptyBlocks(body, tokens) {
80
+ return body
81
+ .split("\n")
82
+ .filter((line) => {
83
+ const m = line.match(/^\s*\{\{(\w+)\}\}\s*$/);
84
+ if (!m) return true;
85
+ const value = tokens[m[1]];
86
+ return value !== undefined && String(value) !== "";
87
+ })
88
+ .join("\n");
89
+ }
90
+
91
+ /** Render and print. The one place a playbook reaches stdout. */
92
+ export function printPlaybookTemplate(projectRoot, name, tokens = {}) {
93
+ console.log(renderPlaybook(projectRoot, name, tokens));
94
+ }
95
+
96
+ /** The playbooks the CLI ships and `templates check` expects to resolve. */
97
+ export const PLAYBOOKS = ["work", "chore", "bootstrap"];
@@ -0,0 +1,105 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { exists } from "./fs-ops.js";
4
+ import { EXIT, notADoctrinaProject } from "./exit-codes.js";
5
+ import { c } from "./colors.js";
6
+ import { load } from "./index-json.js";
7
+
8
+ // The one precondition every command shares: this is a Doctrina project.
9
+ // Six command modules carried an identical private copy of it; a lib
10
+ // operation that needs it should not have to reach into a command for the
11
+ // check (audit finding F7).
12
+ export function ensureDoctrinaProject(projectRoot) {
13
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
14
+ throw notADoctrinaProject();
15
+ }
16
+ }
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
+
43
+ // The grammar a change id obeys: lowercase letters, digits and hyphens,
44
+ // opening on a letter or a digit — `0042-short-slug` is the shape `work`
45
+ // derives. Deliberately NOT the capability grammar, which additionally
46
+ // requires a leading letter; two rules that look alike are not one rule.
47
+ //
48
+ // It exists because `change new` had NO grammar (third audit, finding 1).
49
+ // Every other authoring command validated its argument; `change new` joined
50
+ // whatever it was given onto a path, so `change new ../../../elsewhere/evil`
51
+ // wrote a change folder OUTSIDE the project — against the `authoring` spec's
52
+ // own "shall not write outside the project working directory" — and ids like
53
+ // `0003-com espaco` reached `validate` and `index rebuild` as legitimate,
54
+ // producing a `next` action whose command could not be run.
55
+ export const CHANGE_ID = /^[a-z0-9][a-z0-9-]*$/;
56
+
57
+ export function isChangeId(id) {
58
+ return typeof id === "string" && CHANGE_ID.test(id);
59
+ }
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
+
82
+ /**
83
+ * Resolve `segments` under `projectRoot` and refuse anything that escapes it.
84
+ *
85
+ * Defence in depth, deliberately separate from the grammar above: the
86
+ * grammar is what tells the USER their id is wrong, and this is what holds
87
+ * even when a caller forgets to ask. A path is inside when it is the root
88
+ * itself or sits beneath it — compared after `path.resolve`, so `..`,
89
+ * absolute arguments and symlink-free trickery all normalise first.
90
+ *
91
+ * Throws rather than returning a code: a caller that reached here with an
92
+ * escaping path has a bug, and the entrypoint's catch turns a thrown error
93
+ * into the gate class with the message intact.
94
+ */
95
+ export function resolveWithinProject(projectRoot, ...segments) {
96
+ const root = path.resolve(projectRoot);
97
+ const target = path.resolve(root, ...segments);
98
+ const rel = path.relative(root, target);
99
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
100
+ throw new Error(
101
+ `refusing to write outside the project: "${path.join(...segments)}" resolves to ${target}`,
102
+ );
103
+ }
104
+ return target;
105
+ }