doctrina-cli 0.15.0 → 0.16.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 (85) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -199
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +7 -75
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +121 -160
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +109 -6
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +210 -24
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +238 -11
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/contract.md.template +7 -0
  82. package/templates/hooks/pre-commit.sample +6 -2
  83. package/templates/playbooks/bootstrap.md.template +43 -0
  84. package/templates/playbooks/chore.md.template +19 -0
  85. package/templates/playbooks/work.md.template +76 -0
@@ -27,14 +27,99 @@ export function wantsJson(flags) {
27
27
  return flags?.get?.("json") === true;
28
28
  }
29
29
 
30
+ // A deprecation notice belongs in the ENVELOPE, not only on the terminal
31
+ // (change 0061). Change 0049 announced a superseded name on the real stderr,
32
+ // before capture — right for a piped stdout, which stays exactly what it
33
+ // was, but it meant the envelope's `stderr` array came back empty and the
34
+ // machine reading only that never learned the name it invoked is on its way
35
+ // out. Deprecation exists so consumers migrate, and this CLI's primary
36
+ // consumer is an agent reading JSON.
37
+ //
38
+ // Set once by the entrypoint, before the command runs, so BOTH json paths
39
+ // carry it: the captured envelope and a command that builds its own payload.
40
+ /** @type {{ use: string, since: string, why: string } | null} */
41
+ let deprecation = null;
42
+
43
+ /** @param {{ use: string, since: string, why: string } | null} record */
44
+ export function setDeprecation(record) {
45
+ deprecation = record;
46
+ }
47
+
48
+ // A command that builds its own payload calls `emitJson` BEFORE it returns —
49
+ // in `coverage.js` the call sits one line above `return jsonClean ? 0 :
50
+ // strict ? 1 : 0`. So the call site cannot know the exit code, and the
51
+ // optimistic defaults answered for it: `coverage --strict` exited 1 while
52
+ // its payload said `"ok": true, "exit_code": 0`, and `exit_code` was 0 in
53
+ // every native payload the CLI has ever emitted (change 0086).
54
+ //
55
+ // The entrypoint is the one place that knows the code, which is why the
56
+ // CAPTURED path (a command with no payload of its own) was always right: it
57
+ // emits after the run, with the real code. The native path now works the
58
+ // same way — `deferJson()` before the run holds the payload, `flushJson(code)`
59
+ // after it writes the envelope with the verdict the process actually reports.
60
+ /** @type {{ command: string, data: object, opts: object } | null} */
61
+ let held = null;
62
+ let deferring = false;
63
+
64
+ /** Hold the next payload instead of writing it, until `flushJson`. */
65
+ export function deferJson() {
66
+ deferring = true;
67
+ held = null;
68
+ }
69
+
70
+ /**
71
+ * Write the held payload with the exit code the command actually returned,
72
+ * and leave deferred mode. A no-op when the command emitted nothing.
73
+ */
74
+ export function flushJson(code) {
75
+ deferring = false;
76
+ if (!held) return;
77
+ const { command, data } = held;
78
+ held = null;
79
+ writeEnvelope(command, data, { ok: code === EXIT.OK, exitCode: code });
80
+ }
81
+
30
82
  // Print a structured payload. `command` is the invocation, `data` whatever
31
83
  // that command has to say.
