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.
- package/README.md +27 -72
- package/package.json +3 -3
- package/src/commands/adapter.js +27 -3
- package/src/commands/analyze.js +16 -212
- package/src/commands/change.js +144 -267
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +65 -21
- package/src/commands/close.js +349 -45
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +284 -111
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +66 -7
- package/src/commands/doctor.js +293 -130
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +17 -3
- package/src/commands/index-rebuild.js +55 -44
- package/src/commands/init.js +88 -16
- package/src/commands/intake.js +85 -99
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +65 -88
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +23 -91
- package/src/commands/report.js +40 -109
- 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 +23 -39
- package/src/commands/spec.js +38 -11
- package/src/commands/status.js +69 -171
- package/src/commands/templates.js +35 -148
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +44 -877
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +243 -267
- package/src/index.js +139 -12
- package/src/lib/actions.js +490 -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/args.js +14 -0
- package/src/lib/change-ops.js +133 -0
- package/src/lib/commands.js +135 -10
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +431 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +370 -3
- package/src/lib/docs-impact.js +270 -27
- package/src/lib/ears.js +24 -1
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +231 -29
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +42 -24
- package/src/lib/intake-model.js +155 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +228 -0
- package/src/lib/metrics-model.js +189 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +105 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +328 -25
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +231 -0
- package/src/lib/spec-ops.js +72 -8
- package/src/lib/templates-model.js +289 -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 +1381 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +413 -0
- package/src/lib/work-model.js +102 -0
- package/templates/AGENTS.md.template +21 -28
- package/templates/README.md +35 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +16 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +16 -2
- package/templates/playbooks/bootstrap.md.template +47 -0
- package/templates/playbooks/chore.md.template +24 -0
- package/templates/playbooks/work.md.template +83 -0
- package/src/commands/constitution.js +0 -145
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Writing the intake and printing the bootstrap playbook.
|
|
3
|
+
//
|
|
4
|
+
// `init --intake` performs the same three operations `intake` does, and
|
|
5
|
+
// reached into `commands/intake.js` for them (audit finding F7). They are
|
|
6
|
+
// operations over the tree, not renderings of one command, so both
|
|
7
|
+
// entry points read them from here.
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { exists, isFile, read, relPath, write } from "./fs-ops.js";
|
|
10
|
+
import { assessBrief } from "./clarity.js";
|
|
11
|
+
import { c } from "./colors.js";
|
|
12
|
+
import { printPlaybookTemplate } from "./playbook.js";
|
|
13
|
+
import { getHeader, setHeader } from "./doc-model.js";
|
|
14
|
+
import { stateWord } from "./spec-ops.js";
|
|
15
|
+
// Clarification gate (review Topic A). A description too thin to spec from is
|
|
16
|
+
// the moment to ask the user, not to let the agent invent requirements. Prints
|
|
17
|
+
// the specific gaps before the bootstrap playbook; advisory, never blocking —
|
|
18
|
+
// the intake is still captured verbatim (the playbook step 6 resolves it).
|
|
19
|
+
/**
|
|
20
|
+
* Does this argument read as a FILE PATH, or as the description itself?
|
|
21
|
+
*
|
|
22
|
+
* `doctrina intake "<description>"` is the form the surface block's wording
|
|
23
|
+
* invites — "store the intent" — and it answered `description file not found:`
|
|
24
|
+
* followed by the author's whole sentence, echoed back as if it were a
|
|
25
|
+
* filename, with no mention of `--text` (change 0071). Prose and a path are
|
|
26
|
+
* distinguishable without guessing: a path has no sentence in it.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately conservative — anything that could be a path IS treated as one,
|
|
29
|
+
* so a real file is never mistaken for prose and read as a description.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} value
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function looksLikePath(value) {
|
|
35
|
+
const v = String(value).trim();
|
|
36
|
+
if (v === "") return false;
|
|
37
|
+
if (/[/\\]/.test(v)) return true; // a separator settles it
|
|
38
|
+
if (/^[.~]/.test(v)) return true; // ./x, ../x, ~/x
|
|
39
|
+
if (!/\s/.test(v)) return true; // one token: assume a filename
|
|
40
|
+
if (/\.(md|txt|markdown|rst|adoc|org)$/i.test(v)) return true; // "my notes.txt"
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// What `init` tells a person to do next (change 0173). The first run used to
|
|
45
|
+
// contradict itself three ways: `init` said "edit AGENTS.md and product.md",
|
|
46
|
+
// `next` asked for the description again as an intake, and AGENTS.md said the
|
|
47
|
+
// agent runs the commands while the human stays passive. One answer now,
|
|
48
|
+
// the same one `next` and AGENTS.md give.
|
|
49
|
+
//
|
|
50
|
+
// `intakeFrom` is "tty" when the description was typed at init's question
|
|
51
|
+
// (it is stored as the intake), or null when init got no intake at all.
|
|
52
|
+
export function nextStepAfterInit(intakeFrom) {
|
|
53
|
+
if (intakeFrom === "tty") {
|
|
54
|
+
return [
|
|
55
|
+
"Next: open your AI agent in this repository and tell it:",
|
|
56
|
+
" read AGENTS.md and run doctrina next",
|
|
57
|
+
"It turns your description into product.md and specs; you review and approve.",
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
return [
|
|
61
|
+
"Next: give your AI agent the whole project description. It runs",
|
|
62
|
+
" doctrina intake --text \"<description>\"",
|
|
63
|
+
"and turns it into product.md and specs (AGENTS.md, \"Working from intent\");",
|
|
64
|
+
"for an existing codebase, `doctrina work --from-diff` backfills them from the code.",
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function warnIfThinIntake(body) {
|
|
69
|
+
const assessment = assessBrief(body, { kind: "intake" });
|
|
70
|
+
if (!assessment.thin) return;
|
|
71
|
+
console.log(c.yellow("⚠ thin intake — clarify with the user before converting to specs:"));
|
|
72
|
+
for (const reason of assessment.reasons) console.log(` - ${reason}`);
|
|
73
|
+
console.log("");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// THE INTAKE'S STATUS IS A CONTROL VALUE, SO IT GETS AN ENUM AND AN OWNER.
|
|
77
|
+
//
|
|
78
|
+
// `next` branches on it: pending means the bootstrap is unfinished, and every
|
|
79
|
+
// other artifact's status in this framework is both written by a command and
|
|
80
|
+
// checked against an enum — `spec set` refuses "Status: nonsense", and
|
|
81
|
+
// `validate` calls it an error. The intake was the one header the playbook
|
|
82
|
+
// told an agent to edit BY HAND, and the one nothing read back.
|
|
83
|
+
//
|
|
84
|
+
// So any token that was not exactly "converted" meant pending, in silence:
|
|
85
|
+
// "convertido" typed in a Portuguese project, "done", or an empty value left
|
|
86
|
+
// by a botched edit. `validate` exited 0 on all of them and `next` went on
|
|
87
|
+
// asking for a bootstrap that had already happened.
|
|
88
|
+
export const INTAKE_STATUSES = ["pending", "converted"];
|
|
89
|
+
|
|
90
|
+
/** The intake's status word, note stripped and folded. Defaults to pending. */
|
|
91
|
+
export function intakeStatus(text) {
|
|
92
|
+
const raw = getHeader(String(text ?? ""), "Status");
|
|
93
|
+
if (raw === null) return "pending";
|
|
94
|
+
return stateWord(raw) || "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** null when the intake's status is a declared value; otherwise the error. */
|
|
98
|
+
export function intakeStatusError(text) {
|
|
99
|
+
const word = intakeStatus(text);
|
|
100
|
+
if (INTAKE_STATUSES.includes(word)) return null;
|
|
101
|
+
const got = getHeader(String(text ?? ""), "Status");
|
|
102
|
+
return `Status must be one of ${INTAKE_STATUSES.join("|")} `
|
|
103
|
+
+ `(got "${String(got ?? "").trim()}") — every other value reads as pending, in silence`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Flip the stored intake to `converted`. Returns the path written, or null
|
|
108
|
+
* when there is no intake (or no Status header) to flip.
|
|
109
|
+
*/
|
|
110
|
+
export function markIntakeConverted(projectRoot) {
|
|
111
|
+
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
112
|
+
if (!exists(intakePath)) return null;
|
|
113
|
+
const next = setHeader(read(intakePath), "Status", "converted");
|
|
114
|
+
if (next === null) return null;
|
|
115
|
+
write(intakePath, next, { force: true });
|
|
116
|
+
return intakePath;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Shared with `init --intake`. Writes the intake verbatim under a small
|
|
120
|
+
// status header; `doctrina intake --converted` flips it at the end of the
|
|
121
|
+
// playbook, which is how `next` knows the bootstrap is done.
|
|
122
|
+
export function writeIntakeFile(projectRoot, { body, source, projectName, date, force = false }) {
|
|
123
|
+
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
124
|
+
const content = [
|
|
125
|
+
`# Intake — ${projectName}`,
|
|
126
|
+
"",
|
|
127
|
+
"- **Status:** pending",
|
|
128
|
+
`- **Date:** ${date}`,
|
|
129
|
+
`- **Source:** ${source}`,
|
|
130
|
+
"",
|
|
131
|
+
"<!-- Raw project intent, stored verbatim. The bootstrap playbook",
|
|
132
|
+
" (doctrina intake) converts it into product.md and capability",
|
|
133
|
+
" specs. Close it with `doctrina intake --converted`; after that the",
|
|
134
|
+
" specs are the only source of truth; never edit this file to",
|
|
135
|
+
" change requirements. -->",
|
|
136
|
+
"",
|
|
137
|
+
"---",
|
|
138
|
+
"",
|
|
139
|
+
body.replace(/\r\n/g, "\n").replace(/\n*$/, "\n"),
|
|
140
|
+
].join("\n");
|
|
141
|
+
write(intakePath, content, { force });
|
|
142
|
+
return intakePath;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The bootstrap playbook, like the work and chore playbooks, is a TEMPLATE
|
|
146
|
+
// resolved project-over-bundled (audit finding F17). What stays here is the
|
|
147
|
+
// one conditional: a tree that never got its specs/ directory.
|
|
148
|
+
export function printBootstrapPlaybook(projectRoot) {
|
|
149
|
+
const hasSpecs = exists(path.join(projectRoot, ".doctrina", "specs"));
|
|
150
|
+
printPlaybookTemplate(projectRoot, "bootstrap", {
|
|
151
|
+
MISSING_SPECS_WARNING: hasSpecs
|
|
152
|
+
? ""
|
|
153
|
+
: "\n" + c.yellow("warn:") + " .doctrina/specs/ is missing — re-run `doctrina init` first.",
|
|
154
|
+
});
|
|
155
|
+
}
|
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
|
+
}
|