doctrina-cli 0.18.0 → 0.21.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 +3 -2
- package/package.json +1 -1
- package/src/commands/adapter.js +84 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +164 -17
- package/src/commands/clarify.js +1 -204
- package/src/commands/close.js +18 -1
- package/src/commands/context.js +81 -9
- package/src/commands/contract.js +1 -0
- package/src/commands/delivery.js +356 -0
- package/src/commands/doctor.js +21 -1
- package/src/commands/handoff.js +11 -2
- package/src/commands/index-rebuild.js +23 -0
- package/src/commands/init.js +28 -1
- package/src/commands/intake.js +60 -3
- package/src/commands/metrics.js +43 -3
- package/src/commands/next.js +41 -6
- package/src/commands/prime.js +125 -7
- package/src/commands/review.js +39 -3
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- package/src/commands/upgrade.js +33 -0
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +32 -4
- package/src/commands/work.js +126 -7
- package/src/commands/workspace.js +419 -0
- package/src/index.js +100 -66
- package/src/lib/actions.js +44 -4
- package/src/lib/adapters.js +143 -1
- package/src/lib/args.js +33 -1
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +770 -0
- package/src/lib/commands.js +52 -5
- package/src/lib/context-slice.js +149 -0
- package/src/lib/delivery.js +286 -0
- package/src/lib/docs-impact.js +36 -1
- package/src/lib/fs-ops.js +23 -0
- package/src/lib/intake-model.js +1 -1
- package/src/lib/json-out.js +9 -1
- package/src/lib/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/shell.js +45 -0
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +43 -2
- package/src/lib/task-card.js +195 -0
- package/src/lib/views.js +52 -3
- package/src/lib/work-model.js +66 -2
- package/src/lib/workspace-agents.js +306 -0
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +160 -0
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +323 -0
- package/templates/AGENTS.md.template +17 -14
- package/templates/README.md +11 -1
- 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/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
- package/templates/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +63 -0
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +44 -0
- package/templates/playbooks/work.md.template +1 -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.21.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|delta
|
|
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|tick|check|close
|
|
32
33
|
Maintain upgrade · index rebuild · hooks install · watch · metrics · completion · ci · templates list
|
|
33
34
|
```
|
|
34
35
|
|
package/package.json
CHANGED
package/src/commands/adapter.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { exists, isFile, mkdirp, pruneEmptyDirs, read, relPath, remove, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { applyAdapterMigration, planAdapterMigration } from "../lib/adapters.js";
|
|
6
|
+
import { relinkShadow, shadowRootOf } from "../lib/workspace.js";
|
|
5
7
|
import {
|
|
6
8
|
ADAPTER_STATES, adapterFiles, describeAdapter, listAdapterNames, renderAdapterFile,
|
|
9
|
+
isMergedConfig, mergeJson, unmergeJson, containsJson, holdsAnyOf,
|
|
7
10
|
} from "../lib/adapters.js";
|
|
8
11
|
import { flagBool } from "../lib/args.js";
|
|
9
12
|
import { today } from "../lib/dates.js";
|
|
@@ -11,6 +14,7 @@ import { c } from "../lib/colors.js";
|
|
|
11
14
|
import { suggest } from "../lib/suggest.js";
|
|
12
15
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
16
|
import { projectName } from "../lib/project.js";
|
|
17
|
+
import { applySkillExport } from "../lib/skill-export.js";
|
|
14
18
|
|
|
15
19
|
// `doctrina adapter` — add, remove, and inventory agent adapters (audit
|
|
16
20
|
// item C1).
|
|
@@ -109,6 +113,21 @@ function adapterAdd(projectRoot, name, cmdFlags) {
|
|
|
109
113
|
let skipped = 0;
|
|
110
114
|
for (const file of spec.files) {
|
|
111
115
|
const dest = path.join(projectRoot, file.relativePath);
|
|
116
|
+
// A shared settings file is merged, never overwritten nor skipped
|
|
117
|
+
// (change 0211): the user's own keys stay, ours are added once.
|
|
118
|
+
if (isMergedConfig(file) && exists(dest)) {
|
|
119
|
+
const merged = mergeInto(dest, renderAdapterFile(file, tokens));
|
|
120
|
+
if (merged === null) {
|
|
121
|
+
console.log(c.yellow("skip ") + ` ${file.relativePath} (not valid JSON — add the entries by hand)`);
|
|
122
|
+
skipped += 1;
|
|
123
|
+
} else if (merged) {
|
|
124
|
+
console.log(c.green("merged ") + ` ${file.relativePath}`);
|
|
125
|
+
written += 1;
|
|
126
|
+
} else {
|
|
127
|
+
console.log(c.gray("ok ") + ` ${file.relativePath} (already holds the doctrina entries)`);
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
112
131
|
if (exists(dest) && !force) {
|
|
113
132
|
console.log(c.yellow("skip ") + ` ${file.relativePath} (already present; --force overwrites)`);
|
|
114
133
|
skipped += 1;
|
|
@@ -120,10 +139,24 @@ function adapterAdd(projectRoot, name, cmdFlags) {
|
|
|
120
139
|
written += 1;
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
// An adapter installed before its files moved (change 0238): the copies
|
|
143
|
+
// the CLI wrote under the old paths go, an edited one stays and is named.
|
|
144
|
+
const filesRoot = shadowRootOf(projectRoot) ?? projectRoot;
|
|
145
|
+
const moves = planAdapterMigration(projectRoot, tokens, filesRoot);
|
|
146
|
+
const gone = applyAdapterMigration(filesRoot, moves);
|
|
147
|
+
relinkShadow(projectRoot, gone);
|
|
148
|
+
for (const g of gone) console.log(c.green("removed") + ` ${g.rel} (replaced by its skill)`);
|
|
149
|
+
for (const rel of moves.kept) console.log(c.yellow("kept ") + ` ${rel} (edited since install — Cursor now reads the skill; remove it once your edits are moved)`);
|
|
123
150
|
console.log("");
|
|
124
151
|
console.log(`${c.bold("Adapter " + name)} — ${written} file${written === 1 ? "" : "s"} written` +
|
|
125
152
|
(skipped > 0 ? `, ${skipped} left alone` : "") +
|
|
126
153
|
(spec.source === "project" ? c.gray(" (from .doctrina/templates/adapters/)") : ""));
|
|
154
|
+
// The project's skills follow the agent in (change 0212): with the claude
|
|
155
|
+
// adapter, `.claude/skills/` becomes an export root.
|
|
156
|
+
const exported = applySkillExport(projectRoot);
|
|
157
|
+
if (exported.writes.length > 0) {
|
|
158
|
+
console.log(c.green("export ") + ` ${exported.writes.length} skill file${exported.writes.length === 1 ? "" : "s"} where the agent discovers skills`);
|
|
159
|
+
}
|
|
127
160
|
console.log(c.gray("Additive only: AGENTS.md and .doctrina/product.md were not touched."));
|
|
128
161
|
return 0;
|
|
129
162
|
}
|
|
@@ -156,6 +189,29 @@ function adapterRemove(projectRoot, name, cmdFlags) {
|
|
|
156
189
|
for (const file of spec.files) {
|
|
157
190
|
const dest = path.join(projectRoot, file.relativePath);
|
|
158
191
|
if (!isFile(dest)) continue;
|
|
192
|
+
// A shared settings file loses only the entries this adapter added;
|
|
193
|
+
// the file goes only when nothing of the user's is left in it.
|
|
194
|
+
if (isMergedConfig(file)) {
|
|
195
|
+
const left = unmergeFrom(dest, renderAdapterFile(file, tokens));
|
|
196
|
+
if (left === undefined) {
|
|
197
|
+
console.log(c.yellow("kept ") + ` ${file.relativePath} (not valid JSON — remove the doctrina entries by hand)`);
|
|
198
|
+
kept += 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (left === UNTOUCHED) {
|
|
202
|
+
console.log(c.gray("kept ") + ` ${file.relativePath} (holds no doctrina entries — yours)`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (left !== null) {
|
|
206
|
+
console.log(c.green("updated") + ` ${file.relativePath} (doctrina entries removed; yours kept)`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
remove(dest);
|
|
210
|
+
console.log(c.green("removed") + ` ${file.relativePath}`);
|
|
211
|
+
removed += 1;
|
|
212
|
+
vacated.add(path.dirname(dest));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
159
215
|
const pristine = normalise(read(dest)) === normalise(renderAdapterFile(file, tokens));
|
|
160
216
|
if (!pristine && !force) {
|
|
161
217
|
console.log(c.yellow("kept ") + ` ${file.relativePath} (edited since install; --force removes it)`);
|
|
@@ -191,6 +247,34 @@ function adapterRemove(projectRoot, name, cmdFlags) {
|
|
|
191
247
|
return 0;
|
|
192
248
|
}
|
|
193
249
|
|
|
250
|
+
// Merge the rendered template into an existing JSON file. Returns true when
|
|
251
|
+
// the file changed, false when it already held every entry, null when the
|
|
252
|
+
// file is not JSON this command can safely rewrite.
|
|
253
|
+
function mergeInto(dest, rendered) {
|
|
254
|
+
let theirs;
|
|
255
|
+
try { theirs = JSON.parse(read(dest)); } catch { return null; }
|
|
256
|
+
const ours = JSON.parse(rendered);
|
|
257
|
+
if (containsJson(theirs, ours)) return false;
|
|
258
|
+
write(dest, JSON.stringify(mergeJson(theirs, ours), null, 2) + "\n", { force: true });
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const UNTOUCHED = Symbol("untouched");
|
|
263
|
+
|
|
264
|
+
// Take the rendered template's entries back out of a JSON file. Returns what
|
|
265
|
+
// is left (written back), null when nothing is (the caller removes the
|
|
266
|
+
// file), undefined when the file is not JSON.
|
|
267
|
+
function unmergeFrom(dest, rendered) {
|
|
268
|
+
let theirs;
|
|
269
|
+
try { theirs = JSON.parse(read(dest)); } catch { return undefined; }
|
|
270
|
+
// A file holding none of our entries was never ours, or was rewritten by
|
|
271
|
+
// the user since: it is theirs, and stays as it is.
|
|
272
|
+
if (!holdsAnyOf(theirs, JSON.parse(rendered))) return UNTOUCHED;
|
|
273
|
+
const left = unmergeJson(theirs, JSON.parse(rendered));
|
|
274
|
+
if (left !== null) write(dest, JSON.stringify(left, null, 2) + "\n", { force: true });
|
|
275
|
+
return left;
|
|
276
|
+
}
|
|
277
|
+
|
|
194
278
|
function normalise(text) {
|
|
195
279
|
return text.replace(/\r\n/g, "\n").trim();
|
|
196
280
|
}
|
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
|
@@ -2,26 +2,28 @@
|
|
|
2
2
|
import { parseChecklist, getSection, getHeader, parseChangeTitle } from "../lib/doc-model.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
|
-
import { exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { eolOf, exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, withEol, write } from "../lib/fs-ops.js";
|
|
6
6
|
import { diffLines, formatUnified } from "../lib/diff.js";
|
|
7
7
|
import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
|
|
8
8
|
import * as idx from "../lib/index-json.js";
|
|
9
|
-
import { archivedChangeEntry, deriveIndex } from "../lib/scan.js";
|
|
9
|
+
import { archivedChangeEntry, deriveIndex, listHeader } from "../lib/scan.js";
|
|
10
10
|
import { extractOps, applyOps } from "../lib/spec-ops.js";
|
|
11
11
|
import { printAdrCheckpoint } from "../lib/adr-guard.js";
|
|
12
12
|
import { GATES, TRANSITIONS, checkTransition, recordForcedGap } from "../lib/gates.js";
|
|
13
13
|
import { today } from "../lib/dates.js";
|
|
14
|
-
import { flagBool, flagString } from "../lib/args.js";
|
|
14
|
+
import { flagAll, flagBool, flagString } from "../lib/args.js";
|
|
15
15
|
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
23
|
import { changeNew, addDeltaOps } from "../lib/change-ops.js";
|
|
24
|
+
import { scanText } from "../lib/clarify-scan.js";
|
|
23
25
|
|
|
24
|
-
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "delta"];
|
|
26
|
+
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "delta", "card"];
|
|
25
27
|
|
|
26
28
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
27
29
|
// adding a command never requires editing the entrypoint — the gap that
|
|
@@ -46,6 +48,8 @@ export async function run(positional, flags) {
|
|
|
46
48
|
return await changeAbandon(positional.slice(1), flags);
|
|
47
49
|
case "delta":
|
|
48
50
|
return changeDelta(positional.slice(1), flags);
|
|
51
|
+
case "card":
|
|
52
|
+
return changeCard(positional.slice(1));
|
|
49
53
|
default:
|
|
50
54
|
console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
|
|
51
55
|
const guess = suggest(sub, SUBCOMMANDS);
|
|
@@ -56,6 +60,82 @@ export async function run(positional, flags) {
|
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
// `change card <id> [<n>]` (change 0222, ADR 0030): one task as an executor
|
|
64
|
+
// receives it — the task and the lines under it, the change's why, the spec
|
|
65
|
+
// cut to the task, and what to do when done. The executor does not see the
|
|
66
|
+
// planner's conversation; the card is its whole context.
|
|
67
|
+
function changeCard(args) {
|
|
68
|
+
const id = args[0];
|
|
69
|
+
if (!id) {
|
|
70
|
+
console.error(c.red("error:") + " change card requires <id> [task number]");
|
|
71
|
+
return EXIT.USAGE;
|
|
72
|
+
}
|
|
73
|
+
const refused = refuseChangeRef(id);
|
|
74
|
+
if (refused !== null) return refused;
|
|
75
|
+
const projectRoot = process.cwd();
|
|
76
|
+
ensureDoctrinaProject(projectRoot);
|
|
77
|
+
if (!isDir(path.join(projectRoot, ".doctrina", "changes", id))) {
|
|
78
|
+
console.error(c.red("error:") + ` change "${id}" not found at .doctrina/changes/${id}`);
|
|
79
|
+
return EXIT.USAGE;
|
|
80
|
+
}
|
|
81
|
+
let ordinal = null;
|
|
82
|
+
if (args[1] !== undefined) {
|
|
83
|
+
ordinal = Number(args[1]);
|
|
84
|
+
if (!Number.isInteger(ordinal) || ordinal < 1) {
|
|
85
|
+
console.error(c.red("error:") + ` task number must be a positive integer (got "${args[1]}")`);
|
|
86
|
+
return EXIT.USAGE;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const { card, reason } = taskCard(projectRoot, id, ordinal);
|
|
90
|
+
if (!card) {
|
|
91
|
+
console.log(c.gray(reason));
|
|
92
|
+
return reason.startsWith("every task") ? EXIT.OK : EXIT.PRECONDITION;
|
|
93
|
+
}
|
|
94
|
+
const L = [];
|
|
95
|
+
L.push(`# Task card — ${card.change}, task ${card.ordinal} of ${card.total}` + (card.checked ? " (already ticked)" : ""));
|
|
96
|
+
L.push("");
|
|
97
|
+
L.push(`Change: ${card.title}` + (card.delivery ? ` · delivery ${card.delivery}` : ""));
|
|
98
|
+
L.push(`Repository: ${projectRoot}`);
|
|
99
|
+
L.push("");
|
|
100
|
+
L.push("## Task");
|
|
101
|
+
L.push("");
|
|
102
|
+
L.push(card.text);
|
|
103
|
+
for (const d of card.details) L.push(`- ${d}`);
|
|
104
|
+
if (card.why) {
|
|
105
|
+
L.push("", "## Why the change exists", "", card.why);
|
|
106
|
+
}
|
|
107
|
+
if (card.story) {
|
|
108
|
+
L.push("", `## The story (delivery ${card.delivery})`, "", card.story);
|
|
109
|
+
}
|
|
110
|
+
// What the change does to the spec — the requirement the card implements
|
|
111
|
+
// is here, not in the spec as it stands (change 0231).
|
|
112
|
+
if (card.changes.length > 0) {
|
|
113
|
+
L.push("", "## What this change does to the spec", "");
|
|
114
|
+
for (const ch of card.changes) L.push(`- ${ch.capability} ${ch.line}`);
|
|
115
|
+
}
|
|
116
|
+
for (const s of card.slices) {
|
|
117
|
+
L.push("", `## Spec — ${s.capability} as it stands (${s.whole ? "whole: nothing in it matched the task" : "cut to this task"})`, "", s.text.trim());
|
|
118
|
+
}
|
|
119
|
+
if (card.decisions.list.length > 0) {
|
|
120
|
+
L.push("", "## Decisions that bind it", "");
|
|
121
|
+
for (const d of card.decisions.list) {
|
|
122
|
+
L.push(`- ADR ${d.id} — ${d.title} (\`doctrina show ${d.id}\`)`);
|
|
123
|
+
if (d.decision) L.push(` ${d.decision}`);
|
|
124
|
+
}
|
|
125
|
+
if (card.decisions.more > 0) L.push(`- … and ${card.decisions.more} more accepted global decisions — \`doctrina prime --rules\``);
|
|
126
|
+
L.push("", "Where the story or the task contradicts one of these, stop and report it: the decision stands until the planner supersedes it.");
|
|
127
|
+
}
|
|
128
|
+
L.push("", "## When done", "");
|
|
129
|
+
L.push(card.fields.verify
|
|
130
|
+
? `1. Run the card's check: \`${card.fields.verify}\` — it must pass.`
|
|
131
|
+
: "1. Run the project's check: `doctrina verify` — it must pass.");
|
|
132
|
+
L.push(`2. Tick the box: \`doctrina change tick ${card.change} ${card.ordinal}\`.`);
|
|
133
|
+
L.push("3. Report back: the files you changed, the commands you ran and what they printed.");
|
|
134
|
+
L.push("Stay inside this card: do not open, close or archive a change, and do not edit `.doctrina/` — the planner does.");
|
|
135
|
+
for (const line of L) console.log(line);
|
|
136
|
+
return EXIT.OK;
|
|
137
|
+
}
|
|
138
|
+
|
|
59
139
|
// Enforce a lifecycle transition's preconditions from the shared gate map.
|
|
60
140
|
// One implementation, so every driver of a transition refuses — and
|
|
61
141
|
// explains, and forces — identically (C6).
|
|
@@ -153,9 +233,6 @@ function changeApply(args, flags) {
|
|
|
153
233
|
// and exit 0 — through the very analyze → apply path the docs prescribe.
|
|
154
234
|
// The preconditions now come from the shared map, so what guards a
|
|
155
235
|
// transition does not depend on which command drove it.
|
|
156
|
-
const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
|
|
157
|
-
if (!applyGate.ok) return 1;
|
|
158
|
-
|
|
159
236
|
// Applying twice is silent corruption. The ops verbs are ADDITIVE —
|
|
160
237
|
// `append-requirement` appends, `bump-version` bumps — so a second pass
|
|
161
238
|
// duplicates every requirement and criterion the delta carries and bumps
|
|
@@ -173,6 +250,12 @@ function changeApply(args, flags) {
|
|
|
173
250
|
return 0;
|
|
174
251
|
}
|
|
175
252
|
|
|
253
|
+
// The skip above comes FIRST (change 0221): the apply gate is a pre-apply
|
|
254
|
+
// question, and on an applied change its ADDED check reads the content the
|
|
255
|
+
// apply wrote as a conflict — which refused the resume of a killed close.
|
|
256
|
+
const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
|
|
257
|
+
if (!applyGate.ok) return 1;
|
|
258
|
+
|
|
176
259
|
const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
177
260
|
const date = today();
|
|
178
261
|
let errors = 0;
|
|
@@ -353,6 +436,13 @@ async function changeCheck(id, { verbose = false } = {}) {
|
|
|
353
436
|
opsFindings += 1;
|
|
354
437
|
} else {
|
|
355
438
|
console.log(c.green("✓ ") + `${rel}: ${result.applied.length} op${result.applied.length === 1 ? "" : "s"} would apply cleanly`);
|
|
439
|
+
// What clarify will say about the spec once this lands (change 0232):
|
|
440
|
+
// the smells the merged spec has and the current one does not.
|
|
441
|
+
// Advisory — clarify is not a close gate — but a project that runs
|
|
442
|
+
// `clarify --all` in its build learned about the word mid-close.
|
|
443
|
+
for (const smell of newSmells(read(targetPath), result.text, projectRoot)) {
|
|
444
|
+
console.log(c.yellow("! ") + `${rel}: writes ${smell.rule} "${smell.token}" into ${capability}'s spec — ${smell.hint} (\`clarify\` flags it after apply)`);
|
|
445
|
+
}
|
|
356
446
|
}
|
|
357
447
|
}
|
|
358
448
|
if (deltaFiles.length === 0) console.log(c.gray("- no spec deltas"));
|
|
@@ -420,14 +510,17 @@ function changeDelta(args, flags) {
|
|
|
420
510
|
}
|
|
421
511
|
// In the order a reader expects them: requirement, criterion, any raw op,
|
|
422
512
|
// and the bump last.
|
|
513
|
+
// `--requirement`, `--criterion` and `--op` repeat (change 0228): each
|
|
514
|
+
// occurrence is one op, in the order given. The parser keeps the last
|
|
515
|
+
// value of a repeated flag, so reading them with flagString wrote one op
|
|
516
|
+
// and reported success for a command line that asked for three.
|
|
423
517
|
const lines = [];
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
const raw = flagString(flags, "op") ?? (rest.length > 0 ? rest.join(" ") : undefined);
|
|
518
|
+
const raw = flagAll(flags, "op");
|
|
519
|
+
if (raw.length === 0 && rest.length > 0) raw.push(rest.join(" "));
|
|
427
520
|
const bump = flagString(flags, "bump");
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
521
|
+
for (const r of flagAll(flags, "requirement")) lines.push(`append-requirement ${r}`);
|
|
522
|
+
for (const r of flagAll(flags, "criterion")) lines.push(`append-criterion ${r}`);
|
|
523
|
+
lines.push(...raw);
|
|
431
524
|
if (bump) lines.push(`bump-version ${bump}`);
|
|
432
525
|
|
|
433
526
|
const r = addDeltaOps(projectRoot, id, capability, lines);
|
|
@@ -550,11 +643,12 @@ function changeTick(args, flags) {
|
|
|
550
643
|
byPath.get(box.file.path).push(box);
|
|
551
644
|
}
|
|
552
645
|
for (const [filePath, hits] of byPath) {
|
|
553
|
-
const
|
|
646
|
+
const before = read(filePath);
|
|
647
|
+
const lines = before.split(/\r?\n/);
|
|
554
648
|
for (const box of hits) {
|
|
555
649
|
lines[box.lineIndex] = lines[box.lineIndex].replace(/\[ \]/, "[x]");
|
|
556
650
|
}
|
|
557
|
-
write(filePath, lines.join("\n"), { force: true });
|
|
651
|
+
write(filePath, withEol(lines.join("\n"), eolOf(before)), { force: true });
|
|
558
652
|
}
|
|
559
653
|
console.log(c.green("ticked ") + `${picked.length} box${picked.length === 1 ? "" : "es"} in ${byPath.size} file${byPath.size === 1 ? "" : "s"}`);
|
|
560
654
|
return 0;
|
|
@@ -707,13 +801,22 @@ async function changeAbandon(args, flags) {
|
|
|
707
801
|
|
|
708
802
|
const reason = flagString(flags, "reason") ?? "";
|
|
709
803
|
const date = today();
|
|
804
|
+
// The delivery it belonged to stays on record (third audit, change 0234):
|
|
805
|
+
// the proposal that carried it is deleted, and the delivery's status went
|
|
806
|
+
// back to "no change yet", as if the planner had never decided anything.
|
|
807
|
+
const proposalFile = path.join(changeDir, "proposal.md");
|
|
808
|
+
const delivery = isFile(proposalFile) ? (listHeader(read(proposalFile), "Delivery") ?? "").trim() : "";
|
|
809
|
+
const recorded = delivery ? `${reason ? `${reason} ` : ""}(delivery ${delivery})` : reason;
|
|
710
810
|
|
|
711
811
|
remove(changeDir);
|
|
712
812
|
console.log(c.green("removed") + ` ${relPath(projectRoot, changeDir)}`);
|
|
713
813
|
|
|
714
814
|
// Ledger line so the abandonment is part of the visible history.
|
|
715
|
-
const ledgerFile = appendLedgerLine(projectRoot, abandonedLine(id,
|
|
815
|
+
const ledgerFile = appendLedgerLine(projectRoot, abandonedLine(id, recorded, date));
|
|
716
816
|
console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerFile)}`);
|
|
817
|
+
if (delivery) {
|
|
818
|
+
console.log(c.gray("hint: ") + `it was this repository's part of delivery ${delivery} — from the workspace, re-route the delivery without this repository (\`doctrina delivery route ${delivery} <the others>\`) or open a new change here with \`doctrina work "<title>" --delivery ${delivery}\``);
|
|
819
|
+
}
|
|
717
820
|
|
|
718
821
|
// Drop the change entry; rebuild from the tree so nothing drifts.
|
|
719
822
|
const current = idx.load(projectRoot);
|
|
@@ -766,6 +869,26 @@ export function printDeltaPreview(projectRoot, changeDir, deltaFiles) {
|
|
|
766
869
|
continue;
|
|
767
870
|
}
|
|
768
871
|
console.log(c.yellow("MODIFIED") + ` ${targetRel}`);
|
|
872
|
+
// A delta with an ops block is previewed as the spec it produces (change
|
|
873
|
+
// 0228): the ops run in memory, as apply runs them, and the diff is the
|
|
874
|
+
// spec before against the spec after. Diffing the spec against the
|
|
875
|
+
// delta FILE printed every line of the spec as a '-' — three hundred
|
|
876
|
+
// lines of noise around the four that change.
|
|
877
|
+
const ops = extractOps(text);
|
|
878
|
+
if (ops.length > 0) {
|
|
879
|
+
const current = read(targetPath);
|
|
880
|
+
const after = applyOps(current, ops);
|
|
881
|
+
if (after.errors.length > 0) {
|
|
882
|
+
for (const e of after.errors) console.error(c.red("error:") + ` ${rel}: ${e}`);
|
|
883
|
+
errors += 1;
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
console.log(formatUnified(diffLines(current, after.text), {
|
|
887
|
+
aLabel: targetRel,
|
|
888
|
+
bLabel: `${targetRel} (after apply)`,
|
|
889
|
+
}));
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
769
892
|
console.log(c.gray("note: the delta body is a fragment to merge — '-' lines are current"));
|
|
770
893
|
console.log(c.gray(" spec content absent from the delta, not necessarily removals."));
|
|
771
894
|
const out = formatUnified(diffLines(read(targetPath), body), {
|
|
@@ -786,6 +909,22 @@ function extractDeltaBody(text) {
|
|
|
786
909
|
}
|
|
787
910
|
|
|
788
911
|
|
|
912
|
+
// The smells `after` has that `before` does not, matched by rule, token and
|
|
913
|
+
// the line's own text — line numbers shift when a delta inserts a line.
|
|
914
|
+
function newSmells(before, after, projectRoot) {
|
|
915
|
+
const key = (/** @type {string} */ text, /** @type {{ line: number, rule: string, token: string }} */ sm) =>
|
|
916
|
+
`${sm.rule}|${sm.token.toLowerCase()}|${text.split("\n")[sm.line - 1] ?? ""}`;
|
|
917
|
+
const seen = new Map();
|
|
918
|
+
for (const sm of scanText(before, projectRoot)) seen.set(key(before, sm), (seen.get(key(before, sm)) ?? 0) + 1);
|
|
919
|
+
const out = [];
|
|
920
|
+
for (const sm of scanText(after, projectRoot)) {
|
|
921
|
+
const k = key(after, sm);
|
|
922
|
+
if ((seen.get(k) ?? 0) > 0) seen.set(k, seen.get(k) - 1);
|
|
923
|
+
else out.push(sm);
|
|
924
|
+
}
|
|
925
|
+
return out;
|
|
926
|
+
}
|
|
927
|
+
|
|
789
928
|
export const help = `
|
|
790
929
|
Usage: doctrina change <subcommand> [args]
|
|
791
930
|
|
|
@@ -808,6 +947,7 @@ Subcommands:
|
|
|
808
947
|
its target + the archive gate preview + an advisory
|
|
809
948
|
list of accepted ADRs citing the touched capabilities.
|
|
810
949
|
Everything close would refuse, listed BEFORE it runs.
|
|
950
|
+
--verbose also prints each delta as it would land.
|
|
811
951
|
tick <id> [n... |--all] List the unchecked boxes (tasks.md + proposal
|
|
812
952
|
## Verification) with ordinals; tick the given ones,
|
|
813
953
|
or every one with --all. No args = list only.
|
|
@@ -819,7 +959,14 @@ Subcommands:
|
|
|
819
959
|
capability — created from the template when absent —
|
|
820
960
|
parsed by the grammar apply reads and dry-run against
|
|
821
961
|
the spec first: nothing is written unless the whole
|
|
822
|
-
block applies.
|
|
962
|
+
block applies. --requirement, --criterion and --op
|
|
963
|
+
repeat, one op each, in order; a second --bump
|
|
964
|
+
replaces the first.
|
|
965
|
+
card <id> [<n>] Print task <n> (default: the next open one) as a
|
|
966
|
+
self-contained card for an executor: the task and the
|
|
967
|
+
lines indented under it (Files:, Requirement:, Verify:),
|
|
968
|
+
the change's Why, the spec cut to the task, and the
|
|
969
|
+
tick command. Exit 0 with a note when every task is done.
|
|
823
970
|
|
|
824
971
|
apply / archive / check accept multiple ids (batch close of a backlog); the
|
|
825
972
|
exit code is the worst per-id result.
|