32
- export function emitJson(command, data, { ok = true, exitCode = EXIT.OK } = {}) {
84
+ /**
85
+ * @param {string} command The OPERATION, never the operation plus arguments.
86
+ * @param {Record<string, unknown>} data
87
+ * @param {{ ok?: boolean, exitCode?: number, args?: string[] }} [opts]
88
+ */
89
+ export function emitJson(command, data, opts = {}) {
90
+ if (deferring) {
91
+ held = { command, data, opts };
92
+ return;
93
+ }
94
+ writeEnvelope(command, data, opts);
95
+ }
96
+
97
+ /**
98
+ * `EXIT` is frozen, so its members infer as literal types — without this the
99
+ * default `exitCode = EXIT.OK` narrows the parameter to `0`.
100
+ *
101
+ * @param {string} command
102
+ * @param {Record<string, unknown>} data
103
+ * @param {{ ok?: boolean, exitCode?: number, args?: string[] }} [opts]
104
+ */
105
+ function writeEnvelope(command, data, { ok = true, exitCode = EXIT.OK, args } = {}) {
33
106
  process.stdout.write(JSON.stringify({
34
107
  $schema_version: JSON_SCHEMA_VERSION,
108
+ // The OPERATION, never the operation plus its arguments. `command` used
109
+ // to carry `"why carteira"`, so the field a consumer branches on took a
110
+ // different value for every capability — while `next --json` documents
111
+ // `command`/`args` as the contract (third audit, finding 6). A
112
+ // sub-operation like `spec list` IS the operation and stays whole; the
113
+ // catalog is what tells the two apart.
35
114
  command,
36
115
  ok,
37
116
  exit_code: exitCode,
117
+ // Present when the invocation carried arguments, so it stays
118
+ // reconstructible without parsing the name.
119
+ ...(args && args.length > 0 ? { args } : {}),
120
+ // Present only when the invoked name is superseded, so a consumer can
121
+ // branch on its presence rather than on a string.
122
+ ...(deprecation ? { deprecated: deprecation } : {}),
38
123
  ...data,
39
124
  }, null, 2) + "\n");
40
125
  }
@@ -53,8 +138,14 @@ export async function captureOutput(fn) {
53
138
  // only console.* let that output escape the envelope and corrupt the
54
139
  // JSON on stdout, so the raw stream is captured too.
55
140
  const origWrite = process.stdout.write.bind(process.stdout);
141
+ // The raw stderr stream is captured too (change 0114): `verify` tees a
142
+ // child's stderr straight to process.stderr, so a failing check's
143
+ // diagnostics landed on the real stderr while the envelope said
144
+ // `"stderr": []`. Carriage returns are stripped — a child on Windows
145
+ // writes CRLF, and `"ok\r"` is not a line.
146
+ const origErrWrite = process.stderr.write.bind(process.stderr);
56
147
  const pushLines = (target, chunk) => {
57
- for (const line of String(chunk).replace(/\n$/, "").split("\n")) target.push(line);
148
+ for (const line of String(chunk).replace(/\r?\n$/, "").split(/\r?\n/)) target.push(line.replace(/\r$/, ""));
58
149
  };
59
150
  console.log = (...args) => stdout.push(args.map(String).join(" "));
60
151
  console.error = (...args) => stderr.push(args.map(String).join(" "));
@@ -65,6 +156,12 @@ export async function captureOutput(fn) {
65
156
  if (cb) cb();
66
157
  return true;
67
158
  };
159
+ process.stderr.write = (chunk, ...rest) => {
160
+ pushLines(stderr, chunk);
161
+ const cb = rest.find((r) => typeof r === "function");
162
+ if (cb) cb();
163
+ return true;
164
+ };
68
165
  try {
69
166
  const code = await fn();
70
167
  return { code: code ?? EXIT.OK, stdout, stderr };
@@ -73,6 +170,7 @@ export async function captureOutput(fn) {
73
170
  console.error = origError;
74
171
  console.warn = origWarn;
75
172
  process.stdout.write = origWrite;
173
+ process.stderr.write = origErrWrite;
76
174
  }
77
175
  }
78
176
 
@@ -0,0 +1,192 @@
1
+ // @ts-check
2
+ // The archive ledger, read as well as written.
3
+ //
4
+ // `.doctrina/changes/archive/LEDGER.md` is a structured log — date, change
5
+ // id, title, and the capabilities the change touched with the operation it
6
+ // performed on each — and for two releases exactly two things read it: an
7
+ // id set in `decision scope`, and a cross-check in `validate`. Everything
8
+ // else that wanted to know "what has been happening here" derived it from
9
+ // git, which knows about files and nothing about capabilities (audit
10
+ // findings F13, F14).
11
+ //
12
+ // The parse is DELIBERATELY tolerant. The file's own header invites humans
13
+ // to edit it and promises the CLI only appends, so a line that does not
14
+ // match the grammar is not a corrupt ledger — it is a person writing a note.
15
+ // Such lines are counted, never thrown, and never rewritten.
16
+ import path from "node:path";
17
+ import { appendFileSync } from "node:fs";
18
+ import { exists, isFile, mkdirp, read, write } from "./fs-ops.js";
19
+ import { today } from "./dates.js";
20
+
21
+ export const LEDGER_REL = ".doctrina/changes/archive/LEDGER.md";
22
+
23
+ const HEADER =
24
+ "# Change ledger\n\n" +
25
+ "One line per archived change, newest last. Appended by\n" +
26
+ "`doctrina change archive`; edit freely, the CLI only appends.\n\n";
27
+
28
+ export function ledgerPath(projectRoot) {
29
+ return path.join(projectRoot, ...LEDGER_REL.split("/"));
30
+ }
31
+
32
+ // One entry per recognised line. `kind` separates the three things the CLI
33
+ // records, because they mean different things to a reader: a change that
34
+ // LANDED, one that was discarded, and a gate that was waved through.
35
+ /**
36
+ * @typedef {{
37
+ * date: string, id: string, kind: "archived"|"abandoned"|"gap",
38
+ * title: string, specs: {capability: string, operation: string}[],
39
+ * line: number, raw: string
40
+ * }} LedgerEntry
41
+ */
42
+
43
+ // `- YYYY-MM-DD — <id> — <rest>`, with either an em dash or a hyphen as the
44
+ // separator and any indentation (a gap line is indented). The date's month
45
+ // and day are range-checked: a bullet a person typed by hand is far more
46
+ // likely to be prose that happens to start with digits than an entry, and
47
+ // admitting it would put a fictional date into a churn window.
48
+ const LINE_RE = /^\s*-\s+(\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01]))\s+[—-]\s+(\S+)\s+[—-]\s+(.*)$/;
49
+ const SPECS_RE = /\(specs:\s*([^)]*)\)\s*$/;
50
+
51
+ /**
52
+ * Parse ledger text into entries.
53
+ *
54
+ * @param {string} text
55
+ * @returns {{entries: LedgerEntry[], unparsed: number}}
56
+ */
57
+ export function parseLedger(text) {
58
+ const entries = [];
59
+ let unparsed = 0;
60
+ const lines = String(text ?? "").split(/\r?\n/);
61
+ for (let i = 0; i < lines.length; i++) {
62
+ const raw = lines[i];
63
+ if (raw.trim() === "") continue;
64
+ const m = raw.match(LINE_RE);
65
+ if (!m) {
66
+ // Prose, the file's own header, a hand-written note: not an entry, and
67
+ // not an error either.
68
+ if (/^\s*-\s/.test(raw)) unparsed += 1;
69
+ continue;
70
+ }
71
+ const [, date, id, rest] = m;
72
+ const trimmed = rest.trim();
73
+ let kind = /** @type {"archived"|"abandoned"|"gap"} */ ("archived");
74
+ if (/^abandoned\b/i.test(trimmed)) kind = "abandoned";
75
+ else if (/^(?:forced|docs gap)\b/i.test(trimmed)) kind = "gap";
76
+
77
+ const specsMatch = trimmed.match(SPECS_RE);
78
+ const specs = specsMatch ? parseSpecs(specsMatch[1]) : [];
79
+ const title = specsMatch ? trimmed.slice(0, specsMatch.index).trim() : trimmed;
80
+ entries.push({ date, id, kind, title, specs, line: i + 1, raw });
81
+ }
82
+ return { entries, unparsed };
83
+ }
84
+
85
+ // "cli MODIFIED, gates MODIFIED" → [{capability, operation}]. An entry whose
86
+ // operation is missing keeps the capability: the capability is the fact the
87
+ // consumers need, and dropping the pair over a missing verb would lose it.
88
+ function parseSpecs(list) {
89
+ const out = [];
90
+ for (const part of list.split(",")) {
91
+ const t = part.trim();
92
+ if (!t) continue;
93
+ const m = t.match(/^([a-z][a-z0-9-]*)(?:\s+([A-Z]+))?$/);
94
+ if (!m) continue;
95
+ out.push({ capability: m[1], operation: m[2] ?? "" });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * Read and parse the project's ledger.
102
+ *
103
+ * @param {string} projectRoot
104
+ * @returns {{path: string, exists: boolean, entries: LedgerEntry[], unparsed: number}}
105
+ */
106
+ export function readLedger(projectRoot) {
107
+ const p = ledgerPath(projectRoot);
108
+ if (!isFile(p)) return { path: p, exists: false, entries: [], unparsed: 0 };
109
+ const { entries, unparsed } = parseLedger(read(p));
110
+ return { path: p, exists: true, entries, unparsed };
111
+ }
112
+
113
+ /**
114
+ * How often each capability was touched, newest first by count.
115
+ *
116
+ * The number is reported, never judged: a spec that changes often may be
117
+ * badly drawn or may simply be where the work is, and the CLI has no way to
118
+ * tell those apart (ADR 0005). Only landed changes count — an abandoned
119
+ * change touched nothing, and a forced-gap line records a waived gate rather
120
+ * than a spec edit.
121
+ *
122
+ * @param {LedgerEntry[]} entries
123
+ * @param {{since?: string|null}} [options] since — ISO date, inclusive
124
+ * @returns {{capability: string, changes: number, last: string}[]}
125
+ */
126
+ export function churnByCapability(entries, { since = null } = {}) {
127
+ const seen = new Map();
128
+ for (const e of entries) {
129
+ if (e.kind !== "archived") continue;
130
+ if (since && e.date < since) continue;
131
+ for (const s of e.specs) {
132
+ const row = seen.get(s.capability) ?? { capability: s.capability, changes: 0, last: "" };
133
+ row.changes += 1;
134
+ if (e.date > row.last) row.last = e.date;
135
+ seen.set(s.capability, row);
136
+ }
137
+ }
138
+ return [...seen.values()].sort((a, b) => b.changes - a.changes || a.capability.localeCompare(b.capability));
139
+ }
140
+
141
+ /**
142
+ * Append one line, creating the ledger when this is its first entry.
143
+ *
144
+ * The three things that write the ledger — archive, abandon, and a forced
145
+ * transition — used to carry their own copy of this header and their own
146
+ * append, so the format the parser above reads had three authors. A forced
147
+ * transition can happen before any change has ever been archived, and a gap
148
+ * that goes unrecorded because the file did not exist yet is exactly the
149
+ * silence the ledger exists to break.
150
+ *
151
+ * @param {string} projectRoot
152
+ * @param {string} line the entry, without the trailing newline
153
+ */
154
+ export function appendLedgerLine(projectRoot, line) {
155
+ const p = ledgerPath(projectRoot);
156
+ if (!exists(p)) {
157
+ mkdirp(path.dirname(p));
158
+ write(p, HEADER);
159
+ }
160
+ appendFileSync(p, `${line}\n`);
161
+ return p;
162
+ }
163
+
164
+ /** The canonical line for an archived change. */
165
+ export function archivedLine(id, title, specsAffected = [], date = today()) {
166
+ const specs = specsAffected.length > 0
167
+ ? ` (specs: ${specsAffected.map((s) => `${s.capability} ${s.operation}`).join(", ")})`
168
+ : "";
169
+ return `- ${date} — ${id} — ${title}${specs}`;
170
+ }
171
+
172
+ /** The canonical line for an abandoned change. */
173
+ export function abandonedLine(id, reason = "", date = today()) {
174
+ return `- ${date} — ${id} — abandoned${reason ? ` — ${reason}` : ""}`;
175
+ }
176
+
177
+ /** The canonical line for a lifecycle gate that was waved through. */
178
+ export function forcedLine(id, transition, blockers, date = today()) {
179
+ const summary = blockers.map((b) => `${b.gate}: ${b.message}`).join("; ");
180
+ return ` - ${date} — ${id} — forced ${transition} past ${blockers.length} ` +
181
+ `blocker${blockers.length === 1 ? "" : "s"} (${summary})`;
182
+ }
183
+
184
+ /**
185
+ * The canonical line for a docs gap closed with --force.
186
+ *
187
+ * It follows the entry grammar rather than being free prose, because a
188
+ * waived gate that no reader can find is the same as an unrecorded one.
189
+ */
190
+ export function docsGapLine(id, signals, date = today()) {
191
+ return ` - ${date} — ${id} — docs gap: closed with --force; ${signals.join("; ")} documented nowhere`;
192
+ }
@@ -0,0 +1,191 @@
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
+ export function slugFromPrompt(prompt, { words = SLUG_WORDS } = {}) {
187
+ const content = terms(prompt).slice(0, words);
188
+ const slug = slugify(content.join(" "));
189
+ // A prompt of nothing but stopwords ("do it now") still needs an id.
190
+ return slug || slugify(prompt).split("-").slice(0, words).join("-");
191
+ }
@@ -0,0 +1,159 @@
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
+ /**
87
+ * A snapshot for a window, or null when there is no history to read.
88
+ *
89
+ * @param {string} projectRoot
90
+ * @param {string} since a git --since value ("90 days ago", "2026-01-01")
91
+ */
92
+ export function collectMetrics(projectRoot, since) {
93
+ const log = git(projectRoot, [
94
+ "log", `--since=${since}`, "--date=unix",
95
+ "--pretty=format:@@%H|%ct|%s", "--name-only",
96
+ ]);
97
+ if (log.state !== GIT_STATE.OK) return null;
98
+ const commits = parseLog(log.stdout);
99
+ if (commits.length === 0) return null;
100
+ return computeSnapshot(commits, since);
101
+ }
102
+
103
+ /**
104
+ * Every saved snapshot, oldest first.
105
+ *
106
+ * The snapshots are a TIME SERIES, versioned in the repository — and for two
107
+ * releases the only thing that ever read them was a delta against the most
108
+ * recent one, so a year of measurement answered exactly one question: "is it
109
+ * better than last time?" (audit finding F15). Reading the series is what
110
+ * makes the saving worth doing.
111
+ */
112
+ export function readSeries(projectRoot) {
113
+ const dir = path.join(projectRoot, ...METRICS_REL.split("/"));
114
+ if (!isDir(dir)) return [];
115
+ const out = [];
116
+ for (const f of readdirSync(dir).sort()) {
117
+ if (!/^\d{4}-\d{2}-\d{2}\.json$/.test(f)) continue;
118
+ const file = path.join(dir, f);
119
+ if (!isFile(file)) continue;
120
+ try {
121
+ const snap = JSON.parse(read(file));
122
+ // The filename is the authority on when it was taken: `generated` can
123
+ // be absent in a hand-edited file, and a series ordered by a field
124
+ // that may be missing is not ordered at all.
125
+ out.push({ ...snap, generated: snap.generated ?? f.replace(/\.json$/, "") });
126
+ } catch { /* a malformed snapshot is skipped, never fatal */ }
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /** The most recent saved snapshot, excluding one basename. */
132
+ export function latestSnapshot(projectRoot, excludeBase = null) {
133
+ const series = readSeries(projectRoot).filter((s) => `${s.generated}.json` !== excludeBase);
134
+ return series.length > 0 ? series[series.length - 1] : null;
135
+ }
136
+
137
+ /**
138
+ * The direction each tracked rate moved across the WHOLE series.
139
+ *
140
+ * First to last, not last to previous: a rate that drifted up for six months
141
+ * and dipped once looks like an improvement from the delta and like what it
142
+ * is from the series.
143
+ *
144
+ * @param {ReturnType<typeof readSeries>} series
145
+ */
146
+ export function trend(series) {
147
+ const rates = ["revert_rate", "fix_share", "reedit_rate"];
148
+ if (series.length < 2) return { span: series.length, rows: [] };
149
+ const first = series[0];
150
+ const last = series[series.length - 1];
151
+ const rows = [];
152
+ for (const rate of rates) {
153
+ const a = first[rate];
154
+ const b = last[rate];
155
+ if (typeof a !== "number" || typeof b !== "number") continue;
156
+ rows.push({ rate, first: a, last: b, delta: round(b - a) });
157
+ }
158
+ return { span: series.length, from: first.generated, to: last.generated, rows };
159
+ }