doctrina-cli 0.12.0 → 0.13.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/package.json +1 -1
- package/src/commands/analyze.js +10 -0
- package/src/commands/change.js +245 -6
- package/src/commands/clarify.js +32 -18
- package/src/commands/close.js +63 -10
- package/src/commands/init.js +15 -2
- package/src/commands/templates.js +113 -10
- package/src/commands/upgrade.js +15 -11
- package/src/commands/validate.js +35 -1
- package/src/commands/work.js +61 -6
- package/src/lib/adr-guard.js +46 -0
- package/src/lib/commands.js +100 -2
- package/src/lib/spec-ops.js +143 -3
- package/templates/.gitkeep +0 -0
- package/templates/AGENTS.md.template +25 -35
- package/templates/change/spec-delta.md.template +13 -7
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "../lib/fs-ops.js";
|
|
4
|
-
import { locateTemplatesDir } from "../lib/templates.js";
|
|
4
|
+
import { locateTemplatesDir, loadTemplateTree } from "../lib/templates.js";
|
|
5
|
+
import { surfaceBlock, findSurfaceBlock } from "../lib/commands.js";
|
|
5
6
|
import { flagBool } from "../lib/args.js";
|
|
6
7
|
import { c } from "../lib/colors.js";
|
|
7
8
|
import { suggest } from "../lib/suggest.js";
|
|
@@ -56,6 +57,50 @@ function updateTemplates(flags) {
|
|
|
56
57
|
|
|
57
58
|
const plan = [];
|
|
58
59
|
|
|
60
|
+
// AGENTS.md command-surface block: the one CLI-OWNED span of the file
|
|
61
|
+
// (marker-delimited, generated from the command catalog). Three cases:
|
|
62
|
+
// markers present but stale -> regenerate the span; a legacy hand-written
|
|
63
|
+
// "## Doctrina command surface" section without markers -> replace that
|
|
64
|
+
// section with the managed block (the section was CLI-scaffolded content
|
|
65
|
+
// to begin with — this is the one sanctioned exception to additive-only,
|
|
66
|
+
// see ADR 0015); neither -> append the block. This is what makes
|
|
67
|
+
// `doctrina upgrade --write` actually refresh AGENTS.md when the CLI
|
|
68
|
+
// gains commands, instead of only bumping the stamp.
|
|
69
|
+
const agentsMdPath = path.join(projectRoot, "AGENTS.md");
|
|
70
|
+
if (isFile(agentsMdPath)) {
|
|
71
|
+
const text = read(agentsMdPath);
|
|
72
|
+
const block = findSurfaceBlock(text);
|
|
73
|
+
const fresh = surfaceBlock();
|
|
74
|
+
if (block) {
|
|
75
|
+
if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(fresh)) {
|
|
76
|
+
plan.push({
|
|
77
|
+
describe: ["AGENTS.md: regenerate the doctrina:surface block (stale vs installed CLI)"],
|
|
78
|
+
apply() {
|
|
79
|
+
const cur = read(agentsMdPath);
|
|
80
|
+
const b = findSurfaceBlock(cur);
|
|
81
|
+
if (b) write(agentsMdPath, cur.slice(0, b.start) + fresh + cur.slice(b.end), { force: true });
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
const legacy = findLegacySurfaceSection(text);
|
|
87
|
+
plan.push({
|
|
88
|
+
describe: [legacy
|
|
89
|
+
? "AGENTS.md: replace the hand-written \"## Doctrina command surface\" section with the generated doctrina:surface block"
|
|
90
|
+
: "AGENTS.md: append the generated doctrina:surface command block (agents discover commands through this file)"],
|
|
91
|
+
apply() {
|
|
92
|
+
const cur = read(agentsMdPath);
|
|
93
|
+
const l = findLegacySurfaceSection(cur);
|
|
94
|
+
if (l) {
|
|
95
|
+
write(agentsMdPath, cur.slice(0, l.start) + fresh + "\n\n" + cur.slice(l.end), { force: true });
|
|
96
|
+
} else {
|
|
97
|
+
write(agentsMdPath, cur + (cur.endsWith("\n") ? "" : "\n") + "\n" + fresh + "\n", { force: true });
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
59
104
|
// Markdown files: append stub sections for missing recommended headings.
|
|
60
105
|
for (const [rel, sections] of [
|
|
61
106
|
["AGENTS.md", AGENTS_SECTIONS],
|
|
@@ -178,6 +223,15 @@ function checkTemplates() {
|
|
|
178
223
|
if (hasHeading(text, heading)) ok.push(`AGENTS.md: ${heading}`);
|
|
179
224
|
else findings.push(`AGENTS.md missing recommended section "${heading}"`);
|
|
180
225
|
}
|
|
226
|
+
// Command-surface block: present and current vs the installed catalog.
|
|
227
|
+
const block = findSurfaceBlock(text);
|
|
228
|
+
if (!block) {
|
|
229
|
+
findings.push("AGENTS.md has no doctrina:surface block — agents discover commands through this file (`templates update --write` adds it)");
|
|
230
|
+
} else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
|
|
231
|
+
findings.push("AGENTS.md doctrina:surface block is stale vs the installed CLI (`templates update --write` or `doctrina upgrade --write` regenerates it)");
|
|
232
|
+
} else {
|
|
233
|
+
ok.push("AGENTS.md: doctrina:surface block current");
|
|
234
|
+
}
|
|
181
235
|
} else {
|
|
182
236
|
findings.push("AGENTS.md missing at project root");
|
|
183
237
|
}
|
|
@@ -194,6 +248,32 @@ function checkTemplates() {
|
|
|
194
248
|
findings.push(".doctrina/product.md missing");
|
|
195
249
|
}
|
|
196
250
|
|
|
251
|
+
// Installed agent adapters: every adapter is a thin POINTER at AGENTS.md
|
|
252
|
+
// (that is why one `upgrade --write` refresh of the hub reaches every
|
|
253
|
+
// installed agent). Verify the contract holds for each adapter file the
|
|
254
|
+
// project actually has: it must still reference AGENTS.md. The shipped
|
|
255
|
+
// adapter template tree is the inventory, so a new adapter is covered
|
|
256
|
+
// automatically.
|
|
257
|
+
try {
|
|
258
|
+
const templatesDir = locateTemplatesDir();
|
|
259
|
+
const adaptersRoot = path.join(templatesDir, "adapters");
|
|
260
|
+
if (isDir(adaptersRoot)) {
|
|
261
|
+
for (const agent of walk(adaptersRoot).map((f) => relPath(adaptersRoot, f))) {
|
|
262
|
+
const posix = agent.replace(/\\/g, "/");
|
|
263
|
+
if (posix.endsWith("README.md") || posix.endsWith(".gitkeep")) continue;
|
|
264
|
+
const rel = posix.split("/").slice(1).join("/").replace(/\.template$/, "");
|
|
265
|
+
if (!rel) continue;
|
|
266
|
+
const installed = path.join(projectRoot, rel);
|
|
267
|
+
if (!isFile(installed)) continue; // not installed — nothing to check
|
|
268
|
+
if (read(installed).includes("AGENTS.md")) {
|
|
269
|
+
ok.push(`adapter ${rel}: points at AGENTS.md`);
|
|
270
|
+
} else {
|
|
271
|
+
findings.push(`adapter ${rel} no longer references AGENTS.md — agents loading it will miss the hub (restore the pointer, e.g. re-run \`doctrina init --agent <name> --force\`)`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} catch { /* no templates dir (unusual install) — skip the adapter pass */ }
|
|
276
|
+
|
|
197
277
|
// index.json schema fields
|
|
198
278
|
const indexPath = path.join(projectRoot, ".doctrina", "index.json");
|
|
199
279
|
if (isFile(indexPath)) {
|
|
@@ -227,6 +307,24 @@ function checkTemplates() {
|
|
|
227
307
|
return 1;
|
|
228
308
|
}
|
|
229
309
|
|
|
310
|
+
// Whitespace/EOL-insensitive comparison for the surface block, so a CRLF
|
|
311
|
+
// checkout is not eternally "stale".
|
|
312
|
+
function normalizeBlock(s) {
|
|
313
|
+
return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// A pre-marker AGENTS.md's hand-written surface section: from the
|
|
317
|
+
// "## Doctrina command surface" heading up to (excluding) the next "## "
|
|
318
|
+
// heading. The trailing "Continuous: ..." paragraph the old template put
|
|
319
|
+
// after the bullets belongs to the section body, so it is replaced too.
|
|
320
|
+
function findLegacySurfaceSection(text) {
|
|
321
|
+
const head = text.match(/^##\s+Doctrina command surface.*$/m);
|
|
322
|
+
if (!head) return null;
|
|
323
|
+
const after = text.slice(head.index + head[0].length);
|
|
324
|
+
const next = after.match(/^##\s+/m);
|
|
325
|
+
return { start: head.index, end: next ? head.index + head[0].length + next.index : text.length };
|
|
326
|
+
}
|
|
327
|
+
|
|
230
328
|
function hasHeading(text, heading) {
|
|
231
329
|
// Match an exact h2 heading at line start (case-insensitive for the body
|
|
232
330
|
// after "## ", so "## Vision" matches "## VISION" or "## vision" too).
|
|
@@ -245,15 +343,20 @@ Subcommands:
|
|
|
245
343
|
their relative paths and line counts.
|
|
246
344
|
check Compare the current project's AGENTS.md, product.md,
|
|
247
345
|
and index.json against the recommended sections and
|
|
248
|
-
schema fields shipped in this CLI version
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
346
|
+
schema fields shipped in this CLI version — including
|
|
347
|
+
whether the AGENTS.md doctrina:surface block (the
|
|
348
|
+
CLI-owned, generated command catalog) is present and
|
|
349
|
+
current. Read-only; never modifies any files. Exits 1
|
|
350
|
+
when recommendations exist, 0 otherwise.
|
|
351
|
+
update Fixer for what check reports: appends missing
|
|
352
|
+
recommended sections as stubs, adds missing index.json
|
|
353
|
+
fields, and regenerates the AGENTS.md doctrina:surface
|
|
354
|
+
block from the installed command catalog. Preview by
|
|
355
|
+
default (exits 1 while updates are pending); pass
|
|
356
|
+
--write to apply. Additive-only outside the surface
|
|
357
|
+
block: the marker-delimited span is the one CLI-owned
|
|
358
|
+
region it rewrites (ADR 0015); your content is never
|
|
359
|
+
touched.
|
|
257
360
|
|
|
258
361
|
Distinct from \`doctrina validate\`: validate answers "is this a
|
|
259
362
|
well-formed Doctrina tree?"; templates check answers "does this
|
package/src/commands/upgrade.js
CHANGED
|
@@ -82,10 +82,12 @@ export async function run(_positional, flags) {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
// 4. Full structural check — surfaces what
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
85
|
+
// 4. Full structural check — surfaces what the upgrade cannot fix by
|
|
86
|
+
// itself (new validate checks the old scaffold fails, hand-authored
|
|
87
|
+
// drift). The command surface in AGENTS.md is NOT in this bucket any
|
|
88
|
+
// more: step 1 regenerates the doctrina:surface block from the
|
|
89
|
+
// installed catalog (ADR 0015). --fix under --write so the mechanical
|
|
90
|
+
// part self-heals.
|
|
89
91
|
console.log("");
|
|
90
92
|
console.log(c.gray("──── 3/3 validate" + (writeMode ? " --fix" : "")));
|
|
91
93
|
const vFlags = writeMode ? new Map([["fix", true]]) : new Map();
|
|
@@ -102,7 +104,7 @@ export async function run(_positional, flags) {
|
|
|
102
104
|
}
|
|
103
105
|
if (vCode === 0) {
|
|
104
106
|
console.log(c.green(`✓ project upgraded to ${running}`) + c.gray(" — review any warnings above; new commands: `doctrina --help`."));
|
|
105
|
-
console.log(c.gray("
|
|
107
|
+
console.log(c.gray("AGENTS.md's doctrina:surface block was regenerated from the installed catalog, so agents reading the hub now discover the current commands."));
|
|
106
108
|
} else {
|
|
107
109
|
console.log(c.yellow("upgraded with findings") + " — resolve the validate errors above (they predate or exceed what an additive upgrade can fix).");
|
|
108
110
|
}
|
|
@@ -116,14 +118,16 @@ Bring an existing project up to the installed CLI after an npm update.
|
|
|
116
118
|
The project keeps the scaffold of the version that init-ed it; this is
|
|
117
119
|
the one command that closes the gap, orchestrating the existing pieces:
|
|
118
120
|
|
|
119
|
-
1. templates update —
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
1. templates update — regenerate the AGENTS.md doctrina:surface block
|
|
122
|
+
from the installed command catalog (the block is
|
|
123
|
+
CLI-owned, ADR 0015 — this is how agents reading
|
|
124
|
+
the hub discover commands added since init), and
|
|
125
|
+
append missing recommended sections / index.json
|
|
126
|
+
fields (additive-only outside the block)
|
|
122
127
|
2. index rebuild — regenerate index.json from the tree and migrate
|
|
123
128
|
the framework_version stamp to the running CLI
|
|
124
|
-
3. validate (--fix) — surface anything the
|
|
125
|
-
(
|
|
126
|
-
surface — refresh it so agents see new commands)
|
|
129
|
+
3. validate (--fix) — surface anything the upgrade cannot fix
|
|
130
|
+
(hand-authored drift, new validate checks)
|
|
127
131
|
|
|
128
132
|
Preview by default (exits 1 when steps are pending); --write applies.
|
|
129
133
|
`;
|
package/src/commands/validate.js
CHANGED
|
@@ -254,7 +254,12 @@ export async function run(_positional, flags) {
|
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
// 7. Every open change has a proposal
|
|
257
|
+
// 7. Every open change has a proposal, and its delta files carry a
|
|
258
|
+
// well-formed **Operation:** header. The delta used to be the one
|
|
259
|
+
// 100% hand-authored file with no early check: a missing header passed
|
|
260
|
+
// silently at creation and exploded days later in the closing analyze
|
|
261
|
+
// (operator review 2026-07-19 §3.2). Warning-level — the change is
|
|
262
|
+
// still a draft — but now every `validate` run names it.
|
|
258
263
|
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
259
264
|
if (isDir(changesDir)) {
|
|
260
265
|
const { readdirSync } = await import("node:fs");
|
|
@@ -267,6 +272,35 @@ export async function run(_positional, flags) {
|
|
|
267
272
|
`(every dir under .doctrina/changes/ needs a proposal.md — that exact filename)`,
|
|
268
273
|
);
|
|
269
274
|
}
|
|
275
|
+
// A change whose tasks are still the scaffold placeholders was opened
|
|
276
|
+
// but never planned — the agent is (or will be) implementing with no
|
|
277
|
+
// recorded plan. Early, every-run signal; analyze/close hard-fail it.
|
|
278
|
+
const tasksPath = path.join(changesDir, entry, "tasks.md");
|
|
279
|
+
if (isFile(tasksPath)) {
|
|
280
|
+
const ph = (read(tasksPath).match(/^\s*-\s*\[[ xX]\]\s*$/gm) ?? []).length;
|
|
281
|
+
if (ph > 0) {
|
|
282
|
+
warnings.push(
|
|
283
|
+
`open change "${entry}" tasks.md still carries ${ph} scaffold placeholder task${ph === 1 ? "" : "s"} — ` +
|
|
284
|
+
`the change was opened but never planned (replace them with real tasks; analyze/close refuse them)`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const deltaPath of walk(path.join(changesDir, entry, "specs"))) {
|
|
289
|
+
if (!deltaPath.endsWith("delta.md")) continue;
|
|
290
|
+
const text = read(deltaPath);
|
|
291
|
+
const m = text.match(/^\*\*Operation:\*\*\s*(\S+)/m);
|
|
292
|
+
if (!m) {
|
|
293
|
+
warnings.push(
|
|
294
|
+
`${relPath(projectRoot, deltaPath)} has no **Operation:** header — ` +
|
|
295
|
+
`analyze/apply will refuse it at close time (add "**Operation:** ADDED|MODIFIED|REMOVED")`,
|
|
296
|
+
);
|
|
297
|
+
} else if (!["ADDED", "MODIFIED", "REMOVED"].includes(m[1])) {
|
|
298
|
+
warnings.push(
|
|
299
|
+
`${relPath(projectRoot, deltaPath)} Operation "${m[1]}" is not ADDED|MODIFIED|REMOVED — ` +
|
|
300
|
+
`analyze/apply will refuse it at close time`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
270
304
|
}
|
|
271
305
|
}
|
|
272
306
|
|
package/src/commands/work.js
CHANGED
|
@@ -2,11 +2,12 @@ import path from "node:path";
|
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { exists, isDir, isFile, mkdirp, read, relPath, write } from "../lib/fs-ops.js";
|
|
6
6
|
import { flagString, flagBool } from "../lib/args.js";
|
|
7
7
|
import * as idx from "../lib/index-json.js";
|
|
8
8
|
import { c } from "../lib/colors.js";
|
|
9
9
|
import { assessBrief } from "../lib/clarity.js";
|
|
10
|
+
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
10
11
|
import { changeNew } from "./change.js";
|
|
11
12
|
|
|
12
13
|
// `work` is the second half of the no-ceremony path (ADR 0005): a brief
|
|
@@ -106,6 +107,26 @@ export async function run(positional, flags) {
|
|
|
106
107
|
if (updated !== txt) write(proposalPath, updated, { force: true });
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
// A pinned capability is an explicit statement of intent, so the delta —
|
|
111
|
+
// historically the only 100% hand-authored file in the flow, and the one
|
|
112
|
+
// whose missing **Operation:** header exploded days later at analyze
|
|
113
|
+
// (operator review 2026-07-19 §3.2) — is scaffolded from the template with
|
|
114
|
+
// the Operation prefilled: MODIFIED when the spec exists, ADDED when it
|
|
115
|
+
// does not. Never scaffolded from a ranked GUESS (that would put a wrong
|
|
116
|
+
// capability's delta in the change); only from --capability.
|
|
117
|
+
if (pinned && !chore) {
|
|
118
|
+
const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", pinned, "delta.md");
|
|
119
|
+
if (!exists(deltaPath)) {
|
|
120
|
+
const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
|
|
121
|
+
const op = isFile(path.join(projectRoot, ".doctrina", "specs", pinned, "spec.md")) ? "MODIFIED" : "ADDED";
|
|
122
|
+
const body = substitute(tpl, { CAPABILITY: pinned })
|
|
123
|
+
.replace(/^\*\*Operation:\*\*.*$/m, `**Operation:** ${op}`);
|
|
124
|
+
mkdirp(path.dirname(deltaPath));
|
|
125
|
+
write(deltaPath, body);
|
|
126
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, deltaPath)}` + c.gray(` (Operation: ${op} prefilled)`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
109
130
|
// Capability hint: term overlap for a prompt, changed-file overlap for a diff
|
|
110
131
|
// (review F10 — rank by what the working tree touched, not just prompt words).
|
|
111
132
|
const allChanged = fromDiff ? files : changedFiles(projectRoot);
|
|
@@ -114,6 +135,16 @@ export async function run(positional, flags) {
|
|
|
114
135
|
const capability = pinned ?? matches[0]?.id ?? null;
|
|
115
136
|
const clarity = assessBrief(effPrompt, { kind: "prompt" });
|
|
116
137
|
|
|
138
|
+
// --quiet: registering backlog, not starting now (operator review §3.7 — 19
|
|
139
|
+
// works printed 19 identical 50-line playbooks). One line per change; the
|
|
140
|
+
// playbook is reprintable on demand with --resume.
|
|
141
|
+
if (flagBool(flags, "quiet", false)) {
|
|
142
|
+
console.log("");
|
|
143
|
+
console.log(c.green("opened ") + c.cyan(id) +
|
|
144
|
+
c.gray(` — playbook on demand: doctrina work --resume ${id}`));
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
117
148
|
console.log("");
|
|
118
149
|
if (chore) {
|
|
119
150
|
printChorePlaybook(projectRoot, { id, prompt: effPrompt });
|
|
@@ -339,6 +370,8 @@ function printChorePlaybook(projectRoot, { id, prompt }) {
|
|
|
339
370
|
console.log("");
|
|
340
371
|
console.log(`1. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
|
|
341
372
|
console.log(" with small, checkable steps; implement them, checking each box.");
|
|
373
|
+
console.log(c.gray(" (Do it before implementing — analyze/close refuse leftover scaffold"));
|
|
374
|
+
console.log(c.gray(" placeholders, and `change tick` will not tick an empty box.)"));
|
|
342
375
|
console.log("2. Prove it (archive refuses unchecked boxes):");
|
|
343
376
|
console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
|
|
344
377
|
console.log(` ${c.cyan("doctrina verify --clean")} — clean-checkout reproducibility lint`);
|
|
@@ -406,8 +439,14 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
406
439
|
console.log(c.gray(" \"**Realizes:** SC1\" header (or \"n/a — <why>\"). `doctrina validate` warns"));
|
|
407
440
|
console.log(c.gray(" on an active spec with no Realizes; `doctrina trace` reports the link."));
|
|
408
441
|
console.log("");
|
|
409
|
-
|
|
410
|
-
|
|
442
|
+
if (pinned) {
|
|
443
|
+
console.log(`3. A delta is already scaffolded (Operation prefilled) at`);
|
|
444
|
+
console.log(` .doctrina/changes/${id}/specs/${pinned}/delta.md — fill its body.`);
|
|
445
|
+
console.log(" Add one more delta per additional affected capability:");
|
|
446
|
+
} else {
|
|
447
|
+
console.log("3. Write one delta per affected capability at");
|
|
448
|
+
console.log(` .doctrina/changes/${id}/specs/<capability>/delta.md:`);
|
|
449
|
+
}
|
|
411
450
|
console.log(c.gray(" # Spec Delta — capability: <capability>"));
|
|
412
451
|
console.log(c.gray(" **Operation:** ADDED | MODIFIED | REMOVED"));
|
|
413
452
|
console.log(c.gray(" **Target spec on apply:** `.doctrina/specs/<capability>/spec.md`"));
|
|
@@ -423,8 +462,12 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
423
462
|
console.log(c.gray(" bump-version minor"));
|
|
424
463
|
console.log(c.gray(" set-criterion 1: verified"));
|
|
425
464
|
console.log(c.gray(" append-criterion [unverified] new signal — verified by `test/y.test.js`"));
|
|
465
|
+
console.log(c.gray(" append-requirement event: When <trigger>, the system shall <action>."));
|
|
466
|
+
console.log(c.gray(" replace-requirement ubiquitous 2: The system shall <action>."));
|
|
426
467
|
console.log(c.gray(" ```"));
|
|
427
|
-
console.log(c.gray("
|
|
468
|
+
console.log(c.gray(" append-* ops number/position at APPLY time, so concurrent changes"));
|
|
469
|
+
console.log(c.gray(" appending to the same spec never collide. Only free-prose rewrites"));
|
|
470
|
+
console.log(c.gray(" (Purpose, Maturity, ...) stay a by-hand merge."));
|
|
428
471
|
if (fromDiff) {
|
|
429
472
|
console.log(c.gray(" --from-diff: the code already exists — describe its CURRENT behaviour, and"));
|
|
430
473
|
console.log(c.gray(" mark each criterion [unverified] until a test proves it (don't assume the"));
|
|
@@ -432,7 +475,10 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
432
475
|
}
|
|
433
476
|
console.log("");
|
|
434
477
|
console.log(`4. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
|
|
435
|
-
console.log(" with small, checkable implementation tasks (a few hours each, max)
|
|
478
|
+
console.log(" with small, checkable implementation tasks (a few hours each, max),");
|
|
479
|
+
console.log(" and record the change's What/Scope in its proposal.md. Do this BEFORE");
|
|
480
|
+
console.log(" implementing — analyze and close refuse a change whose scaffold");
|
|
481
|
+
console.log(" placeholders were never replaced, and `change tick` will not tick them.");
|
|
436
482
|
console.log("");
|
|
437
483
|
console.log("5. Implement task by task, checking each box as it lands. Advance the");
|
|
438
484
|
console.log(" spec's Implementation: planned → partial → implemented as code lands.");
|
|
@@ -442,6 +488,10 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
442
488
|
console.log(" (an architecture, a boundary, a trade-off a future session must");
|
|
443
489
|
console.log(` not relitigate)? If yes: ${c.cyan("doctrina decision new \"<title>\"")} now,`);
|
|
444
490
|
console.log(" before closing — the definition of done requires it recorded.");
|
|
491
|
+
console.log(c.gray(" (close re-checks this: it warns when the change touches capabilities"));
|
|
492
|
+
console.log(c.gray(" cited by an accepted ADR — amend via decision supersede, not silence.)"));
|
|
493
|
+
console.log(" Touching an integration surface (ports, env vars, public endpoints)?");
|
|
494
|
+
console.log(` Own it in a contract: ${c.cyan("doctrina contract new <id>")} · ${c.cyan("doctrina contract check")}.`);
|
|
445
495
|
console.log("");
|
|
446
496
|
console.log(`7. Close in one attested pass (preferred — runs every gate and stops`);
|
|
447
497
|
console.log(" at the first failure with the exact rerun command):");
|
|
@@ -476,7 +526,12 @@ named after that word (review G1). Use --resume to do so directly.
|
|
|
476
526
|
Options:
|
|
477
527
|
--title "<short>" Short display title: drives the slug and the proposal
|
|
478
528
|
H1; the full prompt still lands under ## Why
|
|
479
|
-
--capability <cap> Pin the capability instead of ranking matches
|
|
529
|
+
--capability <cap> Pin the capability instead of ranking matches. Also
|
|
530
|
+
scaffolds specs/<cap>/delta.md in the change with the
|
|
531
|
+
Operation header prefilled (MODIFIED when the spec
|
|
532
|
+
exists, ADDED when it does not)
|
|
533
|
+
--quiet Register the change and print one line — no playbook
|
|
534
|
+
(backlog entry; reprint later with --resume <id>)
|
|
480
535
|
--id <id> Override the derived change id
|
|
481
536
|
--resume <id> Reprint the playbook for an open change; create nothing
|
|
482
537
|
--from-diff Backfill: scaffold from the working-tree changes (no
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { isDir, read, walk } from "./fs-ops.js";
|
|
3
|
+
import { listHeader } from "./scan.js";
|
|
4
|
+
|
|
5
|
+
// Deterministic ADR checkpoint (operator review 2026-07-19 §4.6). The work
|
|
6
|
+
// playbook's "record an ADR" step was ignorable in silence: a change that
|
|
7
|
+
// flexibilised an accepted ADR closed with no amendment and nothing barked.
|
|
8
|
+
// This helper names the accepted ADRs whose text cites any of the change's
|
|
9
|
+
// touched capabilities, so `change check` and `close` can print a loud,
|
|
10
|
+
// advisory reminder: if the change alters what the ADR decided, record it
|
|
11
|
+
// (`decision supersede` / `decision new`) instead of drifting past it.
|
|
12
|
+
// A word match on capability names — a hint, never a decision (ADR 0005),
|
|
13
|
+
// and never a blocker (an ADR merely MENTIONING a capability is normal).
|
|
14
|
+
export function adrAdvisories(projectRoot, capabilities) {
|
|
15
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
16
|
+
if (!isDir(adrDir) || capabilities.length === 0) return [];
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const f of walk(adrDir)) {
|
|
19
|
+
const base = path.basename(f);
|
|
20
|
+
const m = base.match(/^(\d{4})-.*\.md$/);
|
|
21
|
+
if (!m) continue;
|
|
22
|
+
const text = read(f);
|
|
23
|
+
if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
|
|
24
|
+
const caps = capabilities.filter((cap) =>
|
|
25
|
+
new RegExp(`(^|[^a-z0-9-])${cap.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9-]|$)`, "im").test(text),
|
|
26
|
+
);
|
|
27
|
+
if (caps.length === 0) continue;
|
|
28
|
+
const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
|
|
29
|
+
out.push({ id: m[1], title: titleMatch ? titleMatch[1].trim() : base, caps });
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Print the advisory block shared by `change check` and `close`. Returns the
|
|
35
|
+
// number of ADRs flagged (informational; callers never gate on it).
|
|
36
|
+
export function printAdrCheckpoint(projectRoot, capabilities, { c }) {
|
|
37
|
+
const hits = adrAdvisories(projectRoot, capabilities);
|
|
38
|
+
if (hits.length === 0) return 0;
|
|
39
|
+
console.log(c.yellow("ADR checkpoint") + c.gray(" — accepted ADRs cite the touched capabilities:"));
|
|
40
|
+
for (const h of hits) {
|
|
41
|
+
console.log(` ADR ${c.cyan(h.id)} ${h.title} ${c.gray(`(cites: ${h.caps.join(", ")})`)}`);
|
|
42
|
+
}
|
|
43
|
+
console.log(c.gray(" If this change alters what an ADR decided, record it now:"));
|
|
44
|
+
console.log(c.gray(" ") + c.cyan("doctrina decision supersede <n>") + c.gray(" · ") + c.cyan("doctrina decision new \"<title>\""));
|
|
45
|
+
return hits.length;
|
|
46
|
+
}
|
package/src/lib/commands.js
CHANGED
|
@@ -40,8 +40,10 @@ export const OPERATIONS = [
|
|
|
40
40
|
["spec list", "List specs with version, status, and size"],
|
|
41
41
|
["spec set", "Edit spec headers / a criterion mark and resync the index"],
|
|
42
42
|
["change new", "Open a change proposal"],
|
|
43
|
-
["change apply", "Apply spec deltas (
|
|
44
|
-
["change archive", "Archive an applied change"],
|
|
43
|
+
["change apply", "Apply spec deltas (ops blocks mechanically; batch ids ok)"],
|
|
44
|
+
["change archive", "Archive an applied change (batch ids ok)"],
|
|
45
|
+
["change check", "Pre-close dry-run: everything close would refuse, listed first"],
|
|
46
|
+
["change tick", "List/tick the unchecked boxes (tasks + verification; --all)"],
|
|
45
47
|
["change diff", "Preview spec deltas (line diff for MODIFIED)"],
|
|
46
48
|
["change abandon", "Discard an open change cleanly (recorded in the ledger)"],
|
|
47
49
|
["contract new", "Own the integration surface (ports, env, interfaces)"],
|
|
@@ -94,6 +96,102 @@ export function surfaceHelp() {
|
|
|
94
96
|
return OPERATIONS.map(([op, summary]) => ` ${op.padEnd(21)}${summary}`).join("\n");
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// The CLI-owned AGENTS.md command-surface block (operator review 2026-07-19,
|
|
101
|
+
// meta-conclusion): for an agent, AGENTS.md IS the discovery interface — it
|
|
102
|
+
// never runs `--help` spontaneously — so every command absent from the hub
|
|
103
|
+
// was invisible for ~35 changes (`prime`, `handoff`, `doctor`, `show`, ...).
|
|
104
|
+
// The fix is a marker-delimited section GENERATED from OPERATIONS, so the
|
|
105
|
+
// hub's catalog is the CLI's catalog by construction: `init` scaffolds it,
|
|
106
|
+
// `templates check` flags it stale, and `templates update --write` (hence
|
|
107
|
+
// `doctrina upgrade --write`) regenerates exactly the marked span and
|
|
108
|
+
// nothing else. Content between the markers is CLI-owned; everything
|
|
109
|
+
// outside remains the adopter's, under the additive-only guarantee.
|
|
110
|
+
|
|
111
|
+
export const SURFACE_BEGIN =
|
|
112
|
+
"<!-- doctrina:surface:begin — CLI-owned block, generated from the installed command catalog. Refreshed by `doctrina upgrade --write`; edits inside are overwritten. -->";
|
|
113
|
+
export const SURFACE_END = "<!-- doctrina:surface:end -->";
|
|
114
|
+
|
|
115
|
+
// Workflow grouping for the generated block. A test asserts the flattened
|
|
116
|
+
// names equal COMMAND_NAMES, so a new command cannot ship ungrouped (and
|
|
117
|
+
// therefore cannot ship invisible to AGENTS.md).
|
|
118
|
+
export const SURFACE_GROUPS = [
|
|
119
|
+
["Start", ["init", "intake", "work"]],
|
|
120
|
+
["Author", ["spec", "change", "contract", "decision", "skill", "intent"]],
|
|
121
|
+
["Read / orient", ["prime", "context", "show", "search", "status", "next", "why", "handoff", "constitution"]],
|
|
122
|
+
["Gates", ["analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor"]],
|
|
123
|
+
["Maintain", ["templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade"]],
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// Per-command usage hints appended to the generated reference — arguments and
|
|
127
|
+
// the flags an agent reaches for daily. Cosmetic only; the operation list
|
|
128
|
+
// itself always comes from OPERATIONS.
|
|
129
|
+
const SURFACE_HINTS = {
|
|
130
|
+
work: '"<prompt>" (--capability · --chore · --from-diff · --quiet)',
|
|
131
|
+
context: "[<cap>] --concat",
|
|
132
|
+
validate: "(--fix)",
|
|
133
|
+
coverage: "--strict",
|
|
134
|
+
trace: "--strict",
|
|
135
|
+
clarify: "--all (--lang pt|en)",
|
|
136
|
+
close: "<id...>",
|
|
137
|
+
upgrade: "--write",
|
|
138
|
+
prime: "(session start)",
|
|
139
|
+
handoff: "(before compaction/handover)",
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// The full managed section, heading included, between the markers.
|
|
143
|
+
export function surfaceMarkdown() {
|
|
144
|
+
const subsByCmd = new Map();
|
|
145
|
+
for (const [op] of OPERATIONS) {
|
|
146
|
+
const [cmd, sub] = op.split(" ");
|
|
147
|
+
if (!subsByCmd.has(cmd)) subsByCmd.set(cmd, []);
|
|
148
|
+
if (sub) subsByCmd.get(cmd).push(sub);
|
|
149
|
+
}
|
|
150
|
+
const renderCmd = (cmd) => {
|
|
151
|
+
const subs = subsByCmd.get(cmd) ?? [];
|
|
152
|
+
const hint = SURFACE_HINTS[cmd] ? ` ${SURFACE_HINTS[cmd]}` : "";
|
|
153
|
+
return subs.length > 0 ? `\`doctrina ${cmd} ${subs.join("|")}\`` : `\`doctrina ${cmd}${hint}\``;
|
|
154
|
+
};
|
|
155
|
+
const lines = [
|
|
156
|
+
"## Doctrina command surface (generated — reach for these, don't hand-author)",
|
|
157
|
+
"",
|
|
158
|
+
"Every operation of the installed CLI, grouped by workflow. The CLI",
|
|
159
|
+
"scaffolds from canonical templates and keeps `index.json` in sync, so",
|
|
160
|
+
"prefer it over writing artifacts by hand. Details: `doctrina --help`.",
|
|
161
|
+
"",
|
|
162
|
+
];
|
|
163
|
+
for (const [label, cmds] of SURFACE_GROUPS) {
|
|
164
|
+
lines.push(`- **${label}:** ${cmds.map(renderCmd).join(" · ")}`);
|
|
165
|
+
}
|
|
166
|
+
lines.push("");
|
|
167
|
+
lines.push("Session bookends: `doctrina prime` to orient at session start;");
|
|
168
|
+
lines.push("`doctrina handoff` before compaction or handover. Continuous:");
|
|
169
|
+
lines.push("`doctrina watch`. Capture lessons: `doctrina skill suggest`.");
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The block as written to AGENTS.md: markers wrapping the generated section.
|
|
174
|
+
export function surfaceBlock() {
|
|
175
|
+
return `${SURFACE_BEGIN}\n${surfaceMarkdown()}\n${SURFACE_END}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Locate the managed block in an AGENTS.md text. Returns
|
|
179
|
+
// { start, end, inner } (string offsets; inner excludes the marker lines)
|
|
180
|
+
// or null when either marker is absent.
|
|
181
|
+
export function findSurfaceBlock(text) {
|
|
182
|
+
const begin = text.match(/<!--\s*doctrina:surface:begin[\s\S]*?-->/);
|
|
183
|
+
if (!begin) return null;
|
|
184
|
+
const endRe = /<!--\s*doctrina:surface:end\s*-->/;
|
|
185
|
+
const end = endRe.exec(text.slice(begin.index + begin[0].length));
|
|
186
|
+
if (!end) return null;
|
|
187
|
+
const innerStart = begin.index + begin[0].length;
|
|
188
|
+
return {
|
|
189
|
+
start: begin.index,
|
|
190
|
+
end: innerStart + end.index + end[0].length,
|
|
191
|
+
inner: text.slice(innerStart, innerStart + end.index).replace(/^\r?\n|\r?\n$/g, ""),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
97
195
|
// Extract the set of top-level `doctrina <command>` references that appear in
|
|
98
196
|
// CODE context (fenced blocks or inline backtick spans) of a Markdown file —
|
|
99
197
|
// AGENTS.md, typically. Restricting to code context avoids matching prose like
|