doctrina-cli 0.17.0 → 0.20.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 +8 -4
- package/package.json +1 -1
- package/src/commands/adapter.js +74 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +131 -7
- package/src/commands/close.js +92 -28
- package/src/commands/context.js +49 -8
- package/src/commands/delivery.js +238 -0
- package/src/commands/index-rebuild.js +23 -0
- package/src/commands/init.js +15 -0
- package/src/commands/intake.js +47 -1
- package/src/commands/metrics.js +28 -3
- package/src/commands/next.js +1 -0
- package/src/commands/prime.js +76 -7
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +3 -127
- package/src/commands/upgrade.js +15 -0
- package/src/commands/verify.js +59 -6
- package/src/commands/work.js +139 -5
- package/src/commands/workspace.js +409 -0
- package/src/index.js +5 -7
- package/src/lib/actions.js +9 -5
- package/src/lib/adapters.js +80 -0
- package/src/lib/change-ops.js +92 -0
- package/src/lib/code-inventory.js +297 -0
- package/src/lib/commands.js +52 -5
- package/src/lib/context-slice.js +118 -0
- package/src/lib/delivery.js +208 -0
- package/src/lib/git.js +59 -2
- package/src/lib/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/runtime.js +6 -0
- package/src/lib/scan.js +13 -0
- package/src/lib/signoff.js +24 -0
- package/src/lib/skill-candidates.js +162 -0
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +76 -2
- package/src/lib/spec-ops.js +117 -1
- package/src/lib/task-card.js +102 -0
- package/src/lib/validation-model.js +1 -1
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +56 -2
- package/src/lib/work-model.js +90 -12
- package/src/lib/workspace-agents.js +259 -0
- package/src/lib/workspace-tools.js +113 -0
- package/src/lib/workspace.js +371 -0
- package/templates/AGENTS.md.template +17 -14
- package/templates/README.md +11 -0
- package/templates/adapters/claude/.claude/settings.json.template +18 -0
- package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
- package/templates/change/spec-delta.md.template +2 -0
- package/templates/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +40 -0
- package/templates/playbooks/consolidate.md.template +28 -0
- package/templates/playbooks/delivery.md.template +38 -0
- package/templates/playbooks/work.md.template +4 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> CLI for the Doctrina framework. The package is published as
|
|
4
4
|
> `doctrina-cli`; the executable it installs is `doctrina`.
|
|
5
5
|
|
|
6
|
-
**Status:** v0.
|
|
6
|
+
**Status:** v0.20.0 — current release. Zero runtime dependencies
|
|
7
7
|
(Node.js standard library only).
|
|
8
8
|
|
|
9
9
|
## Install
|
|
@@ -25,10 +25,11 @@ surface, by the moment you reach for it:
|
|
|
25
25
|
```
|
|
26
26
|
Bootstrap init · intake · adapter list|add|remove
|
|
27
27
|
Orient prime · next · status · context · show · search · why · handoff
|
|
28
|
-
Change triage · work · spec new|list|set · change new|apply|archive|check|tick|abandon
|
|
28
|
+
Change triage · work · spec new|list|set · change new|apply|archive|check|tick|abandon|delta|card
|
|
29
29
|
contract new|list|check · decision new|accept|land|supersede|list|scope
|
|
30
30
|
skill new|list|suggest · intent add|list
|
|
31
31
|
Gate clarify · validate · coverage · trace · review · verify · close · doctor
|
|
32
|
+
Orchestrate workspace init|add|attach|detach|list|tool|guard · delivery new|route|list|status|check|close
|
|
32
33
|
Maintain upgrade · index rebuild · hooks install · watch · metrics · completion · ci · templates list
|
|
33
34
|
```
|
|
34
35
|
|
|
@@ -72,14 +73,17 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
|
|
|
72
73
|
- **REMOVED:** deletes the target spec.
|
|
73
74
|
- **MODIFIED:** when the delta carries a fenced ` ```ops ` block
|
|
74
75
|
(`set-header` / `bump-version` / `set-criterion` / `replace-criterion`
|
|
75
|
-
/ `append-criterion` / `append-requirement` / `replace-requirement`
|
|
76
|
+
/ `append-criterion` / `append-requirement` / `replace-requirement` /
|
|
77
|
+
`remove-requirement` / `remove-criterion`),
|
|
76
78
|
applies it mechanically to the target spec — all
|
|
77
79
|
ops or none — and regenerates the index (ADR 0007). Without an ops
|
|
78
80
|
block it prints a manual-merge pointer and does not write: auto-merging
|
|
79
81
|
arbitrary prose is the kind of implicit decision Doctrina avoids.
|
|
80
82
|
|
|
81
83
|
On any spec write, the index is rebuilt from the tree so it never drifts
|
|
82
|
-
from the applied spec. `doctrina change
|
|
84
|
+
from the applied spec. `doctrina change delta <id> <capability>` writes the
|
|
85
|
+
ops lines for you (`--requirement`, `--criterion`, `--bump`), dry-run
|
|
86
|
+
against the spec before anything is written. `doctrina change abandon <id>` is the inverse of
|
|
83
87
|
`change new`: it deletes an open change and its index entry and ledgers
|
|
84
88
|
the abandonment. `doctrina close <id>` runs apply together with verify,
|
|
85
89
|
coverage, archive and validate in one attested pass — the definition of
|
package/package.json
CHANGED
package/src/commands/adapter.js
CHANGED
|
@@ -4,6 +4,7 @@ import process from "node:process";
|
|
|
4
4
|
import { exists, isFile, mkdirp, pruneEmptyDirs, read, relPath, remove, write } from "../lib/fs-ops.js";
|
|
5
5
|
import {
|
|
6
6
|
ADAPTER_STATES, adapterFiles, describeAdapter, listAdapterNames, renderAdapterFile,
|
|
7
|
+
isMergedConfig, mergeJson, unmergeJson, containsJson, holdsAnyOf,
|
|
7
8
|
} from "../lib/adapters.js";
|
|
8
9
|
import { flagBool } from "../lib/args.js";
|
|
9
10
|
import { today } from "../lib/dates.js";
|
|
@@ -11,6 +12,7 @@ import { c } from "../lib/colors.js";
|
|
|
11
12
|
import { suggest } from "../lib/suggest.js";
|
|
12
13
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
14
|
import { projectName } from "../lib/project.js";
|
|
15
|
+
import { applySkillExport } from "../lib/skill-export.js";
|
|
14
16
|
|
|
15
17
|
// `doctrina adapter` — add, remove, and inventory agent adapters (audit
|
|
16
18
|
// item C1).
|
|
@@ -109,6 +111,21 @@ function adapterAdd(projectRoot, name, cmdFlags) {
|
|
|
109
111
|
let skipped = 0;
|
|
110
112
|
for (const file of spec.files) {
|
|
111
113
|
const dest = path.join(projectRoot, file.relativePath);
|
|
114
|
+
// A shared settings file is merged, never overwritten nor skipped
|
|
115
|
+
// (change 0211): the user's own keys stay, ours are added once.
|
|
116
|
+
if (isMergedConfig(file) && exists(dest)) {
|
|
117
|
+
const merged = mergeInto(dest, renderAdapterFile(file, tokens));
|
|
118
|
+
if (merged === null) {
|
|
119
|
+
console.log(c.yellow("skip ") + ` ${file.relativePath} (not valid JSON — add the entries by hand)`);
|
|
120
|
+
skipped += 1;
|
|
121
|
+
} else if (merged) {
|
|
122
|
+
console.log(c.green("merged ") + ` ${file.relativePath}`);
|
|
123
|
+
written += 1;
|
|
124
|
+
} else {
|
|
125
|
+
console.log(c.gray("ok ") + ` ${file.relativePath} (already holds the doctrina entries)`);
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
112
129
|
if (exists(dest) && !force) {
|
|
113
130
|
console.log(c.yellow("skip ") + ` ${file.relativePath} (already present; --force overwrites)`);
|
|
114
131
|
skipped += 1;
|
|
@@ -124,6 +141,12 @@ function adapterAdd(projectRoot, name, cmdFlags) {
|
|
|
124
141
|
console.log(`${c.bold("Adapter " + name)} — ${written} file${written === 1 ? "" : "s"} written` +
|
|
125
142
|
(skipped > 0 ? `, ${skipped} left alone` : "") +
|
|
126
143
|
(spec.source === "project" ? c.gray(" (from .doctrina/templates/adapters/)") : ""));
|
|
144
|
+
// The project's skills follow the agent in (change 0212): with the claude
|
|
145
|
+
// adapter, `.claude/skills/` becomes an export root.
|
|
146
|
+
const exported = applySkillExport(projectRoot);
|
|
147
|
+
if (exported.writes.length > 0) {
|
|
148
|
+
console.log(c.green("export ") + ` ${exported.writes.length} skill file${exported.writes.length === 1 ? "" : "s"} where the agent discovers skills`);
|
|
149
|
+
}
|
|
127
150
|
console.log(c.gray("Additive only: AGENTS.md and .doctrina/product.md were not touched."));
|
|
128
151
|
return 0;
|
|
129
152
|
}
|
|
@@ -156,6 +179,29 @@ function adapterRemove(projectRoot, name, cmdFlags) {
|
|
|
156
179
|
for (const file of spec.files) {
|
|
157
180
|
const dest = path.join(projectRoot, file.relativePath);
|
|
158
181
|
if (!isFile(dest)) continue;
|
|
182
|
+
// A shared settings file loses only the entries this adapter added;
|
|
183
|
+
// the file goes only when nothing of the user's is left in it.
|
|
184
|
+
if (isMergedConfig(file)) {
|
|
185
|
+
const left = unmergeFrom(dest, renderAdapterFile(file, tokens));
|
|
186
|
+
if (left === undefined) {
|
|
187
|
+
console.log(c.yellow("kept ") + ` ${file.relativePath} (not valid JSON — remove the doctrina entries by hand)`);
|
|
188
|
+
kept += 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (left === UNTOUCHED) {
|
|
192
|
+
console.log(c.gray("kept ") + ` ${file.relativePath} (holds no doctrina entries — yours)`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (left !== null) {
|
|
196
|
+
console.log(c.green("updated") + ` ${file.relativePath} (doctrina entries removed; yours kept)`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
remove(dest);
|
|
200
|
+
console.log(c.green("removed") + ` ${file.relativePath}`);
|
|
201
|
+
removed += 1;
|
|
202
|
+
vacated.add(path.dirname(dest));
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
159
205
|
const pristine = normalise(read(dest)) === normalise(renderAdapterFile(file, tokens));
|
|
160
206
|
if (!pristine && !force) {
|
|
161
207
|
console.log(c.yellow("kept ") + ` ${file.relativePath} (edited since install; --force removes it)`);
|
|
@@ -191,6 +237,34 @@ function adapterRemove(projectRoot, name, cmdFlags) {
|
|
|
191
237
|
return 0;
|
|
192
238
|
}
|
|
193
239
|
|
|
240
|
+
// Merge the rendered template into an existing JSON file. Returns true when
|
|
241
|
+
// the file changed, false when it already held every entry, null when the
|
|
242
|
+
// file is not JSON this command can safely rewrite.
|
|
243
|
+
function mergeInto(dest, rendered) {
|
|
244
|
+
let theirs;
|
|
245
|
+
try { theirs = JSON.parse(read(dest)); } catch { return null; }
|
|
246
|
+
const ours = JSON.parse(rendered);
|
|
247
|
+
if (containsJson(theirs, ours)) return false;
|
|
248
|
+
write(dest, JSON.stringify(mergeJson(theirs, ours), null, 2) + "\n", { force: true });
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const UNTOUCHED = Symbol("untouched");
|
|
253
|
+
|
|
254
|
+
// Take the rendered template's entries back out of a JSON file. Returns what
|
|
255
|
+
// is left (written back), null when nothing is (the caller removes the
|
|
256
|
+
// file), undefined when the file is not JSON.
|
|
257
|
+
function unmergeFrom(dest, rendered) {
|
|
258
|
+
let theirs;
|
|
259
|
+
try { theirs = JSON.parse(read(dest)); } catch { return undefined; }
|
|
260
|
+
// A file holding none of our entries was never ours, or was rewritten by
|
|
261
|
+
// the user since: it is theirs, and stays as it is.
|
|
262
|
+
if (!holdsAnyOf(theirs, JSON.parse(rendered))) return UNTOUCHED;
|
|
263
|
+
const left = unmergeJson(theirs, JSON.parse(rendered));
|
|
264
|
+
if (left !== null) write(dest, JSON.stringify(left, null, 2) + "\n", { force: true });
|
|
265
|
+
return left;
|
|
266
|
+
}
|
|
267
|
+
|
|
194
268
|
function normalise(text) {
|
|
195
269
|
return text.replace(/\r\n/g, "\n").trim();
|
|
196
270
|
}
|
package/src/commands/analyze.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { isDir, relPath } from "../lib/fs-ops.js";
|
|
4
|
+
import { isDir, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
5
|
+
import { listHeader } from "../lib/scan.js";
|
|
6
|
+
import { stripAnsi } from "../lib/json-out.js";
|
|
5
7
|
import { c } from "../lib/colors.js";
|
|
6
8
|
import { collectAnalysis } from "../lib/analysis.js";
|
|
7
9
|
import { EXIT } from "../lib/exit-codes.js";
|
|
@@ -40,13 +42,22 @@ export async function run(positional, _flags) {
|
|
|
40
42
|
console.log(`analyzing ${relPath(projectRoot, changeDir)}/`);
|
|
41
43
|
console.log("");
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
// An APPLIED change is asked only what still means something (change
|
|
46
|
+
// 0221): its pre-apply checks read the spec the apply wrote as a conflict,
|
|
47
|
+
// and a close stopped after its apply could never be rerun. The integrity
|
|
48
|
+
// checks — the set `archive` asks — still count.
|
|
49
|
+
const proposal = path.join(changeDir, "proposal.md");
|
|
50
|
+
const applied = isFile(proposal) && (listHeader(read(proposal), "Status") ?? "").toLowerCase() === "applied";
|
|
51
|
+
const results = collectAnalysis(projectRoot, changeDir)
|
|
52
|
+
.map((r) => applied && r.kind === "fail" && r.scope === "pre-apply"
|
|
53
|
+
? { ...r, kind: "info", line: c.gray("- " + stripAnsi(r.line).replace(/^✗\s*/, "") + " (already applied — a pre-apply check, not asked again)") }
|
|
54
|
+
: r);
|
|
44
55
|
for (const r of results) console.log(r.line);
|
|
45
56
|
console.log("");
|
|
46
57
|
|
|
47
58
|
const failed = results.filter((r) => r.kind === "fail").length;
|
|
48
59
|
if (failed === 0) {
|
|
49
|
-
console.log(c.green("ok") + ` ready to apply`);
|
|
60
|
+
console.log(c.green("ok") + (applied ? " already applied — resuming: the integrity checks pass" : ` ready to apply`));
|
|
50
61
|
} else {
|
|
51
62
|
console.log(c.red("fail") + ` ${failed} issue${failed === 1 ? "" : "s"}`);
|
|
52
63
|
}
|
package/src/commands/change.js
CHANGED
|
@@ -16,17 +16,18 @@ import { c } from "../lib/colors.js";
|
|
|
16
16
|
import { appendLedgerLine, archivedLine, abandonedLine } from "../lib/ledger.js";
|
|
17
17
|
import { suggest } from "../lib/suggest.js";
|
|
18
18
|
import { confirm, isInteractive } from "../lib/prompt.js";
|
|
19
|
+
import { taskCard } from "../lib/task-card.js";
|
|
19
20
|
import { EXIT } from "../lib/exit-codes.js";
|
|
20
21
|
import { ensureDoctrinaProject, refuseChangeRef } from "../lib/project.js";
|
|
21
22
|
import { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
|
|
22
|
-
import { changeNew } from "../lib/change-ops.js";
|
|
23
|
+
import { changeNew, addDeltaOps } from "../lib/change-ops.js";
|
|
23
24
|
|
|
24
|
-
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon"];
|
|
25
|
+
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "delta", "card"];
|
|
25
26
|
|
|
26
27
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
27
28
|
// adding a command never requires editing the entrypoint — the gap that
|
|
28
29
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
29
|
-
export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason"] };
|
|
30
|
+
export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason", "requirement", "criterion", "bump", "op"] };
|
|
30
31
|
|
|
31
32
|
export async function run(positional, flags) {
|
|
32
33
|
const sub = positional[0];
|
|
@@ -44,6 +45,10 @@ export async function run(positional, flags) {
|
|
|
44
45
|
return changeTick(positional.slice(1), flags);
|
|
45
46
|
case "abandon":
|
|
46
47
|
return await changeAbandon(positional.slice(1), flags);
|
|
48
|
+
case "delta":
|
|
49
|
+
return changeDelta(positional.slice(1), flags);
|
|
50
|
+
case "card":
|
|
51
|
+
return changeCard(positional.slice(1));
|
|
47
52
|
default:
|
|
48
53
|
console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
|
|
49
54
|
const guess = suggest(sub, SUBCOMMANDS);
|
|
@@ -54,6 +59,64 @@ export async function run(positional, flags) {
|
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
// `change card <id> [<n>]` (change 0222, ADR 0030): one task as an executor
|
|
63
|
+
// receives it — the task and the lines under it, the change's why, the spec
|
|
64
|
+
// cut to the task, and what to do when done. The executor does not see the
|
|
65
|
+
// planner's conversation; the card is its whole context.
|
|
66
|
+
function changeCard(args) {
|
|
67
|
+
const id = args[0];
|
|
68
|
+
if (!id) {
|
|
69
|
+
console.error(c.red("error:") + " change card requires <id> [task number]");
|
|
70
|
+
return EXIT.USAGE;
|
|
71
|
+
}
|
|
72
|
+
const refused = refuseChangeRef(id);
|
|
73
|
+
if (refused !== null) return refused;
|
|
74
|
+
const projectRoot = process.cwd();
|
|
75
|
+
ensureDoctrinaProject(projectRoot);
|
|
76
|
+
if (!isDir(path.join(projectRoot, ".doctrina", "changes", id))) {
|
|
77
|
+
console.error(c.red("error:") + ` change "${id}" not found at .doctrina/changes/${id}`);
|
|
78
|
+
return EXIT.USAGE;
|
|
79
|
+
}
|
|
80
|
+
let ordinal = null;
|
|
81
|
+
if (args[1] !== undefined) {
|
|
82
|
+
ordinal = Number(args[1]);
|
|
83
|
+
if (!Number.isInteger(ordinal) || ordinal < 1) {
|
|
84
|
+
console.error(c.red("error:") + ` task number must be a positive integer (got "${args[1]}")`);
|
|
85
|
+
return EXIT.USAGE;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const { card, reason } = taskCard(projectRoot, id, ordinal);
|
|
89
|
+
if (!card) {
|
|
90
|
+
console.log(c.gray(reason));
|
|
91
|
+
return reason.startsWith("every task") ? EXIT.OK : EXIT.PRECONDITION;
|
|
92
|
+
}
|
|
93
|
+
const L = [];
|
|
94
|
+
L.push(`# Task card — ${card.change}, task ${card.ordinal} of ${card.total}` + (card.checked ? " (already ticked)" : ""));
|
|
95
|
+
L.push("");
|
|
96
|
+
L.push(`Change: ${card.title}` + (card.delivery ? ` · delivery ${card.delivery}` : ""));
|
|
97
|
+
L.push(`Repository: ${projectRoot}`);
|
|
98
|
+
L.push("");
|
|
99
|
+
L.push("## Task");
|
|
100
|
+
L.push("");
|
|
101
|
+
L.push(card.text);
|
|
102
|
+
for (const d of card.details) L.push(`- ${d}`);
|
|
103
|
+
if (card.why) {
|
|
104
|
+
L.push("", "## Why the change exists", "", card.why);
|
|
105
|
+
}
|
|
106
|
+
for (const s of card.slices) {
|
|
107
|
+
L.push("", `## Spec — ${s.capability} (cut to this task)`, "", s.text.trim());
|
|
108
|
+
}
|
|
109
|
+
L.push("", "## When done", "");
|
|
110
|
+
L.push(card.fields.verify
|
|
111
|
+
? `1. Run the card's check: \`${card.fields.verify}\` — it must pass.`
|
|
112
|
+
: "1. Run the project's check: `doctrina verify` — it must pass.");
|
|
113
|
+
L.push(`2. Tick the box: \`doctrina change tick ${card.change} ${card.ordinal}\`.`);
|
|
114
|
+
L.push("3. Report back: the files you changed, the commands you ran and what they printed.");
|
|
115
|
+
L.push("Stay inside this card: do not open, close or archive a change, and do not edit `.doctrina/` — the planner does.");
|
|
116
|
+
for (const line of L) console.log(line);
|
|
117
|
+
return EXIT.OK;
|
|
118
|
+
}
|
|
119
|
+
|
|
57
120
|
// Enforce a lifecycle transition's preconditions from the shared gate map.
|
|
58
121
|
// One implementation, so every driver of a transition refuses — and
|
|
59
122
|
// explains, and forces — identically (C6).
|
|
@@ -151,9 +214,6 @@ function changeApply(args, flags) {
|
|
|
151
214
|
// and exit 0 — through the very analyze → apply path the docs prescribe.
|
|
152
215
|
// The preconditions now come from the shared map, so what guards a
|
|
153
216
|
// transition does not depend on which command drove it.
|
|
154
|
-
const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
|
|
155
|
-
if (!applyGate.ok) return 1;
|
|
156
|
-
|
|
157
217
|
// Applying twice is silent corruption. The ops verbs are ADDITIVE —
|
|
158
218
|
// `append-requirement` appends, `bump-version` bumps — so a second pass
|
|
159
219
|
// duplicates every requirement and criterion the delta carries and bumps
|
|
@@ -171,6 +231,12 @@ function changeApply(args, flags) {
|
|
|
171
231
|
return 0;
|
|
172
232
|
}
|
|
173
233
|
|
|
234
|
+
// The skip above comes FIRST (change 0221): the apply gate is a pre-apply
|
|
235
|
+
// question, and on an applied change its ADDED check reads the content the
|
|
236
|
+
// apply wrote as a conflict — which refused the resume of a killed close.
|
|
237
|
+
const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
|
|
238
|
+
if (!applyGate.ok) return 1;
|
|
239
|
+
|
|
174
240
|
const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
175
241
|
const date = today();
|
|
176
242
|
let errors = 0;
|
|
@@ -398,6 +464,49 @@ async function changeCheck(id, { verbose = false } = {}) {
|
|
|
398
464
|
// `tick <id> --all` checks everything (tasks.md + the proposal's
|
|
399
465
|
// ## Verification). Ticking is a claim of completion — the honest gate is
|
|
400
466
|
// still archive/verify; this only removes the mechanical friction.
|
|
467
|
+
// `change delta <id> <capability> [ops]` — write ops lines into the change's
|
|
468
|
+
// MODIFIED delta through the grammar `apply` reads (change 0205). Rendering
|
|
469
|
+
// only; lib/change-ops.js decides.
|
|
470
|
+
function changeDelta(args, flags) {
|
|
471
|
+
const [id, capability, ...rest] = args;
|
|
472
|
+
if (!id || !capability) {
|
|
473
|
+
console.error(c.red("error:") + " change delta requires <id> <capability> and at least one operation");
|
|
474
|
+
console.error(c.gray("hint: ") + "doctrina change delta <id> <capability> --requirement \"event: When <trigger>, the system shall <action>.\" --criterion \"[unverified] <signal> — verified by `<test>`\" --bump minor");
|
|
475
|
+
return EXIT.USAGE;
|
|
476
|
+
}
|
|
477
|
+
const refused = refuseChangeRef(id);
|
|
478
|
+
if (refused !== null) return refused;
|
|
479
|
+
const projectRoot = process.cwd();
|
|
480
|
+
ensureDoctrinaProject(projectRoot);
|
|
481
|
+
if (!isDir(path.join(projectRoot, ".doctrina", "changes", id))) {
|
|
482
|
+
console.error(c.red("error:") + ` change "${id}" not found at .doctrina/changes/${id}`);
|
|
483
|
+
return EXIT.USAGE;
|
|
484
|
+
}
|
|
485
|
+
// In the order a reader expects them: requirement, criterion, any raw op,
|
|
486
|
+
// and the bump last.
|
|
487
|
+
const lines = [];
|
|
488
|
+
const requirement = flagString(flags, "requirement");
|
|
489
|
+
const criterion = flagString(flags, "criterion");
|
|
490
|
+
const raw = flagString(flags, "op") ?? (rest.length > 0 ? rest.join(" ") : undefined);
|
|
491
|
+
const bump = flagString(flags, "bump");
|
|
492
|
+
if (requirement) lines.push(`append-requirement ${requirement}`);
|
|
493
|
+
if (criterion) lines.push(`append-criterion ${criterion}`);
|
|
494
|
+
if (raw) lines.push(raw);
|
|
495
|
+
if (bump) lines.push(`bump-version ${bump}`);
|
|
496
|
+
|
|
497
|
+
const r = addDeltaOps(projectRoot, id, capability, lines);
|
|
498
|
+
if (r.code !== EXIT.OK) {
|
|
499
|
+
console.error(c.red("error:") + ` ${r.error}`);
|
|
500
|
+
for (const d of r.details ?? []) console.error(` - ${d}`);
|
|
501
|
+
if (r.hint) console.error(c.gray("hint: ") + r.hint);
|
|
502
|
+
return r.code;
|
|
503
|
+
}
|
|
504
|
+
console.log(c.green(r.created ? "created" : "updated") + ` ${r.deltaRel}` +
|
|
505
|
+
c.gray(` — ${lines.length} op${lines.length === 1 ? "" : "s"} added; the block now holds ${r.block.length} (${r.block.join(", ")}), and applies cleanly`));
|
|
506
|
+
console.log(c.gray("Preview the merge: ") + c.cyan(`doctrina change check ${id} --verbose`));
|
|
507
|
+
return EXIT.OK;
|
|
508
|
+
}
|
|
509
|
+
|
|
401
510
|
function changeTick(args, flags) {
|
|
402
511
|
const id = args[0];
|
|
403
512
|
if (!id) {
|
|
@@ -768,6 +877,18 @@ Subcommands:
|
|
|
768
877
|
or every one with --all. No args = list only.
|
|
769
878
|
abandon <id> Delete an open change folder and its index entry, and
|
|
770
879
|
record the abandonment in the ledger ([--reason "..."]).
|
|
880
|
+
delta <id> <capability> [--requirement "<section>: <text>"] [--criterion "<text>"]
|
|
881
|
+
[--bump major|minor|patch] [--op "<op line>" | <op line>]
|
|
882
|
+
Write ops into the change's MODIFIED delta for the
|
|
883
|
+
capability — created from the template when absent —
|
|
884
|
+
parsed by the grammar apply reads and dry-run against
|
|
885
|
+
the spec first: nothing is written unless the whole
|
|
886
|
+
block applies. A second --bump replaces the first.
|
|
887
|
+
card <id> [<n>] Print task <n> (default: the next open one) as a
|
|
888
|
+
self-contained card for an executor: the task and the
|
|
889
|
+
lines indented under it (Files:, Requirement:, Verify:),
|
|
890
|
+
the change's Why, the spec cut to the task, and the
|
|
891
|
+
tick command. Exit 0 with a note when every task is done.
|
|
771
892
|
|
|
772
893
|
apply / archive / check accept multiple ids (batch close of a backlog); the
|
|
773
894
|
exit code is the worst per-id result.
|
|
@@ -780,9 +901,12 @@ A MODIFIED delta may carry a fenced operations block that \`apply\` executes:
|
|
|
780
901
|
append-criterion [unverified] new signal — verified by \`test/x.test.ts\`
|
|
781
902
|
append-requirement event: When <trigger>, the system shall <action>.
|
|
782
903
|
replace-requirement ubiquitous 2: The system shall <action>.
|
|
904
|
+
remove-requirement state 1: superseded by event 3
|
|
905
|
+
remove-criterion 4: proven by criterion 2 since 0.9
|
|
783
906
|
\`\`\`
|
|
784
907
|
append-* ops resolve numbering/position at apply time, so concurrent open
|
|
785
|
-
changes appending to the same spec cannot collide.
|
|
908
|
+
changes appending to the same spec cannot collide. \`change delta\` writes
|
|
909
|
+
these lines for you, one call per addition.
|
|
786
910
|
|
|
787
911
|
Options:
|
|
788
912
|
--force Overwrite existing files (where applicable)
|
package/src/commands/close.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { appendFileSync } from "node:fs";
|
|
5
|
-
import { exists, isDir, isFile, read
|
|
4
|
+
import { appendFileSync, readdirSync } from "node:fs";
|
|
5
|
+
import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
|
|
6
6
|
import { flagBool } from "../lib/args.js";
|
|
7
7
|
import { c } from "../lib/colors.js";
|
|
8
|
-
import { parseCapabilityFromDelta } from "../lib/doc-model.js";
|
|
9
8
|
import { printAdrCheckpoint, acceptedDecisionCount } from "../lib/adr-guard.js";
|
|
10
9
|
import { docsRemedy, checkDocsImpact, checkChangelogImpact } from "../lib/docs-impact.js";
|
|
11
10
|
import { collectRuntimeFindings } from "../lib/runtime.js";
|
|
12
11
|
import { derivedImplementations, implementationMismatch, summarize } from "../lib/coverage-model.js";
|
|
13
|
-
import { specHeader, dependentsOf } from "../lib/scan.js";
|
|
12
|
+
import { specHeader, dependentsOf, deltaCapabilities } from "../lib/scan.js";
|
|
14
13
|
import { sequence, sequenceLabels, stepRerun } from "../lib/gates.js";
|
|
15
14
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
16
15
|
import { refuseChangeRef } from "../lib/project.js";
|
|
16
|
+
import { unfinishedChanges } from "../lib/snapshot.js";
|
|
17
17
|
import * as analyze from "./analyze.js";
|
|
18
18
|
import * as change from "./change.js";
|
|
19
19
|
import * as verify from "./verify.js";
|
|
@@ -23,7 +23,7 @@ import * as validate from "./validate.js";
|
|
|
23
23
|
import * as indexRebuild from "./index-rebuild.js";
|
|
24
24
|
import { appendLedgerLine, docsGapLine, ledgerPath as ledgerFile } from "../lib/ledger.js";
|
|
25
25
|
import * as review from "./review.js";
|
|
26
|
-
import
|
|
26
|
+
import { skillCandidates } from "../lib/skill-candidates.js";
|
|
27
27
|
|
|
28
28
|
// One-command close (review 2026-06-27 passive-user feature #2). The work
|
|
29
29
|
// playbook lists the closing sequence — analyze → apply → runtime → verify →
|
|
@@ -65,9 +65,62 @@ export async function run(positional, flags) {
|
|
|
65
65
|
? c.green("ok") + ` all ${positional.length} changes closed`
|
|
66
66
|
: c.red("fail") + " at least one close stopped — see above");
|
|
67
67
|
}
|
|
68
|
+
reportSkills(projectRoot, positional);
|
|
69
|
+
reportUnfinished(projectRoot, positional);
|
|
68
70
|
return worst;
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
// Capture the lesson while it is fresh (operator review §4.7) — the lesson
|
|
74
|
+
// THIS close produced (change 0202). The close used to print the whole
|
|
75
|
+
// `skill suggest` listing, per id: the same thirteen candidates after every
|
|
76
|
+
// close, which a field review of 0.17 named as noise nobody read. Now a
|
|
77
|
+
// candidate born from a change just closed gets its line; every other one is
|
|
78
|
+
// a count pointing at the full listing. Once per run, batch or not.
|
|
79
|
+
// Advisory — a listing, never a gate, and a failure never un-closes anything.
|
|
80
|
+
function reportSkills(projectRoot, closing) {
|
|
81
|
+
let candidates;
|
|
82
|
+
try {
|
|
83
|
+
candidates = skillCandidates(projectRoot);
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (candidates.length === 0) return;
|
|
88
|
+
const ids = new Set(closing);
|
|
89
|
+
const fresh = candidates.filter((cand) => cand.source === "change" && ids.has(cand.id));
|
|
90
|
+
const waiting = candidates.length - fresh.length;
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log(c.gray("──── skills (advisory)"));
|
|
93
|
+
for (const cand of fresh) {
|
|
94
|
+
console.log(c.yellow(" ! ") + `${cand.id} is fix-shaped — capture the lesson while it is fresh: ` +
|
|
95
|
+
c.cyan(`doctrina skill new ${cand.slug}`));
|
|
96
|
+
}
|
|
97
|
+
if (waiting > 0) {
|
|
98
|
+
console.log(c.gray(` ${waiting} ${fresh.length > 0 ? "older " : ""}candidate${waiting === 1 ? "" : "s"} waiting — \`doctrina skill suggest\``));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The other open changes, said at the one moment someone is looking (change
|
|
103
|
+
// 0201). A field review of 0.17: change 0078 shipped, stayed open, and no
|
|
104
|
+
// close after it said a word. A change that looks finished — applied, or
|
|
105
|
+
// every task ticked — and one open a week or more get a line each; the rest
|
|
106
|
+
// are a count. Advisory: it never moves the exit code.
|
|
107
|
+
function reportUnfinished(projectRoot, closing) {
|
|
108
|
+
const others = unfinishedChanges(projectRoot, { exclude: closing });
|
|
109
|
+
if (others.length === 0) return;
|
|
110
|
+
const flagged = others.filter((ch) => ch.reason !== null);
|
|
111
|
+
console.log("");
|
|
112
|
+
console.log(c.gray(`──── other open changes (advisory) — ${others.length}`));
|
|
113
|
+
for (const ch of flagged) {
|
|
114
|
+
const age = ch.ageDays !== null ? `, opened ${ch.ageDays}d ago` : "";
|
|
115
|
+
const how = ch.finished ? ` — ${c.cyan(`doctrina close ${ch.id}`)}` : "";
|
|
116
|
+
console.log(c.yellow(" ! ") + `${ch.id}: ${ch.reason}${age}${how}`);
|
|
117
|
+
}
|
|
118
|
+
const quiet = others.length - flagged.length;
|
|
119
|
+
if (quiet > 0) {
|
|
120
|
+
console.log(c.gray(` ${quiet} more open and recent — \`doctrina next\``));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
71
124
|
async function closeOne(projectRoot, id, flags) {
|
|
72
125
|
const refused = refuseChangeRef(id);
|
|
73
126
|
if (refused !== null) return refused;
|
|
@@ -113,6 +166,8 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
113
166
|
// archive), which is rendering, not sequencing.
|
|
114
167
|
const verifyConfigured = isFile(path.join(projectRoot, ".doctrina", "verify.json"));
|
|
115
168
|
const runners = {
|
|
169
|
+
// An applied change resumes here: analyze asks it only the integrity
|
|
170
|
+
// checks (change 0221).
|
|
116
171
|
analyze: { run: () => analyze.run([id], new Map()) },
|
|
117
172
|
|
|
118
173
|
// ADR checkpoint (operator review §4.6): the playbook's "record an ADR"
|
|
@@ -138,13 +193,17 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
138
193
|
},
|
|
139
194
|
|
|
140
195
|
// The review runs against the working tree, which for a close IS the
|
|
141
|
-
// change's diff: the work is done and not yet archived.
|
|
196
|
+
// change's diff: the work is done and not yet archived. It is told which
|
|
197
|
+
// change it is reviewing (change 0200): the deltas it carries are the
|
|
198
|
+
// spec updates, merged one step later by `apply`, and code under a
|
|
199
|
+
// capability the change carries no delta for is one line, not one
|
|
200
|
+
// break per capability. Its exit code is
|
|
142
201
|
// discarded on purpose — the step is declared advisory, and an advisory
|
|
143
202
|
// step that could still move the close's result would be advisory in name
|
|
144
203
|
// only. A crash is caught by the loop and reported the same way.
|
|
145
204
|
review: {
|
|
146
205
|
run: async () => {
|
|
147
|
-
await review.run([], new Map());
|
|
206
|
+
await review.run([], new Map([["change", id]]));
|
|
148
207
|
return 0;
|
|
149
208
|
},
|
|
150
209
|
},
|
|
@@ -237,7 +296,10 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
237
296
|
// verify is conditional: skipped with a loud note when no verify.json is
|
|
238
297
|
// declared, because a project may not have wired the real gate yet.
|
|
239
298
|
verify: verifyConfigured
|
|
240
|
-
|
|
299
|
+
// --reuse (change 0203): the suite the agent just ran by hand, or the
|
|
300
|
+
// one the previous id of this batch ran, on byte-identical code, is
|
|
301
|
+
// not run a second time. Anything that differs runs everything.
|
|
302
|
+
? { run: () => verify.run([], new Map([["reuse", true]])) }
|
|
241
303
|
: { skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
|
|
242
304
|
|
|
243
305
|
coverage: {
|
|
@@ -270,7 +332,9 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
270
332
|
// one session with an empty `## [Unreleased]` behind them.
|
|
271
333
|
const log = checkChangelogImpact(projectRoot, changeDir);
|
|
272
334
|
if (log.ok) {
|
|
273
|
-
|
|
335
|
+
// One line when both answers are the same sentence (change 0202):
|
|
336
|
+
// "touches no documented surface" twice read as two checks.
|
|
337
|
+
if (log.reason !== r.reason) console.log(c.green("ok") + ` ${log.reason}`);
|
|
274
338
|
return 0;
|
|
275
339
|
}
|
|
276
340
|
console.error(c.red("error:") + ` this change ${log.reason}:`);
|
|
@@ -404,18 +468,15 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
404
468
|
}
|
|
405
469
|
}
|
|
406
470
|
|
|
407
|
-
// Capture the lesson while it is fresh (operator review §4.7): surface the
|
|
408
|
-
// fix-shaped lessons not yet written as skills, right where the operator
|
|
409
|
-
// just proved they finish what they start. Advisory — a listing, never a
|
|
410
|
-
// gate, and a suggest failure never un-closes the change.
|
|
411
|
-
console.log("");
|
|
412
|
-
console.log(c.gray("──── skills (advisory)"));
|
|
413
|
-
try {
|
|
414
|
-
await skill.run(["suggest"], new Map());
|
|
415
|
-
} catch { /* advisory only */ }
|
|
416
|
-
|
|
417
471
|
console.log("");
|
|
418
472
|
console.log(c.green(`✓ change ${id} closed`) + c.gray(closingClaim(ran, skippedSteps)));
|
|
473
|
+
// Where it went, and the sentence for the human (change 0214): the close
|
|
474
|
+
// moves the change out of every default read, and a field review found
|
|
475
|
+
// the person asking for "the doctrina work" of work already archived.
|
|
476
|
+
const archived = archivedPathOf(projectRoot, id);
|
|
477
|
+
if (archived) {
|
|
478
|
+
console.log(c.bold("Tell the human: ") + `change ${id} is closed — archived at ${archived}`);
|
|
479
|
+
}
|
|
419
480
|
console.log(c.gray("Next: ") + c.cyan("doctrina next"));
|
|
420
481
|
return 0;
|
|
421
482
|
}
|
|
@@ -448,6 +509,14 @@ function closingClaim(ran, skippedSteps) {
|
|
|
448
509
|
return `${claimed || " —"} ${skipped.length === 1 ? "Skipped" : "Skipped"}: ${skipped.join(", ")}.`;
|
|
449
510
|
}
|
|
450
511
|
|
|
512
|
+
// The archive folder a just-closed change landed in, newest first.
|
|
513
|
+
function archivedPathOf(projectRoot, id) {
|
|
514
|
+
const dir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
515
|
+
if (!isDir(dir)) return null;
|
|
516
|
+
const name = readdirSync(dir).sort().reverse().find((n) => n.endsWith(`-${id}`) && /^\d{4}-\d{2}-\d{2}-/.test(n));
|
|
517
|
+
return name ? `.doctrina/changes/archive/${name}/` : null;
|
|
518
|
+
}
|
|
519
|
+
|
|
451
520
|
function missingRunner(step) {
|
|
452
521
|
console.error(c.red("error:") +
|
|
453
522
|
` "${step.label}" is declared in the close sequence but has no runner here`);
|
|
@@ -484,14 +553,7 @@ function reportDependentCoverage(projectRoot, touched) {
|
|
|
484
553
|
}
|
|
485
554
|
|
|
486
555
|
function touchedCapabilities(projectRoot, id) {
|
|
487
|
-
|
|
488
|
-
const caps = new Set();
|
|
489
|
-
for (const deltaPath of walk(specsDir)) {
|
|
490
|
-
if (!deltaPath.endsWith("delta.md")) continue;
|
|
491
|
-
const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
|
|
492
|
-
if (cap) caps.add(cap);
|
|
493
|
-
}
|
|
494
|
-
return [...caps].sort();
|
|
556
|
+
return deltaCapabilities(path.join(projectRoot, ".doctrina", "changes", id));
|
|
495
557
|
}
|
|
496
558
|
|
|
497
559
|
// The sequence is DECLARED once, in lib/gates.js, and rendered here. It used
|
|
@@ -522,7 +584,9 @@ first failure with the exact command to rerun:
|
|
|
522
584
|
|
|
523
585
|
${SEQUENCE}
|
|
524
586
|
|
|
525
|
-
then
|
|
587
|
+
then two advisory notes, once per run: the skill candidate a just-closed
|
|
588
|
+
fix-shaped change produced (the others are a count), and the other open
|
|
589
|
+
changes that look finished or are a week old.
|
|
526
590
|
|
|
527
591
|
The coverage gate is scoped to the capabilities the change's deltas touch,
|
|
528
592
|
so a deliberately deferred spec elsewhere cannot block an unrelated close;
|