doctrina-cli 0.15.1 → 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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/adapter.js +24 -1
- package/src/commands/analyze.js +8 -212
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/change-ops.js +131 -0
- package/src/lib/commands.js +56 -4
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +181 -23
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
package/src/lib/json-out.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
+
}
|