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.
Files changed (42) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +74 -0
  4. package/src/commands/analyze.js +14 -3
  5. package/src/commands/change.js +73 -4
  6. package/src/commands/close.js +18 -1
  7. package/src/commands/context.js +49 -8
  8. package/src/commands/delivery.js +238 -0
  9. package/src/commands/index-rebuild.js +23 -0
  10. package/src/commands/init.js +15 -0
  11. package/src/commands/intake.js +47 -1
  12. package/src/commands/metrics.js +28 -3
  13. package/src/commands/prime.js +76 -7
  14. package/src/commands/upgrade.js +15 -0
  15. package/src/commands/work.js +86 -2
  16. package/src/commands/workspace.js +409 -0
  17. package/src/index.js +5 -7
  18. package/src/lib/actions.js +1 -1
  19. package/src/lib/adapters.js +80 -0
  20. package/src/lib/code-inventory.js +297 -0
  21. package/src/lib/commands.js +44 -5
  22. package/src/lib/context-slice.js +118 -0
  23. package/src/lib/delivery.js +208 -0
  24. package/src/lib/metrics-model.js +23 -1
  25. package/src/lib/playbook.js +1 -1
  26. package/src/lib/skill-export.js +95 -0
  27. package/src/lib/snapshot.js +39 -2
  28. package/src/lib/task-card.js +102 -0
  29. package/src/lib/views.js +51 -2
  30. package/src/lib/work-model.js +58 -0
  31. package/src/lib/workspace-agents.js +259 -0
  32. package/src/lib/workspace-tools.js +113 -0
  33. package/src/lib/workspace.js +371 -0
  34. package/templates/AGENTS.md.template +17 -14
  35. package/templates/README.md +9 -0
  36. package/templates/adapters/claude/.claude/settings.json.template +18 -0
  37. package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
  38. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
  39. package/templates/delivery.md.template +39 -0
  40. package/templates/playbooks/absorb.md.template +40 -0
  41. package/templates/playbooks/delivery.md.template +38 -0
  42. 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.18.0 — current release. Zero runtime dependencies
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -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
  }
@@ -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
- const results = collectAnalysis(projectRoot, changeDir);
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
  }
@@ -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.
@@ -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`);
@@ -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
- if (wanted("AGENTS.md")) pushFile("AGENTS.md", "root rules");
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) : read(path.join(projectRoot, item.rel)).trimEnd());
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: AGENTS.md, product.md, the capability spec (when given —
716
- otherwise every active spec), open changes, accepted ADRs. Skills are
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.