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
@@ -127,3 +127,83 @@ export function renderAdapterFile(file, tokens) {
127
127
  }
128
128
 
129
129
  export { exists };
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Configuration files an adapter SHARES with the user (change 0211).
133
+ //
134
+ // A hook lives in the agent's own settings — `.claude/settings.json`,
135
+ // `.cursor/hooks.json` — which the project may already own: permissions,
136
+ // other hooks, a model choice. Writing the file whole would be the data loss
137
+ // `adapter add` exists to avoid, and skipping it because it exists would
138
+ // leave the hooks out exactly where an agent is configured. So a `.json`
139
+ // adapter file is MERGED: objects recurse, arrays gain the entries they do
140
+ // not already hold, and a scalar the user set stays theirs. Removal takes
141
+ // back only what the template put in.
142
+
143
+ /** Whether an adapter file is merged into a shared configuration file. */
144
+ export function isMergedConfig(file) {
145
+ return file.relativePath.endsWith(".json");
146
+ }
147
+
148
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
149
+
150
+ /** `ours` merged into `theirs`; neither argument is modified. */
151
+ export function mergeJson(theirs, ours) {
152
+ if (Array.isArray(theirs) && Array.isArray(ours)) {
153
+ return [...theirs, ...ours.filter((o) => !theirs.some((t) => same(t, o)))];
154
+ }
155
+ if (isPlainObject(theirs) && isPlainObject(ours)) {
156
+ const out = { ...theirs };
157
+ for (const [k, v] of Object.entries(ours)) {
158
+ out[k] = k in theirs ? mergeJson(theirs[k], v) : v;
159
+ }
160
+ return out;
161
+ }
162
+ return theirs;
163
+ }
164
+
165
+ /**
166
+ * `theirs` with everything `ours` contributed taken out, and the containers
167
+ * that leaves empty pruned. A scalar is removed only when it equals ours AND
168
+ * nothing of the user's remains beside it. Returns null when nothing is left.
169
+ */
170
+ export function unmergeJson(theirs, ours) {
171
+ if (Array.isArray(theirs) && Array.isArray(ours)) {
172
+ const left = theirs.filter((t) => !ours.some((o) => same(t, o)));
173
+ return left.length > 0 ? left : null;
174
+ }
175
+ if (isPlainObject(theirs) && isPlainObject(ours)) {
176
+ const out = {};
177
+ for (const [k, v] of Object.entries(theirs)) {
178
+ if (!(k in ours) || isScalar(ours[k])) { out[k] = v; continue; }
179
+ const kept = unmergeJson(v, ours[k]);
180
+ if (kept !== null) out[k] = kept;
181
+ }
182
+ // Our scalars (a `"version": 1`) go only when they are all that is left.
183
+ const userKeys = Object.keys(out).filter((k) => !(k in ours && isScalar(ours[k]) && same(out[k], ours[k])));
184
+ return userKeys.length > 0 ? out : null;
185
+ }
186
+ return same(theirs, ours) ? null : theirs;
187
+ }
188
+
189
+ function isPlainObject(v) {
190
+ return v !== null && typeof v === "object" && !Array.isArray(v);
191
+ }
192
+
193
+ function isScalar(v) {
194
+ return v === null || typeof v !== "object";
195
+ }
196
+
197
+ /** Whether `theirs` holds at least one array entry `ours` contributes. */
198
+ export function holdsAnyOf(theirs, ours) {
199
+ if (Array.isArray(theirs) && Array.isArray(ours)) return ours.some((o) => theirs.some((t) => same(t, o)));
200
+ if (isPlainObject(theirs) && isPlainObject(ours)) {
201
+ return Object.keys(ours).some((k) => k in theirs && holdsAnyOf(theirs[k], ours[k]));
202
+ }
203
+ return false;
204
+ }
205
+
206
+ /** Whether every entry `ours` contributes is present in `theirs`. */
207
+ export function containsJson(theirs, ours) {
208
+ return same(mergeJson(theirs, ours), theirs);
209
+ }
@@ -0,0 +1,297 @@
1
+ // @ts-check
2
+ import { readdirSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { isDir, isFile, read } from "./fs-ops.js";
5
+ import { gitLines } from "./git.js";
6
+
7
+ // The INVENTORY of an existing codebase (change 0221): what `intake
8
+ // --from-code` stores as the intake of a repository that already has code
9
+ // and no specs. Deterministic, local and cheap — it reads manifests and
10
+ // counts files; it never interprets prose and never reads a secret.
11
+ //
12
+ // It is the FACTS half of absorbing a project. The agent does the other
13
+ // half — reading the code it points at and writing what the system does
14
+ // today — through the absorb playbook (ADR 0005: the CLI does no
15
+ // interpretation).
16
+
17
+ const SKIP_DIRS = new Set([
18
+ ".git", "node_modules", ".doctrina", "dist", "build", "out", "target", "vendor", "coverage",
19
+ ".venv", "venv", "__pycache__", ".next", ".nuxt", ".gradle", ".idea", ".vscode", "bin", "obj",
20
+ ".terraform", ".pytest_cache", ".mypy_cache", "Pods", ".dart_tool", ".expo",
21
+ ]);
22
+ const MAX_FILES = 20000;
23
+
24
+ // Dependency name → what it tells about the project.
25
+ const JS_SIGNALS = {
26
+ react: "React", next: "Next.js", vue: "Vue", nuxt: "Nuxt", "@angular/core": "Angular", svelte: "Svelte",
27
+ "react-native": "React Native", expo: "Expo", express: "Express", fastify: "Fastify", "@nestjs/core": "NestJS",
28
+ koa: "Koa", graphql: "GraphQL", prisma: "Prisma", typeorm: "TypeORM", sequelize: "Sequelize", mongoose: "Mongoose",
29
+ jest: "Jest", vitest: "Vitest", mocha: "Mocha", "@playwright/test": "Playwright", playwright: "Playwright",
30
+ cypress: "Cypress", "@cucumber/cucumber": "Cucumber", webdriverio: "WebdriverIO", "@testing-library/react": "Testing Library",
31
+ storybook: "Storybook", "@storybook/react": "Storybook", typescript: "TypeScript", "k6": "k6",
32
+ };
33
+ const PY_SIGNALS = {
34
+ django: "Django", flask: "Flask", fastapi: "FastAPI", pytest: "pytest", behave: "Behave",
35
+ "robotframework": "Robot Framework", selenium: "Selenium", playwright: "Playwright", requests: "requests",
36
+ sqlalchemy: "SQLAlchemy", pandas: "pandas", pyspark: "PySpark", locust: "Locust", celery: "Celery",
37
+ };
38
+ const JVM_SIGNALS = {
39
+ "spring-boot": "Spring Boot", junit: "JUnit", "rest-assured": "REST Assured", cucumber: "Cucumber",
40
+ testng: "TestNG", selenium: "Selenium", kotlin: "Kotlin", mockito: "Mockito",
41
+ };
42
+
43
+ const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?$)|(^test_.*\.py$)|(_test\.(py|go)$)|(\.feature$)|(Tests?\.(java|kt|cs)$)|(\.robot$)|(_spec\.rb$)/;
44
+ const CI_FILES = [
45
+ [".github/workflows", "GitHub Actions"], [".gitlab-ci.yml", "GitLab CI"], ["Jenkinsfile", "Jenkins"],
46
+ ["azure-pipelines.yml", "Azure Pipelines"], ["bitbucket-pipelines.yml", "Bitbucket Pipelines"], [".circleci", "CircleCI"],
47
+ ];
48
+ const INFRA_FILES = [
49
+ ["Dockerfile", "Docker"], ["docker-compose.yml", "Docker Compose"], ["docker-compose.yaml", "Docker Compose"],
50
+ ["compose.yml", "Docker Compose"], ["helm", "Helm"], ["k8s", "Kubernetes manifests"], ["kubernetes", "Kubernetes manifests"],
51
+ ["serverless.yml", "Serverless"], ["Procfile", "Procfile"],
52
+ ];
53
+ const ENV_EXAMPLES = [".env.example", ".env.sample", ".env.template", ".env.dist"];
54
+ const AREA_ROOTS = ["src", "app", "lib", "packages", "apps", "services", "modules", "features", "pages", "components", "tests", "test", "e2e", "cypress"];
55
+
56
+ function walk(root) {
57
+ const files = [];
58
+ let truncated = false;
59
+ const stack = [""];
60
+ while (stack.length && !truncated) {
61
+ const rel = stack.pop();
62
+ let entries;
63
+ try {
64
+ entries = readdirSync(path.join(root, rel), { withFileTypes: true });
65
+ } catch {
66
+ continue;
67
+ }
68
+ for (const e of entries) {
69
+ const r = rel ? `${rel}/${e.name}` : e.name;
70
+ if (e.isDirectory()) {
71
+ if (!SKIP_DIRS.has(e.name)) stack.push(r);
72
+ } else if (e.isFile()) {
73
+ files.push(r);
74
+ if (files.length >= MAX_FILES) {
75
+ truncated = true;
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ }
81
+ return { files: files.sort(), truncated };
82
+ }
83
+
84
+ function readJson(p) {
85
+ try {
86
+ return JSON.parse(read(p));
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function signalsFrom(names, table) {
93
+ const out = new Set();
94
+ for (const n of names) {
95
+ const key = String(n).toLowerCase();
96
+ for (const [dep, label] of Object.entries(table)) {
97
+ // Exact names only (a scope prefix aside): `express` must not read as
98
+ // `expo`, nor `nextjs-toolbox` as Next.js.
99
+ if (key === dep || key.endsWith("/" + dep)) out.add(label);
100
+ }
101
+ }
102
+ return [...out];
103
+ }
104
+
105
+ /** Every `KEY=` name in an env example file — names only, never a value. */
106
+ export function envNames(text) {
107
+ return String(text).split(/\r?\n/)
108
+ .map((l) => l.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/)?.[1])
109
+ .filter(Boolean);
110
+ }
111
+
112
+ function readmeSummary(root) {
113
+ for (const name of ["README.md", "readme.md", "README.MD", "README"]) {
114
+ const p = path.join(root, name);
115
+ if (!isFile(p)) continue;
116
+ const lines = read(p).split(/\r?\n/);
117
+ const title = lines.find((l) => /^#\s+/.test(l))?.replace(/^#\s+/, "").trim() ?? "";
118
+ const para = [];
119
+ let started = false;
120
+ for (const l of lines) {
121
+ if (/^#/.test(l) || /^\s*(<|!\[|\[!\[)/.test(l)) {
122
+ if (started) break;
123
+ continue;
124
+ }
125
+ if (l.trim() === "") {
126
+ if (started) break;
127
+ continue;
128
+ }
129
+ started = true;
130
+ para.push(l.trim());
131
+ }
132
+ return { file: name, title, summary: para.join(" ").slice(0, 400) };
133
+ }
134
+ return null;
135
+ }
136
+
137
+ function gitFacts(root) {
138
+ const dates = gitLines(root, ["log", "--format=%cs"]);
139
+ if (!dates || dates.length === 0) return null;
140
+ return { commits: dates.length, first: dates[dates.length - 1], last: dates[0] };
141
+ }
142
+
143
+ /**
144
+ * The inventory of the codebase at `root`.
145
+ */
146
+ export function inventory(root) {
147
+ const { files, truncated } = walk(root);
148
+ const has = (rel) => files.includes(rel) || isDir(path.join(root, rel));
149
+ const stack = new Set();
150
+ const signals = new Set();
151
+ const checks = [];
152
+
153
+ const pkg = isFile(path.join(root, "package.json")) ? readJson(path.join(root, "package.json")) : null;
154
+ if (pkg) {
155
+ stack.add(isFile(path.join(root, "tsconfig.json")) ? "Node.js / TypeScript" : "Node.js");
156
+ const deps = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})];
157
+ for (const s of signalsFrom(deps, JS_SIGNALS)) signals.add(s);
158
+ const runner = isFile(path.join(root, "pnpm-lock.yaml")) ? "pnpm" : isFile(path.join(root, "yarn.lock")) ? "yarn" : "npm";
159
+ for (const name of ["typecheck", "lint", "test", "build"]) {
160
+ if (pkg.scripts?.[name]) checks.push({ name, command: `${runner} run ${name}`, from: "package.json scripts" });
161
+ }
162
+ for (const [name, cmd] of Object.entries(pkg.scripts ?? {})) {
163
+ if (/^(test:|e2e)/.test(name)) checks.push({ name, command: `${runner} run ${name}`, from: `package.json scripts (${String(cmd).slice(0, 60)})` });
164
+ }
165
+ }
166
+ const pyFiles = ["pyproject.toml", "requirements.txt", "requirements-dev.txt", "setup.py", "Pipfile"].filter((f) => isFile(path.join(root, f)));
167
+ if (pyFiles.length) {
168
+ stack.add("Python");
169
+ const text = pyFiles.map((f) => read(path.join(root, f))).join("\n").toLowerCase();
170
+ const names = text.match(/[a-z][a-z0-9_.-]+/g) ?? [];
171
+ for (const s of signalsFrom([...new Set(names)].filter((n) => PY_SIGNALS[n]), PY_SIGNALS)) signals.add(s);
172
+ if (signals.has("pytest")) checks.push({ name: "test", command: "pytest", from: pyFiles.join(", ") });
173
+ if (signals.has("Behave")) checks.push({ name: "bdd", command: "behave", from: pyFiles.join(", ") });
174
+ }
175
+ const jvm = ["pom.xml", "build.gradle", "build.gradle.kts"].filter((f) => isFile(path.join(root, f)));
176
+ if (jvm.length) {
177
+ stack.add(jvm[0] === "pom.xml" ? "Java / Maven" : "JVM / Gradle");
178
+ const text = jvm.map((f) => read(path.join(root, f))).join("\n").toLowerCase();
179
+ for (const [dep, label] of Object.entries(JVM_SIGNALS)) if (text.includes(dep)) signals.add(label);
180
+ checks.push(jvm[0] === "pom.xml"
181
+ ? { name: "test", command: "mvn -q test", from: "pom.xml" }
182
+ : { name: "test", command: isFile(path.join(root, "gradlew")) ? "./gradlew test" : "gradle test", from: jvm[0] });
183
+ }
184
+ if (isFile(path.join(root, "go.mod"))) {
185
+ stack.add("Go");
186
+ checks.push({ name: "test", command: "go test ./...", from: "go.mod" });
187
+ }
188
+ if (isFile(path.join(root, "Cargo.toml"))) {
189
+ stack.add("Rust");
190
+ checks.push({ name: "test", command: "cargo test", from: "Cargo.toml" });
191
+ }
192
+ if (files.some((f) => /\.(csproj|sln)$/.test(f))) {
193
+ stack.add(".NET");
194
+ checks.push({ name: "test", command: "dotnet test", from: "*.csproj" });
195
+ }
196
+ if (isFile(path.join(root, "pubspec.yaml"))) {
197
+ stack.add("Dart / Flutter");
198
+ checks.push({ name: "test", command: "flutter test", from: "pubspec.yaml" });
199
+ }
200
+ if (isFile(path.join(root, "Gemfile"))) stack.add("Ruby");
201
+ if (isFile(path.join(root, "composer.json"))) stack.add("PHP");
202
+ if (isDir(path.join(root, "android"))) signals.add("Android project");
203
+ if (isDir(path.join(root, "ios"))) signals.add("iOS project");
204
+ if (files.some((f) => f.endsWith(".tf"))) signals.add("Terraform");
205
+
206
+ const testFiles = files.filter((f) => TEST_FILE.test(path.basename(f)) || /(^|\/)(cypress|e2e)\/.+\.[cm]?[jt]sx?$/.test(f));
207
+ const testDirs = new Map();
208
+ for (const f of testFiles) {
209
+ const dir = f.includes("/") ? f.split("/").slice(0, Math.min(2, f.split("/").length - 1)).join("/") : ".";
210
+ testDirs.set(dir, (testDirs.get(dir) ?? 0) + 1);
211
+ }
212
+ const ci = CI_FILES.filter(([p]) => has(p)).map(([p, label]) => ({ path: p, label }));
213
+ const infra = INFRA_FILES.filter(([p]) => has(p)).map(([p, label]) => ({ path: p, label }));
214
+ const env = [];
215
+ for (const f of ENV_EXAMPLES) {
216
+ if (isFile(path.join(root, f))) env.push({ file: f, names: envNames(read(path.join(root, f))) });
217
+ }
218
+
219
+ // Candidate AREAS: the folders one level under the conventional source
220
+ // roots, with how many files each holds — where capabilities usually are.
221
+ const areas = new Map();
222
+ for (const f of files) {
223
+ const parts = f.split("/");
224
+ if (parts.length < 3 || !AREA_ROOTS.includes(parts[0])) continue;
225
+ const key = `${parts[0]}/${parts[1]}`;
226
+ areas.set(key, (areas.get(key) ?? 0) + 1);
227
+ }
228
+ const topDirs = new Map();
229
+ for (const f of files) {
230
+ const top = f.includes("/") ? f.split("/")[0] : null;
231
+ if (top) topDirs.set(top, (topDirs.get(top) ?? 0) + 1);
232
+ }
233
+
234
+ return {
235
+ root,
236
+ files: files.length,
237
+ truncated,
238
+ readme: readmeSummary(root),
239
+ stack: [...stack],
240
+ signals: [...signals].sort(),
241
+ checks,
242
+ tests: { files: testFiles.length, dirs: [...testDirs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10) },
243
+ ci,
244
+ infra,
245
+ env,
246
+ areas: [...areas.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25),
247
+ topDirs: [...topDirs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15),
248
+ git: gitFacts(root),
249
+ };
250
+ }
251
+
252
+ /** The inventory as the Markdown body of an intake. */
253
+ export function inventoryMarkdown(inv, projectName) {
254
+ const L = [];
255
+ L.push(`Code inventory of ${projectName} — the FACTS an agent absorbs the project from.`);
256
+ L.push("Written by `doctrina intake --from-code`: manifests read, files counted, nothing interpreted.");
257
+ L.push("Business intent is NOT in here: what the code cannot say goes to the human as a question.");
258
+ L.push("");
259
+ if (inv.readme) {
260
+ L.push(`## README (${inv.readme.file})`, "");
261
+ if (inv.readme.title) L.push(`- Title: ${inv.readme.title}`);
262
+ if (inv.readme.summary) L.push(`- Opening: ${inv.readme.summary}`);
263
+ L.push("");
264
+ }
265
+ L.push("## Stack", "");
266
+ L.push(`- Languages / build: ${inv.stack.length ? inv.stack.join(", ") : "none detected from manifests"}`);
267
+ L.push(`- Frameworks and tools: ${inv.signals.length ? inv.signals.join(", ") : "none detected"}`);
268
+ L.push(`- Files scanned: ${inv.files}${inv.truncated ? ` (stopped at ${MAX_FILES})` : ""}`);
269
+ if (inv.git) L.push(`- History: ${inv.git.commits} commits, ${inv.git.first} → ${inv.git.last}`);
270
+ L.push("");
271
+ L.push("## Where the code is", "");
272
+ for (const [dir, n] of inv.topDirs) L.push(`- \`${dir}/\` — ${n} files`);
273
+ if (inv.topDirs.length === 0) L.push("- (flat repository)");
274
+ L.push("");
275
+ L.push("## Candidate areas (one capability each, usually)", "");
276
+ for (const [dir, n] of inv.areas) L.push(`- \`${dir}/\` — ${n} files`);
277
+ if (inv.areas.length === 0) L.push("- none under the conventional roots — derive areas from the top-level folders");
278
+ L.push("");
279
+ L.push("## Tests", "");
280
+ L.push(`- ${inv.tests.files} test file${inv.tests.files === 1 ? "" : "s"}`);
281
+ for (const [dir, n] of inv.tests.dirs) L.push(`- \`${dir}\` — ${n}`);
282
+ L.push("");
283
+ L.push("## Build and test commands (candidates for `doctrina verify --init`)", "");
284
+ for (const ch of inv.checks) L.push(`- \`${ch.command}\` — ${ch.name}, from ${ch.from}`);
285
+ if (inv.checks.length === 0) L.push("- none detected — ask the human how this project is built and tested");
286
+ L.push("");
287
+ L.push("## Delivery", "");
288
+ for (const c of inv.ci) L.push(`- CI: ${c.label} (\`${c.path}\`)`);
289
+ for (const i of inv.infra) L.push(`- Runtime: ${i.label} (\`${i.path}\`)`);
290
+ if (inv.ci.length + inv.infra.length === 0) L.push("- no CI or runtime manifest found");
291
+ L.push("");
292
+ L.push("## Configuration (names only — values are never read)", "");
293
+ for (const e of inv.env) L.push(`- \`${e.file}\`: ${e.names.length ? e.names.map((n) => `\`${n}\``).join(", ") : "(no KEY= lines)"}`);
294
+ if (inv.env.length === 0) L.push("- no env example file");
295
+ return L.join("\n") + "\n";
296
+ }
297
+
@@ -19,6 +19,8 @@ export const COMMAND_NAMES = [
19
19
  "prime", "context", "show", "search", "status", "next", "why", "handoff",
20
20
  // gates
21
21
  "analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor",
22
+ // orchestrate
23
+ "workspace", "delivery",
22
24
  // maintenance
23
25
  "templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade", "ci",
24
26
  ];
@@ -48,6 +50,7 @@ export const OPERATIONS = [
48
50
  ["change tick", "List/tick the unchecked boxes (tasks + verification; --all)"],
49
51
  ["change abandon", "Discard an open change cleanly (recorded in the ledger)"],
50
52
  ["change delta", "Add a requirement/criterion/bump to a change's delta (ops written for you)"],
53
+ ["change card", "One task as an executor receives it: the task, its why, the spec cut to it"],
51
54
  ["contract new", "Own the integration surface (ports, env, interfaces)"],
52
55
  ["contract list", "List contracts with status and last-updated date"],
53
56
  ["contract check", "Verify port collisions, env drift, referenced specs"],
@@ -82,6 +85,19 @@ export const OPERATIONS = [
82
85
  ["status", "One-glance project health dashboard"],
83
86
  ["why", "Explain provenance: a capability's chain, or an anchor's (SC1)"],
84
87
  ["handoff", "Markdown handoff note: open work, task state, resume command"],
88
+ ["workspace init", "Create a multi-repository workspace (a private git repo of context)"],
89
+ ["workspace add", "Register a repository: in-repo, or shadowed when it cannot carry .doctrina/"],
90
+ ["workspace attach", "Re-create a shadow project's links in its repository"],
91
+ ["workspace detach", "Remove a shadow project's links; the shadow stays"],
92
+ ["workspace list", "Projects with mode, state and open changes"],
93
+ ["workspace tool", "Declare a tool (MCP server) the work uses: role, read or write"],
94
+ ["workspace guard", "Pre-call hook: allow, deny or ask an MCP call from the declared access"],
95
+ ["delivery new", "Open a delivery across repositories; print the orchestration playbook"],
96
+ ["delivery route", "Choose the projects a delivery touches, dependencies first"],
97
+ ["delivery list", "Deliveries with status, ref and closed/total changes"],
98
+ ["delivery status", "Per project: the changes carrying a delivery and their tasks"],
99
+ ["delivery check", "What still keeps a delivery from closing (exit 1 while anything does)"],
100
+ ["delivery close", "Close a delivery once every project's change is closed"],
85
101
  ["templates list", "List the templates shipped by the installed CLI"],
86
102
  ["templates check", "Compare the project against the recommended template shape"],
87
103
  ["templates update", "Additive fixer for check findings (preview; --write applies)"],
@@ -127,8 +143,8 @@ export const COMMAND_META = {
127
143
  why: { moment: "Orient", when: "you need to justify or trace a capability's existence", purpose: "provenance: intent, proof, ADRs, and history" },
128
144
  handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
129
145
 
130
- triage: { moment: "Change", when: "a request arrives — BEFORE scaffolding, especially if it smells like an incident", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
131
- work: { moment: "Change", when: "a request arrives that changes behaviour (triage says PRODUCT)", purpose: "scaffold a change and print the playbook to execute" },
146
+ triage: { moment: "Change", when: "diagnosing an incident or a runtime symptom — `work` classifies every other request itself", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
147
+ work: { moment: "Change", when: "a request changes behaviour — BEFORE editing code; it classifies the lane itself", purpose: "scaffold a change and print the playbook to execute" },
132
148
  spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
133
149
  change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "drive a change by hand — check --verbose previews every delta" },
134
150
  decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
@@ -146,6 +162,9 @@ export const COMMAND_META = {
146
162
  close: { moment: "Gate", when: "a change is implemented and ready to finish", purpose: "the whole closing sequence in one attested pass" },
147
163
  doctor: { moment: "Gate", when: "something looks wrong and you do not know which gate to ask", purpose: "aggregate diagnostic with per-finding remedies" },
148
164
 
165
+ delivery: { moment: "Orchestrate", when: "a story or screen must land in more than one repository", purpose: "plan, route, track and close one item of work across repositories" },
166
+ workspace: { moment: "Orchestrate", when: "the work spans several repositories, or a repository cannot carry .doctrina/", purpose: "register repositories, in-repo or shadowed, in one versioned workspace" },
167
+
149
168
  templates: { moment: "Maintain", when: "customising a scaffold, to see which template file wins", purpose: "where each shipped template resolves from" },
150
169
  upgrade: { moment: "Maintain", when: "you just updated the doctrina-cli package", purpose: "bring this project up to the installed CLI" },
151
170
  index: { moment: "Maintain", when: "the index drifted from the tree", purpose: "regenerate index.json from the artifacts on disk" },
@@ -158,7 +177,7 @@ export const COMMAND_META = {
158
177
  };
159
178
 
160
179
  // The order moments appear in the generated block.
161
- export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Maintain"];
180
+ export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Orchestrate", "Maintain"];
162
181
 
163
182
  // The Commands block of `doctrina --help`, generated from OPERATIONS so the
164
183
  // printed surface can never omit an operation the catalog knows about.
@@ -192,7 +211,10 @@ export function surfaceHelp() {
192
211
  const ops = live.filter(([op]) => op.split(" ")[0] === cmd);
193
212
  const subs = ops.map(([op]) => op.split(" ")[1]).filter(Boolean);
194
213
  const text = ops.length === 1 ? ops[0][1] : capitalise(COMMAND_META[cmd].purpose);
195
- out.push(line(subs.length > 0 ? `${cmd} ${subs.join("|")}` : cmd, text));
214
+ const op = subs.length > 0 ? `${cmd} ${subs.join("|")}` : cmd;
215
+ // A compact moment's long invocation stands alone rather than wrapping
216
+ // onto a second line (change 0220): `doctrina <cmd> --help` has the rest.
217
+ out.push(COMPACT_MOMENTS.has(moment) && op.length >= 20 ? ` ${op}` : line(op, text));
196
218
  }
197
219
  }
198
220
  const old = OPERATIONS.filter(([op]) => DEPRECATED[op]);
@@ -317,7 +339,10 @@ export const SURFACE_LINE_BUDGET = 40;
317
339
  // each its own line pushed the block past its budget. The budget is the
318
340
  // forcing function — a surface that cannot describe itself in 40 lines is
319
341
  // too large, and the answer is to cut commands (M8), not to raise it.
320
- const COMPACT_MOMENTS = new Set(["Maintain"]);
342
+ // "Orchestrate" is compact too (change 0220): an orchestrating session reads
343
+ // the workspace's own AGENTS.md, which carries the delivery playbook in full;
344
+ // inside one repository the commands only need to be discoverable.
345
+ const COMPACT_MOMENTS = new Set(["Orchestrate", "Maintain"]);
321
346
 
322
347
  // The full managed section, heading included, between the markers.
323
348
  //
@@ -517,6 +542,20 @@ export const AGENT_CHANGELOG = {
517
542
  // Each entry lists only ITS OWN delta. The series carries the rest
518
543
  // forward (see agentChangelogEntries), so a patch never has to restate
519
544
  // the minor that introduced the commands, and never silently erases it.
545
+ "0.20.0": [
546
+ "Work across repositories is a delivery: from the workspace, `doctrina delivery new \"<title>\" --ref <id>`, then follow its playbook — route, one change per repository with `doctrina work \"<title>\" --delivery <id>`, close each, then `doctrina delivery close <id>`.",
547
+ "Write tasks as self-contained cards (indented `Files:`, `Requirement:`, `Verify:` lines) and hand an executor `doctrina change card <id>`; one writer per repository, and only the planner closes (ADR 0030).",
548
+ "An existing codebase with no specs: `doctrina intake --from-code`, then the absorb playbook — describe what the code does today and send what it cannot tell to the human as open questions.",
549
+ "A repository that may not carry `.doctrina/` is a workspace shadow: its `.doctrina/` is a link, hidden from its git — never commit it there; commit the workspace.",
550
+ "MCP tools declared in the workspace are read-only unless declared `write`: a refused or held call means ask the human, never route around it.",
551
+ ],
552
+ "0.19.0": [
553
+ "A request that changes behaviour is a change opened BEFORE code, whether or not it names Doctrina; say its id in your first sentence to the human, and repeat the `Tell the human:` line `work` and `close` print.",
554
+ "`doctrina context <cap> --for \"<task>\" --concat` is now lean (no AGENTS.md, ADR digests, the spec sliced to the task) — read it before editing; `prime` is state, not the spec. `--full` gives the whole text.",
555
+ "Project skills are exported to `.agents/skills/` and `.claude/skills/`, where your agent lists skills — use them; edit `.doctrina/skills/`, never the exports.",
556
+ "`work` holds (exit 3) a request that repeats an open change or one archived in the last 14 days: resume it, or tell the human it is done and where; `--force` only for genuinely new work.",
557
+ "Hooks: `doctrina adapter add claude|cursor` merges session/prompt hooks (`prime --hook`, `prime --pulse`) into the agent's settings.",
558
+ ],
520
559
  "0.18.0": [
521
560
  "`doctrina change delta <id> <capability> --requirement \"<section>: <text>\" --criterion \"<text>\" --bump minor` writes the ops lines for you and dry-runs them against the spec — stop hand-writing ops blocks.",
522
561
  "`close` runs `verify --reuse`: running `doctrina verify` yourself right before `doctrina close` is no longer paid twice, and a batch close runs the suite once. Use `verify --affected` while working when checks declare `\"paths\"`.",
@@ -0,0 +1,118 @@
1
+ // @ts-check
2
+ import { fold } from "./lexicon.js";
3
+ import { getSection } from "./doc-model.js";
4
+
5
+ // The lean read pack (change 0216).
6
+ //
7
+ // A field review: the agent ran `prime` and `work` and skipped `context`,
8
+ // although AGENTS.md calls it the read for ANY task. Measured on this
9
+ // repository, the reason is the price: `context gates --concat` was ~40k
10
+ // tokens — AGENTS.md again (the agent already carries it), the whole spec,
11
+ // and every accepted ADR in full — and `--for "<task>"` cut nothing, because
12
+ // retrieval only degraded the pack once the 50k budget overflowed. These
13
+ // cuts are the default now; `--full` restores the whole text.
14
+
15
+ // How much of an ADR's Decision a digest keeps.
16
+ const ADR_DIGEST_CHARS = 700;
17
+ // How many requirements and criteria a sliced spec keeps at most, each.
18
+ const SLICE_MAX_ITEMS = 25;
19
+
20
+ /**
21
+ * An ADR as a reader deciding whether it binds the task needs it: the
22
+ * title and the opening of its Decision section. `doctrina show NNNN`
23
+ * reads the rest.
24
+ *
25
+ * @param {string} text
26
+ * @param {string} title
27
+ * @param {string} id four-digit ADR number
28
+ */
29
+ export function adrDigest(text, title, id) {
30
+ const decision = (getSection(text.replace(/\r\n/g, "\n"), "Decision") ?? "")
31
+ .split("\n").filter((l) => !l.trim().startsWith("<!--")).join("\n").trim();
32
+ const clipped = decision.length > ADR_DIGEST_CHARS
33
+ ? `${decision.slice(0, ADR_DIGEST_CHARS).replace(/\s+\S*$/, "")} …`
34
+ : decision;
35
+ return [`# ${title}`, "", clipped || "(no Decision section)", "",
36
+ `(digest — full text: \`doctrina show ${id}\`, or \`context --full\`)`].join("\n");
37
+ }
38
+
39
+ /**
40
+ * A spec cut to what the task is about: its headers, its Purpose, and the
41
+ * requirements and criteria that match the query best — the rest named by
42
+ * count, never silently dropped. Deterministic: an item's score is how many
43
+ * distinct query terms it contains; the items scoring at least half the
44
+ * best score are kept, best first in document order, up to a cap per list.
45
+ *
46
+ * @param {string} text
47
+ * @param {string[]} queryTerms already folded, stop words removed
48
+ * @param {string} rel the spec's path, for the footer
49
+ * @returns {{ text: string, kept: number, total: number } | null} null when nothing matched
50
+ */
51
+ export function sliceSpec(text, queryTerms, rel) {
52
+ if (queryTerms.length === 0) return null;
53
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
54
+ // Items: a bullet or numbered entry with its continuation lines.
55
+ /** @type {Array<{ start: number, end: number, kind: "req"|"crit", score: number }>} */
56
+ const items = [];
57
+ let section = "";
58
+ for (let i = 0; i < lines.length; i++) {
59
+ const l = lines[i];
60
+ if (/^##\s/.test(l)) { section = l; continue; }
61
+ const req = /^##\s+Requirements/i.test(section) && /^\s*-\s+\S/.test(l);
62
+ const crit = /^##\s+Acceptance criteria/i.test(section) && /^\s*\d+\.\s+\S/.test(l);
63
+ if (!req && !crit) continue;
64
+ let end = i;
65
+ while (end + 1 < lines.length && /^\s+\S/.test(lines[end + 1]) && !/^\s*(-|\d+\.)\s/.test(lines[end + 1])) end += 1;
66
+ const body = fold(lines.slice(i, end + 1).join(" "));
67
+ const score = queryTerms.filter((t) => body.includes(t)).length;
68
+ items.push({ start: i, end, kind: req ? "req" : "crit", score });
69
+ i = end;
70
+ }
71
+ const best = Math.max(0, ...items.map((it) => it.score));
72
+ if (best === 0) return null;
73
+ const floor = Math.max(1, Math.ceil(best / 2));
74
+ const keep = new Set();
75
+ for (const kind of ["req", "crit"]) {
76
+ items.filter((it) => it.kind === kind && it.score >= floor)
77
+ .sort((a, b) => b.score - a.score || a.start - b.start)
78
+ .slice(0, SLICE_MAX_ITEMS)
79
+ .forEach((it) => keep.add(it.start));
80
+ }
81
+
82
+ const out = [];
83
+ section = "";
84
+ let omitted = 0;
85
+ const flushOmitted = () => {
86
+ if (omitted > 0) out.push(` … ${omitted} not matching the task`);
87
+ omitted = 0;
88
+ };
89
+ for (let i = 0; i < lines.length; i++) {
90
+ const l = lines[i];
91
+ if (/^##\s/.test(l)) {
92
+ flushOmitted();
93
+ section = l;
94
+ out.push(l);
95
+ continue;
96
+ }
97
+ const item = items.find((it) => it.start === i);
98
+ if (item) {
99
+ if (keep.has(i)) { flushOmitted(); out.push(...lines.slice(i, item.end + 1)); } else omitted += 1;
100
+ i = item.end;
101
+ continue;
102
+ }
103
+ // Headers, Purpose and subsection headings are kept; prose in the other
104
+ // sections (Out of scope, Maturity, …) is the first thing a task can do
105
+ // without.
106
+ const inLists = /^##\s+(Requirements|Acceptance criteria)/i.test(section);
107
+ const prose = section !== "" && !/^##\s+Purpose/i.test(section) && !inLists;
108
+ if (/^###\s/.test(l)) { flushOmitted(); out.push(l); continue; }
109
+ if (section === "" || /^##\s+Purpose/i.test(section)) { out.push(l); continue; }
110
+ if (inLists && l.trim() !== "") continue;
111
+ if (!prose) out.push(l);
112
+ }
113
+ flushOmitted();
114
+ const kept = keep.size;
115
+ out.push("", `(sliced for "${queryTerms.join(" ")}" — ${kept} of ${items.length} requirements and criteria kept; ` +
116
+ `full text: ${rel}, or \`context --full\`)`);
117
+ return { text: out.join("\n").replace(/\n{3,}/g, "\n\n"), kept, total: items.length };
118
+ }