doctrina-cli 0.18.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 +3 -2
- package/package.json +1 -1
- package/src/commands/adapter.js +74 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +73 -4
- package/src/commands/close.js +18 -1
- 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/prime.js +76 -7
- package/src/commands/upgrade.js +15 -0
- package/src/commands/work.js +86 -2
- package/src/commands/workspace.js +409 -0
- package/src/index.js +5 -7
- package/src/lib/actions.js +1 -1
- package/src/lib/adapters.js +80 -0
- package/src/lib/code-inventory.js +297 -0
- package/src/lib/commands.js +44 -5
- package/src/lib/context-slice.js +118 -0
- package/src/lib/delivery.js +208 -0
- package/src/lib/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +39 -2
- package/src/lib/task-card.js +102 -0
- package/src/lib/views.js +51 -2
- package/src/lib/work-model.js +58 -0
- 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 +9 -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/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +40 -0
- package/templates/playbooks/delivery.md.template +38 -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.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|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|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
|
@@ -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,12 +16,13 @@ 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";
|
|
23
24
|
|
|
24
|
-
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "delta"];
|
|
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
|
|
@@ -46,6 +47,8 @@ export async function run(positional, flags) {
|
|
|
46
47
|
return await changeAbandon(positional.slice(1), flags);
|
|
47
48
|
case "delta":
|
|
48
49
|
return changeDelta(positional.slice(1), flags);
|
|
50
|
+
case "card":
|
|
51
|
+
return changeCard(positional.slice(1));
|
|
49
52
|
default:
|
|
50
53
|
console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
|
|
51
54
|
const guess = suggest(sub, SUBCOMMANDS);
|
|
@@ -56,6 +59,64 @@ export async function run(positional, flags) {
|
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
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
|
+
|
|
59
120
|
// Enforce a lifecycle transition's preconditions from the shared gate map.
|
|
60
121
|
// One implementation, so every driver of a transition refuses — and
|
|
61
122
|
// explains, and forces — identically (C6).
|
|
@@ -153,9 +214,6 @@ function changeApply(args, flags) {
|
|
|
153
214
|
// and exit 0 — through the very analyze → apply path the docs prescribe.
|
|
154
215
|
// The preconditions now come from the shared map, so what guards a
|
|
155
216
|
// 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
217
|
// Applying twice is silent corruption. The ops verbs are ADDITIVE —
|
|
160
218
|
// `append-requirement` appends, `bump-version` bumps — so a second pass
|
|
161
219
|
// duplicates every requirement and criterion the delta carries and bumps
|
|
@@ -173,6 +231,12 @@ function changeApply(args, flags) {
|
|
|
173
231
|
return 0;
|
|
174
232
|
}
|
|
175
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
|
+
|
|
176
240
|
const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
177
241
|
const date = today();
|
|
178
242
|
let errors = 0;
|
|
@@ -820,6 +884,11 @@ Subcommands:
|
|
|
820
884
|
parsed by the grammar apply reads and dry-run against
|
|
821
885
|
the spec first: nothing is written unless the whole
|
|
822
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.
|
|
823
892
|
|
|
824
893
|
apply / archive / check accept multiple ids (batch close of a backlog); the
|
|
825
894
|
exit code is the worst per-id result.
|
package/src/commands/close.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { appendFileSync } from "node:fs";
|
|
4
|
+
import { appendFileSync, readdirSync } from "node:fs";
|
|
5
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";
|
|
@@ -166,6 +166,8 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
166
166
|
// archive), which is rendering, not sequencing.
|
|
167
167
|
const verifyConfigured = isFile(path.join(projectRoot, ".doctrina", "verify.json"));
|
|
168
168
|
const runners = {
|
|
169
|
+
// An applied change resumes here: analyze asks it only the integrity
|
|
170
|
+
// checks (change 0221).
|
|
169
171
|
analyze: { run: () => analyze.run([id], new Map()) },
|
|
170
172
|
|
|
171
173
|
// ADR checkpoint (operator review §4.6): the playbook's "record an ADR"
|
|
@@ -468,6 +470,13 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
468
470
|
|
|
469
471
|
console.log("");
|
|
470
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
|
+
}
|
|
471
480
|
console.log(c.gray("Next: ") + c.cyan("doctrina next"));
|
|
472
481
|
return 0;
|
|
473
482
|
}
|
|
@@ -500,6 +509,14 @@ function closingClaim(ran, skippedSteps) {
|
|
|
500
509
|
return `${claimed || " —"} ${skipped.length === 1 ? "Skipped" : "Skipped"}: ${skipped.join(", ")}.`;
|
|
501
510
|
}
|
|
502
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
|
+
|
|
503
520
|
function missingRunner(step) {
|
|
504
521
|
console.error(c.red("error:") +
|
|
505
522
|
` "${step.label}" is declared in the close sequence but has no runner here`);
|
package/src/commands/context.js
CHANGED
|
@@ -14,6 +14,7 @@ import { GIT_STATE, historyState, changedFiles } from "../lib/git.js";
|
|
|
14
14
|
import { terms as queryTerms, relevance } from "../lib/lexicon.js";
|
|
15
15
|
import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
|
|
16
16
|
import * as idx from "../lib/index-json.js";
|
|
17
|
+
import { adrDigest, sliceSpec } from "../lib/context-slice.js";
|
|
17
18
|
|
|
18
19
|
// Materialise the AGENTS.md read order as a command: print the exact
|
|
19
20
|
// context pack for a task, in the order an agent should read it. This is
|
|
@@ -72,11 +73,14 @@ export const TIER = { CORE: 0, SPEC: 1, CHANGE: 2, DEPENDENCY: 3, DECISION: 4 };
|
|
|
72
73
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
73
74
|
// adding a command never requires editing the entrypoint — the gap that
|
|
74
75
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
75
|
-
export const flags = { boolean: ["json", "concat"], string: ["budget", "diff", "for"] };
|
|
76
|
+
export const flags = { boolean: ["json", "concat", "full"], string: ["budget", "diff", "for"] };
|
|
76
77
|
|
|
77
78
|
export async function run(positional, cmdFlags) {
|
|
78
79
|
const capability = positional[0] ?? null;
|
|
79
80
|
const concat = flagBool(cmdFlags, "concat", false);
|
|
81
|
+
// --full: the whole text of everything, as before change 0216 — AGENTS.md
|
|
82
|
+
// repeated, every spec and ADR whole. The lean pack is the default.
|
|
83
|
+
const full = flagBool(cmdFlags, "full", false);
|
|
80
84
|
const budgetRaw = flagString(cmdFlags, "budget");
|
|
81
85
|
const diffRef = flagString(cmdFlags, "diff");
|
|
82
86
|
const query = flagString(cmdFlags, "for");
|
|
@@ -162,6 +166,8 @@ export async function run(positional, cmdFlags) {
|
|
|
162
166
|
tier: TIER.CORE,
|
|
163
167
|
rank: [],
|
|
164
168
|
degraded: false,
|
|
169
|
+
/** @type {string | null} */ slice: null,
|
|
170
|
+
/** @type {string | null} */ digest: null,
|
|
165
171
|
...extra,
|
|
166
172
|
};
|
|
167
173
|
pack.push(item);
|
|
@@ -170,7 +176,10 @@ export async function run(positional, cmdFlags) {
|
|
|
170
176
|
|
|
171
177
|
// 1-2. Root rules and product truth — always first. Displayed paths are
|
|
172
178
|
// POSIX strings (path.join would emit backslashes on Windows).
|
|
173
|
-
|
|
179
|
+
// AGENTS.md is not repeated by default (change 0216): every agent this
|
|
180
|
+
// pack is for loads it already — natively, or through its adapter — and
|
|
181
|
+
// it cost ~2.5k tokens per pack to say it twice.
|
|
182
|
+
if (wanted("AGENTS.md") && full) pushFile("AGENTS.md", "root rules");
|
|
174
183
|
if (wanted(".doctrina/product.md")) pushFile(".doctrina/product.md", "product truth");
|
|
175
184
|
|
|
176
185
|
// 3. The capability spec(s) — the current truth. With a named capability,
|
|
@@ -241,6 +250,21 @@ export async function run(positional, cmdFlags) {
|
|
|
241
250
|
}
|
|
242
251
|
}
|
|
243
252
|
|
|
253
|
+
// A spec read for a task — the task's own, and the ones it depends on — is
|
|
254
|
+
// cut to what the task is about (change
|
|
255
|
+
// 0216): headers, Purpose, and the requirements and criteria the query
|
|
256
|
+
// matches, the rest named by count. No query, or nothing matched: whole.
|
|
257
|
+
if (!full && terms.length > 0) {
|
|
258
|
+
for (const item of pack) {
|
|
259
|
+
if ((item.tier !== TIER.CORE && item.tier !== TIER.DEPENDENCY) || !item.rel.startsWith(".doctrina/specs/")) continue;
|
|
260
|
+
const sliced = sliceSpec(read(path.join(projectRoot, item.rel)), terms, item.rel);
|
|
261
|
+
if (!sliced) continue;
|
|
262
|
+
item.slice = sliced.text;
|
|
263
|
+
item.tokens = estimateTokens(sliced.text);
|
|
264
|
+
item.note = `${item.note} — sliced: ${sliced.kept} of ${sliced.total} items match`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
244
268
|
// 4. Open changes (their proposal, tasks, and deltas) — never
|
|
245
269
|
// diff-filtered: work in flight is the one thing a resuming agent
|
|
246
270
|
// cannot reconstruct from anywhere else, so it is always present.
|
|
@@ -347,7 +371,7 @@ export async function run(positional, cmdFlags) {
|
|
|
347
371
|
|
|
348
372
|
const id = path.basename(f).match(/^(\d{4})-/)?.[1] ?? "0000";
|
|
349
373
|
const title = getTitle(text) ?? path.basename(f, ".md");
|
|
350
|
-
pushFile(rel, global ? "accepted ADR" : `accepted ADR (${scope.join(", ")})`, {
|
|
374
|
+
const adrItem = pushFile(rel, global ? "accepted ADR" : `accepted ADR (${scope.join(", ")})`, {
|
|
351
375
|
tier: TIER.DECISION,
|
|
352
376
|
// Best-first, deterministic: an ADR that NAMES this capability outranks
|
|
353
377
|
// an unscoped one — which is DECLARED to belong in every pack — which in
|
|
@@ -360,6 +384,13 @@ export async function run(positional, cmdFlags) {
|
|
|
360
384
|
title,
|
|
361
385
|
summary: adrSummary(text),
|
|
362
386
|
});
|
|
387
|
+
// A decision as a digest by default (change 0216): its title and the
|
|
388
|
+
// opening of its Decision — what tells a reader whether it binds the
|
|
389
|
+
// task. Sixteen whole ADRs were ~22k tokens of the `gates` pack.
|
|
390
|
+
if (adrItem && !full) {
|
|
391
|
+
adrItem.digest = adrDigest(text, title, id);
|
|
392
|
+
adrItem.tokens = estimateTokens(adrItem.digest);
|
|
393
|
+
}
|
|
363
394
|
}
|
|
364
395
|
|
|
365
396
|
// 6. Skills are on-demand: list name + description + trigger, never the
|
|
@@ -406,7 +437,8 @@ export async function run(positional, cmdFlags) {
|
|
|
406
437
|
for (const item of kept) {
|
|
407
438
|
console.log(`===== ${item.rel} (${item.note}) =====`);
|
|
408
439
|
console.log("");
|
|
409
|
-
console.log(item.degraded ? degradedBody(item)
|
|
440
|
+
console.log(item.degraded ? degradedBody(item)
|
|
441
|
+
: item.slice ?? item.digest ?? read(path.join(projectRoot, item.rel)).trimEnd());
|
|
410
442
|
console.log("");
|
|
411
443
|
}
|
|
412
444
|
// Keep the pack pipeable: the budget verdict goes to stderr.
|
|
@@ -417,7 +449,7 @@ export async function run(positional, cmdFlags) {
|
|
|
417
449
|
console.log(c.bold("Context pack") + (capability ? c.gray(` (capability: ${capability})`) : "") + forNote + scope + c.gray(" — read in this order:"));
|
|
418
450
|
console.log("");
|
|
419
451
|
kept.forEach((item, i) => {
|
|
420
|
-
const mark = item.degraded ? c.yellow(" summary") : " ";
|
|
452
|
+
const mark = item.degraded ? c.yellow(" summary") : item.slice ? c.yellow(" sliced") : item.digest ? c.yellow(" digest") : " ";
|
|
421
453
|
console.log(`${String(i + 1).padStart(2)}. ${c.cyan(item.rel.padEnd(56))} ${String(item.lines).padStart(4)} lines ${c.gray(`~${String(item.tokens).padStart(5)} tok`)}${mark} ${c.gray(item.note)}`);
|
|
422
454
|
});
|
|
423
455
|
console.log("");
|
|
@@ -709,14 +741,21 @@ function changedPaths(projectRoot, ref) {
|
|
|
709
741
|
|
|
710
742
|
export const help = `
|
|
711
743
|
Usage: doctrina context [<capability>] [--for "<task>"] [--concat]
|
|
712
|
-
[--budget <tokens>] [--diff <ref>]
|
|
744
|
+
[--budget <tokens>] [--diff <ref>] [--full]
|
|
713
745
|
|
|
714
746
|
Print the exact context pack for a task, in the documented read
|
|
715
|
-
order:
|
|
716
|
-
|
|
747
|
+
order: product.md, the capability spec (when given — otherwise every
|
|
748
|
+
active spec), open changes, accepted ADRs. Skills are
|
|
717
749
|
listed name + description + when-trigger only — they are on-demand by
|
|
718
750
|
design. The change archive is excluded.
|
|
719
751
|
|
|
752
|
+
The pack is LEAN by default: AGENTS.md is not repeated (your agent
|
|
753
|
+
loads it every turn), an accepted ADR is a digest — its title and the
|
|
754
|
+
opening of its Decision; \`doctrina show NNNN\` reads the rest — and
|
|
755
|
+
with --for the task's spec is sliced to its headers, Purpose and the
|
|
756
|
+
requirements and criteria the task matches, the others named by count.
|
|
757
|
+
--full restores every text whole.
|
|
758
|
+
|
|
720
759
|
Assembly is retrieval, not a dump. A token budget ALWAYS applies
|
|
721
760
|
(default ${DEFAULT_BUDGET}; set "context_budget": <n> in
|
|
722
761
|
.doctrina/config.json, or pass --budget). Over budget, accepted ADRs
|
|
@@ -731,6 +770,8 @@ Flags:
|
|
|
731
770
|
the list — ready to pipe into an agent. The budget
|
|
732
771
|
verdict goes to stderr, keeping stdout pure.
|
|
733
772
|
--budget <n> Token ceiling for this call, overriding the project's.
|
|
773
|
+
--full Every artifact whole: AGENTS.md included, specs not
|
|
774
|
+
sliced, ADRs not digested (the pre-0.19 pack).
|
|
734
775
|
--diff <ref> Scope the stable artifacts (AGENTS.md, product.md,
|
|
735
776
|
specs, ADRs) to those changed since the git ref; open
|
|
736
777
|
changes are always included. The resume-session read.
|