doctrina-cli 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +92 -14
  5. package/src/commands/clarify.js +1 -204
  6. package/src/commands/context.js +33 -2
  7. package/src/commands/contract.js +1 -0
  8. package/src/commands/delivery.js +135 -17
  9. package/src/commands/doctor.js +21 -1
  10. package/src/commands/handoff.js +11 -2
  11. package/src/commands/init.js +13 -1
  12. package/src/commands/intake.js +16 -5
  13. package/src/commands/metrics.js +15 -0
  14. package/src/commands/next.js +41 -6
  15. package/src/commands/prime.js +53 -4
  16. package/src/commands/review.js +39 -3
  17. package/src/commands/skill.js +2 -1
  18. package/src/commands/status.js +28 -0
  19. package/src/commands/upgrade.js +18 -0
  20. package/src/commands/validate.js +18 -0
  21. package/src/commands/verify.js +32 -4
  22. package/src/commands/work.js +43 -8
  23. package/src/commands/workspace.js +73 -63
  24. package/src/index.js +98 -62
  25. package/src/lib/actions.js +43 -3
  26. package/src/lib/adapters.js +63 -1
  27. package/src/lib/args.js +33 -1
  28. package/src/lib/clarify-scan.js +221 -0
  29. package/src/lib/code-inventory.js +576 -103
  30. package/src/lib/commands.js +8 -0
  31. package/src/lib/context-slice.js +34 -3
  32. package/src/lib/delivery.js +88 -10
  33. package/src/lib/docs-impact.js +36 -1
  34. package/src/lib/fs-ops.js +23 -0
  35. package/src/lib/intake-model.js +1 -1
  36. package/src/lib/json-out.js +9 -1
  37. package/src/lib/shell.js +45 -0
  38. package/src/lib/snapshot.js +5 -1
  39. package/src/lib/task-card.js +100 -7
  40. package/src/lib/views.js +3 -3
  41. package/src/lib/work-model.js +8 -2
  42. package/src/lib/workspace-agents.js +65 -18
  43. package/src/lib/workspace-guard.js +65 -0
  44. package/src/lib/workspace-locate.js +141 -0
  45. package/src/lib/workspace-tools.js +63 -16
  46. package/src/lib/workspace-view.js +391 -0
  47. package/src/lib/workspace.js +82 -130
  48. package/templates/AGENTS.md.template +1 -1
  49. package/templates/README.md +2 -1
  50. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  51. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  52. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  53. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  54. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  55. package/templates/playbooks/absorb.md.template +39 -16
  56. package/templates/playbooks/bootstrap.md.template +18 -6
  57. package/templates/playbooks/delivery.md.template +12 -6
@@ -14,8 +14,11 @@ import {
14
14
  findWorkspace, isWorkspaceDir, linkPlan, projectAt, projectState, readWorkspace, removeLinks,
15
15
  shadowDirOf, writeExclude, writePointer, writeWorkspace, gitInit,
16
16
  } from "../lib/workspace.js";
17
- import { TOOL_PRESETS, guardDecision, splitClaudeToolName, toolEntry } from "../lib/workspace-tools.js";
17
+ import { TOOL_PRESETS, toolEntry } from "../lib/workspace-tools.js";
18
+ import { workspaceGuard } from "../lib/workspace-guard.js";
18
19
  import { writeOrchestratorFiles } from "../lib/workspace-agents.js";
20
+ import { cdThen } from "../lib/shell.js";
21
+ import { mcpServerNames } from "../lib/workspace-view.js";
19
22
 
20
23
  // `doctrina workspace` — the context of several repositories in one place
21
24
  // (ADR 0029). The decisions live in lib/workspace.js; this module renders
@@ -140,22 +143,7 @@ function workspaceAdd(wsDir, pathArg, flags) {
140
143
  about: flagString(flags, "about") ?? "",
141
144
  dependsOn,
142
145
  };
143
- if (mode === "shadow") {
144
- const shadow = shadowDirOf(wsDir, name);
145
- if (!isDir(path.join(shadow, ".doctrina"))) {
146
- mkdirp(shadow);
147
- const agent = flagString(flags, "agent") ?? "cursor";
148
- const r = spawnSync(process.execPath, [CLI_ENTRY, "init", "--non-interactive",
149
- "--project-name", name, "--project-description", project.about || `${name} (absorbed into a workspace)`,
150
- "--agent", agent], { cwd: shadow, encoding: "utf8", env: { ...process.env, NO_COLOR: "1" } });
151
- if (r.status !== 0) {
152
- console.error(c.red("error:") + ` could not scaffold the shadow at ${shadow}`);
153
- console.error(r.stderr.trim());
154
- return EXIT.GATE;
155
- }
156
- console.log(c.green("created") + ` shadow ${relPath(wsDir, shadow)}/ (AGENTS.md, .doctrina/, ${agent} adapter)`);
157
- }
158
- }
146
+ if (mode === "shadow" && !scaffoldShadow(wsDir, project, flagString(flags, "agent") ?? "cursor")) return EXIT.GATE;
159
147
  ws.projects.push(project);
160
148
  writeWorkspace(wsDir, ws);
161
149
  console.log(c.green("registered") + ` ${name} (${mode}) → ${repo}`);
@@ -185,7 +173,7 @@ function printProjectNext(wsDir, project) {
185
173
  if (st.state === "pending") {
186
174
  console.log("");
187
175
  console.log(`Next: ${project.name} has no specs yet (${st.reason}) — absorb it from its code:`);
188
- console.log(" " + c.cyan(`cd ${project.path} && doctrina intake --from-code`));
176
+ console.log(" " + c.cyan(cdThen(project.path, "doctrina intake --from-code")));
189
177
  }
190
178
  }
191
179
 
@@ -196,6 +184,23 @@ function findProject(ws, name) {
196
184
  throw Object.assign(new Error(`no project named "${name ?? ""}" in this workspace` + (guess ? ` — did you mean "${guess}"?` : "")), { exitCode: EXIT.USAGE });
197
185
  }
198
186
 
187
+ /** Scaffold a shadow's AGENTS.md, .doctrina/ and adapter when they are not there. */
188
+ function scaffoldShadow(wsDir, project, agent) {
189
+ const shadow = shadowDirOf(wsDir, project.name);
190
+ if (isDir(path.join(shadow, ".doctrina"))) return true;
191
+ mkdirp(shadow);
192
+ const r = spawnSync(process.execPath, [CLI_ENTRY, "init", "--non-interactive",
193
+ "--project-name", project.name, "--project-description", project.about || `${project.name} (absorbed into a workspace)`,
194
+ "--agent", agent], { cwd: shadow, encoding: "utf8", env: { ...process.env, NO_COLOR: "1" } });
195
+ if (r.status !== 0) {
196
+ console.error(c.red("error:") + ` could not scaffold the shadow at ${shadow}`);
197
+ console.error(r.stderr.trim());
198
+ return false;
199
+ }
200
+ console.log(c.green("created") + ` shadow ${relPath(wsDir, shadow)}/ (AGENTS.md, .doctrina/, ${agent} adapter)`);
201
+ return true;
202
+ }
203
+
199
204
  function workspaceAttach(wsDir, name, flags) {
200
205
  const ws = readWorkspace(wsDir);
201
206
  const project = findProject(ws, name);
@@ -210,6 +215,20 @@ function workspaceAttach(wsDir, name, flags) {
210
215
  writeWorkspace(wsDir, ws);
211
216
  console.log(c.green("moved") + ` ${project.name} → ${repo}`);
212
217
  }
218
+ // --depends-on on a registered project (second audit, change 0233): the
219
+ // edge `delivery route` orders by could only be given at `add`, before the
220
+ // project it names was always registered.
221
+ if (flags.has("depends-on")) {
222
+ const dependsOn = (flagString(flags, "depends-on") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
223
+ const unknown = dependsOn.filter((d) => d === project.name || !ws.projects.some((p) => p.name === d));
224
+ if (unknown.length > 0) {
225
+ console.error(c.red("error:") + ` --depends-on names no other registered project: ${unknown.join(", ")}`);
226
+ return EXIT.USAGE;
227
+ }
228
+ project.dependsOn = dependsOn;
229
+ writeWorkspace(wsDir, ws);
230
+ console.log(c.green("depends on") + ` ${project.name} → ${dependsOn.join(", ") || "nothing"}`);
231
+ }
213
232
  if (project.mode !== "shadow") {
214
233
  console.log(c.gray(`${project.name} is in-repo — its .doctrina/ is its own; nothing to link`));
215
234
  return EXIT.OK;
@@ -219,6 +238,14 @@ function workspaceAttach(wsDir, name, flags) {
219
238
  console.error(c.gray("hint: ") + `point it at the clone: \`doctrina workspace attach ${project.name} --path <dir>\``);
220
239
  return EXIT.PRECONDITION;
221
240
  }
241
+ // A shadow whose folder is gone (fourth audit, change 0235): attach
242
+ // crashed on ENOENT. A fresh, empty shadow is scaffolded and linked — the
243
+ // project is pending again, and says so; its history is the workspace's
244
+ // git, where a lost folder is restored from.
245
+ if (!isDir(path.join(shadowDirOf(wsDir, project.name), ".doctrina"))) {
246
+ console.log(c.yellow("warn") + ` ${project.name}'s shadow projects/${project.name}/ is gone — if the workspace's git has it, restore it (\`git checkout -- projects/${project.name}\`) before this; scaffolding an empty one`);
247
+ if (!scaffoldShadow(wsDir, project, flagString(flags, "agent") ?? "cursor")) return EXIT.GATE;
248
+ }
222
249
  attachShadow(wsDir, project);
223
250
  return EXIT.OK;
224
251
  }
@@ -278,46 +305,18 @@ function workspaceTool(wsDir, name, flags) {
278
305
  writeWorkspace(wsDir, ws);
279
306
  console.log(c.green(at >= 0 ? "updated" : "declared") + ` ${entry.name} → MCP server "${entry.server}", ${entry.access === "write" ? "read/write (each write asks the human)" : "read-only"}, for ${(entry.for ?? []).join(", ") || "the planner"}`);
280
307
  console.log(c.gray(`the server itself is configured in your agent (Cursor: .cursor/mcp.json; Claude Code: .mcp.json) under the name "${entry.server}"`));
308
+ // Said now, not only at the next validate (change 0235): a key that matches
309
+ // no configuration is a guard that never fires.
310
+ const servers = mcpServerNames([wsDir, ...ws.projects.map((p) => p.path)]);
311
+ if (servers.found && !servers.names.has(entry.server)) {
312
+ console.log(c.yellow("warn") + ` no MCP configuration here names a server "${entry.server}" (found: ${[...servers.names].sort().join(", ") || "none"}) — the guard only sees calls through the exact key; rerun with --server <that key>`);
313
+ }
281
314
  return EXIT.OK;
282
315
  }
283
316
 
284
317
  // Reads the agent's pre-call payload on stdin and answers in the agent's
285
318
  // own format. Never exits non-zero and never blocks on bad input: a broken
286
319
  // guard must not take the agent's tools away.
287
- function workspaceGuard(wsDir, hook) {
288
- if (hook !== "cursor" && hook !== "claude") {
289
- console.error(c.red("error:") + ` --hook needs cursor or claude (got "${hook ?? ""}")`);
290
- return EXIT.USAGE;
291
- }
292
- let call = {};
293
- try {
294
- call = JSON.parse(readFileSync(0, "utf8") || "{}");
295
- } catch {
296
- call = {};
297
- }
298
- let server = null;
299
- let tool = null;
300
- if (hook === "cursor") {
301
- server = call.mcp_server_name ?? null;
302
- tool = call.tool_name ?? null;
303
- } else {
304
- const split = splitClaudeToolName(call.tool_name ?? "");
305
- if (split) ({ server, tool } = split);
306
- }
307
- const tools = wsDir ? readWorkspace(wsDir).tools : [];
308
- const verdict = server && tool ? guardDecision(tools, server, tool, call.tool_input) : { decision: "none", reason: "" };
309
- if (hook === "cursor") {
310
- const permission = verdict.decision === "none" ? "allow" : verdict.decision;
311
- const out = { permission };
312
- if (permission !== "allow") Object.assign(out, { user_message: verdict.reason, agent_message: verdict.reason });
313
- console.log(JSON.stringify(out));
314
- } else if (verdict.decision !== "none") {
315
- console.log(JSON.stringify({ hookSpecificOutput: {
316
- hookEventName: "PreToolUse", permissionDecision: verdict.decision, permissionDecisionReason: verdict.reason,
317
- } }));
318
- }
319
- return EXIT.OK;
320
- }
321
320
 
322
321
  function workspaceList(wsDir) {
323
322
  const ws = readWorkspace(wsDir);
@@ -340,22 +339,30 @@ function workspaceList(wsDir) {
340
339
  const pending = ws.projects.filter((p) => projectState(wsDir, p).state === "pending");
341
340
  if (pending.length > 0) {
342
341
  console.log("");
343
- console.log(`Next: absorb ${pending.map((p) => p.name).join(", ")} — ` + c.cyan(`cd ${pending[0].path} && doctrina intake --from-code`));
342
+ console.log(`Next: absorb ${pending.map((p) => p.name).join(", ")} — ` + c.cyan(cdThen(pending[0].path, "doctrina intake --from-code")));
344
343
  }
345
344
  console.log("");
346
345
  if (ws.tools.length === 0) {
347
- console.log(c.gray("no tool declared — `doctrina workspace tool jira` (presets: " + Object.keys(TOOL_PRESETS).join(", ") + ")"));
346
+ // Named generically (change 0228): which tools a team connects is the
347
+ // team's choice, and a hint that names one product reads as the default.
348
+ console.log(c.gray("no tool declared — `doctrina workspace tool <name> --server <mcp-server> --for <roles>` (presets: " + Object.keys(TOOL_PRESETS).join(", ") + ")"));
348
349
  } else {
349
350
  console.log(c.bold("Tools"));
350
351
  for (const t of ws.tools) {
351
352
  console.log(` ${c.cyan(t.name.padEnd(12))} ${t.server.padEnd(12)} ${t.access.padEnd(6)} ${(t.for ?? []).join(", ").padEnd(16)} ${c.gray(t.purpose ?? "")}`);
352
353
  }
353
354
  }
355
+ // The orchestrator files carry what changes without a workspace write — a
356
+ // project absorbed in its own repository turns ready, an executor model
357
+ // edited into workspace.json (change 0228). The hub kept saying "pending"
358
+ // and the subagent kept `model: inherit` until the next add or tool.
359
+ // Listing refreshes them; only a file whose content differs is written.
360
+ regenerate(wsDir);
354
361
  return EXIT.OK;
355
362
  }
356
363
 
357
364
  export const help = `
358
- Usage: doctrina workspace <init|add|attach|detach|list> [options]
365
+ Usage: doctrina workspace <init|add|attach|detach|list|tool|guard> [options]
359
366
 
360
367
  The context of several repositories in one place (ADR 0029). Keep the
361
368
  workspace directory as a PRIVATE git repository: it versions the
@@ -373,16 +380,18 @@ artifacts of repositories that cannot carry .doctrina/ themselves.
373
380
  scaffolded under projects/<name>/, linked into the repository, and
374
381
  hidden from its git through .git/info/exclude.
375
382
 
376
- attach <name> [--path <dir>]
383
+ attach <name> [--path <dir>] [--depends-on <a,b>]
377
384
  Re-create a shadow's links (after cloning the workspace on another
378
- machine, or with --path after the repository moved).
385
+ machine, or with --path after the repository moved). --depends-on
386
+ replaces the projects it builds on — the order \`delivery route\` uses.
379
387
 
380
388
  detach <name>
381
389
  Remove a shadow's links and its exclude block; the shadow stays.
382
390
 
383
391
  list
384
392
  Every project with its mode, state (ready / pending / missing) and
385
- open changes, then the declared tools.
393
+ open changes, then the declared tools; refreshes the orchestrator
394
+ files when a project's state or workspace.json changed since.
386
395
 
387
396
  tool <name> [--server <mcp-name>] [--access read|write] [--for <roles>]
388
397
  [--purpose "<what for>"] [--remove]
@@ -397,11 +406,12 @@ artifacts of repositories that cannot carry .doctrina/ themselves.
397
406
  Claude Code PreToolUse): reads the call on stdin and answers allow,
398
407
  deny or ask from the declared access. Never blocks on bad input.
399
408
 
400
- Every write (init, add, attach, detach, tool) regenerates the orchestrator
401
- files at the workspace root: AGENTS.md, .cursor/rules/00-doctrina-
402
- workspace.mdc, .cursor/hooks.json and .claude/settings.json (merged),
403
- .claude/agents/doctrina-*.md (read by Claude Code and Cursor) and
404
- <name>.code-workspace, which opens every repository in one window.
409
+ Every write (init, add, attach, detach, tool) and every list regenerates
410
+ the orchestrator files at the workspace root: AGENTS.md,
411
+ .cursor/rules/00-doctrina-workspace.mdc, .cursor/hooks.json and
412
+ .claude/settings.json (merged), .claude/agents/doctrina-*.md (read by
413
+ Claude Code and Cursor) and <name>.code-workspace, which opens every
414
+ repository in one window.
405
415
 
406
416
  The workspace is found from $DOCTRINA_WORKSPACE, the current directory
407
417
  or an ancestor holding ${WORKSPACE_FILE}, a shadow's .doctrina link, or
package/src/index.js CHANGED
@@ -11,54 +11,52 @@ import { EXIT, exitCodeHelp } from "./lib/exit-codes.js";
11
11
  import { recordUsage, operationOf } from "./lib/usage.js";
12
12
  import { wantsJson, emitJson, captureOutput, stripAnsi, setDeprecation, deferJson, flushJson } from "./lib/json-out.js";
13
13
 
14
- import * as init from "./commands/init.js";
15
- import * as spec from "./commands/spec.js";
16
- import * as change from "./commands/change.js";
17
- import * as decision from "./commands/decision.js";
18
- import * as validate from "./commands/validate.js";
19
- import * as hooks from "./commands/hooks.js";
20
- import * as analyze from "./commands/analyze.js";
21
- import * as clarify from "./commands/clarify.js";
22
- import * as templates from "./commands/templates.js";
23
- import * as skill from "./commands/skill.js";
24
- import * as indexCmd from "./commands/index-rebuild.js";
25
- import * as next from "./commands/next.js";
26
- import * as metrics from "./commands/metrics.js";
27
- import * as context from "./commands/context.js";
28
- import * as search from "./commands/search.js";
29
- import * as intake from "./commands/intake.js";
30
- import * as work from "./commands/work.js";
31
- import * as coverage from "./commands/coverage.js";
32
- import * as verify from "./commands/verify.js";
33
- import * as contract from "./commands/contract.js";
34
- import * as trace from "./commands/trace.js";
35
- import * as status from "./commands/status.js";
36
- import * as close from "./commands/close.js";
37
- import * as review from "./commands/review.js";
38
- import * as watch from "./commands/watch.js";
39
- import * as why from "./commands/why.js";
40
- import * as prime from "./commands/prime.js";
41
- import * as handoff from "./commands/handoff.js";
42
- import * as show from "./commands/show.js";
43
- import * as doctor from "./commands/doctor.js";
44
- import * as report from "./commands/report.js";
45
- import * as completion from "./commands/completion.js";
46
- import * as intent from "./commands/intent.js";
47
- import * as upgrade from "./commands/upgrade.js";
48
- import * as adapter from "./commands/adapter.js";
49
- import * as triage from "./commands/triage.js";
50
- import * as ci from "./commands/ci.js";
51
- import * as workspace from "./commands/workspace.js";
52
- import * as delivery from "./commands/delivery.js";
53
14
 
15
+ // Each command is loaded when it is the one invoked (fifth audit, change
16
+ // 0236): the 39 static imports cost ~150 ms before any command ran, and
17
+ // `workspace guard` runs on EVERY MCP call an agent makes — a Playwright
18
+ // session's hundred clicks paid it a hundred times.
19
+ /** @type {Record<string, () => Promise<any>>} */
54
20
  const COMMANDS = {
55
- init, spec, change, decision, validate, hooks, analyze, clarify,
56
- templates, skill, index: indexCmd, next, metrics, context, search,
57
- intake, work, coverage, verify, contract, trace,
58
- status, close, review, watch, why,
59
- prime, handoff, show, doctor, report, completion,
60
- intent, upgrade, adapter, triage, ci,
61
- workspace, delivery,
21
+ init: () => import("./commands/init.js"),
22
+ spec: () => import("./commands/spec.js"),
23
+ change: () => import("./commands/change.js"),
24
+ decision: () => import("./commands/decision.js"),
25
+ validate: () => import("./commands/validate.js"),
26
+ hooks: () => import("./commands/hooks.js"),
27
+ analyze: () => import("./commands/analyze.js"),
28
+ clarify: () => import("./commands/clarify.js"),
29
+ templates: () => import("./commands/templates.js"),
30
+ skill: () => import("./commands/skill.js"),
31
+ index: () => import("./commands/index-rebuild.js"),
32
+ next: () => import("./commands/next.js"),
33
+ metrics: () => import("./commands/metrics.js"),
34
+ context: () => import("./commands/context.js"),
35
+ search: () => import("./commands/search.js"),
36
+ intake: () => import("./commands/intake.js"),
37
+ work: () => import("./commands/work.js"),
38
+ coverage: () => import("./commands/coverage.js"),
39
+ verify: () => import("./commands/verify.js"),
40
+ contract: () => import("./commands/contract.js"),
41
+ trace: () => import("./commands/trace.js"),
42
+ status: () => import("./commands/status.js"),
43
+ close: () => import("./commands/close.js"),
44
+ review: () => import("./commands/review.js"),
45
+ watch: () => import("./commands/watch.js"),
46
+ why: () => import("./commands/why.js"),
47
+ prime: () => import("./commands/prime.js"),
48
+ handoff: () => import("./commands/handoff.js"),
49
+ show: () => import("./commands/show.js"),
50
+ doctor: () => import("./commands/doctor.js"),
51
+ report: () => import("./commands/report.js"),
52
+ completion: () => import("./commands/completion.js"),
53
+ intent: () => import("./commands/intent.js"),
54
+ upgrade: () => import("./commands/upgrade.js"),
55
+ adapter: () => import("./commands/adapter.js"),
56
+ triage: () => import("./commands/triage.js"),
57
+ ci: () => import("./commands/ci.js"),
58
+ workspace: () => import("./commands/workspace.js"),
59
+ delivery: () => import("./commands/delivery.js"),
62
60
  };
63
61
 
64
62
  const TOP_HELP = `Usage: doctrina <command> [options] (\`doctrina <command> --help\` for its flags)
@@ -71,6 +69,15 @@ ${exitCodeHelp()}
71
69
  `;
72
70
 
73
71
  async function main(argv) {
72
+ // The MCP guard's own door (fifth audit, change 0236): it answers before
73
+ // every tool call an agent makes, so it loads the guard and its registry
74
+ // reader only. `--help` and `--json` take the ordinary path.
75
+ if (argv[0] === "workspace" && argv[1] === "guard" && !argv.some((a) => /^(--help|-h|--json|--debug)$/.test(a))) {
76
+ const at = argv.indexOf("--hook");
77
+ const hook = at >= 0 ? argv[at + 1] : argv.find((a) => a.startsWith("--hook="))?.slice("--hook=".length);
78
+ const { runGuard } = await import("./lib/workspace-guard.js");
79
+ return runGuard(hook);
80
+ }
74
81
  // `--version` is only "print the CLI version" when NO command is given.
75
82
  // With a command it is that command's flag to consume — the old global
76
83
  // intercept made `doctrina spec set x --version 0.10.0` print the CLI
@@ -89,7 +96,8 @@ async function main(argv) {
89
96
  // Declarations now live with the command, so a new command cannot
90
97
  // reintroduce the gap by forgetting to edit this file.
91
98
  const bootstrap = parseArgs(argv, { boolean: [...GLOBAL_FLAGS.boolean] });
92
- const commandModule = COMMANDS[bootstrap.positional[0]];
99
+ const loader = Object.hasOwn(COMMANDS, bootstrap.positional[0] ?? "") ? COMMANDS[bootstrap.positional[0]] : null;
100
+ const commandModule = loader ? await loader() : null;
93
101
  const spec = commandModule?.flags;
94
102
  const { positional, flags } = parseArgs(argv, {
95
103
  boolean: [...GLOBAL_FLAGS.boolean, ...(spec?.boolean ?? [])],
@@ -107,7 +115,8 @@ async function main(argv) {
107
115
  }
108
116
 
109
117
  const commandName = positional[0];
110
- const command = COMMANDS[commandName];
118
+ const command = commandName === bootstrap.positional[0] ? commandModule
119
+ : (Object.hasOwn(COMMANDS, commandName) ? await COMMANDS[commandName]() : null);
111
120
  const removed = removalFor(positional);
112
121
  if (removed) {
113
122
  const name = removed === removalFor([commandName]) ? commandName : positional.slice(0, 2).join(" ");
@@ -207,12 +216,13 @@ async function main(argv) {
207
216
  ? command.jsonNative(positional.slice(1))
208
217
  : command.jsonNative === true;
209
218
  if (wantsJson(flags) && !jsonNative) {
210
- const { code, stdout, stderr } = await captureOutput(
219
+ const { code, stdout, stderr, error } = await captureOutput(
211
220
  () => command.run(positional.slice(1), flags),
212
221
  );
213
222
  emitJson(operationName(positional), {
214
223
  stdout: stdout.map(stripAnsi),
215
224
  stderr: stderr.map(stripAnsi),
225
+ ...(error ? { error: String(error.message ?? error) } : {}),
216
226
  }, { ok: code === EXIT.OK, exitCode: code, args: operationArgs(positional) });
217
227
  return code;
218
228
  }
@@ -222,27 +232,53 @@ async function main(argv) {
222
232
  if (wantsJson(flags)) {
223
233
  deferJson();
224
234
  let code = EXIT.OK;
235
+ let thrown = null;
225
236
  try {
226
237
  code = (await command.run(positional.slice(1), flags)) ?? EXIT.OK;
227
- } finally {
228
- flushJson(code);
238
+ } catch (err) {
239
+ thrown = err;
240
+ code = err.exitCode ?? EXIT.GATE;
241
+ }
242
+ // A native command that failed before building its payload still
243
+ // answers in JSON (third audit, change 0234): "not a Doctrina project"
244
+ // or an unknown delivery printed English only, and a loop parsing the
245
+ // payload read nothing at all.
246
+ if (!flushJson(code)) {
247
+ emitJson(operationName(positional), {
248
+ error: thrown ? String(thrown.message) : "the command stopped before it built its payload — stderr says why",
249
+ ...(thrown?.remedy ? { remedy: thrown.remedy } : {}),
250
+ }, { ok: code === EXIT.OK, exitCode: code, args: operationArgs(positional) });
229
251
  }
252
+ if (thrown) await reportError(thrown, flags);
230
253
  return code;
231
254
  }
232
255
  return await command.run(positional.slice(1), flags);
233
256
  } catch (err) {
234
- console.error(c.red("error:") + ` ${err.message}`);
235
- if (err.remedy) {
236
- console.error(c.gray("hint: ") + `run ${c.cyan(err.remedy)} first, then retry`);
237
- }
238
- if (flags.get("debug") && err.stack) {
239
- console.error(c.gray(err.stack));
240
- }
241
- // A typed error carries its own class (precondition vs environment).
242
- // An untyped throw is an unexpected failure of the work itself, which
243
- // is the GATE class: something is wrong here, fix it and retry (C7).
244
- return err.exitCode ?? EXIT.GATE;
257
+ return await reportError(err, flags);
258
+ }
259
+ }
260
+
261
+ /** The human report of a thrown error, and the exit class it carries. */
262
+ async function reportError(err, flags) {
263
+ console.error(c.red("error:") + ` ${err.message}`);
264
+ // A clone of a shadowed repository is not a project to init (change 0234).
265
+ // Loaded only on this error path, like the commands (change 0236).
266
+ const view = /^not a Doctrina project/.test(String(err.message)) ? await import("./lib/workspace-view.js") : null;
267
+ const clone = view ? view.shadowedCloneAt(process.cwd()) : null;
268
+ if (clone) {
269
+ console.error(c.gray("hint: ") + (clone.same
270
+ ? `this repository is the workspace's shadowed "${clone.name}" and its links are missing; never \`doctrina init\` here — link them again: ${c.cyan(view?.attachCommand(clone, process.cwd()))}`
271
+ : `this repository is the workspace's shadowed "${clone.name}" (registered at ${clone.path}); never \`doctrina init\` here — to work from this clone, link its context from the workspace: ${c.cyan(view?.attachCommand(clone, process.cwd()))}`));
272
+ } else if (err.remedy) {
273
+ console.error(c.gray("hint: ") + `run ${c.cyan(err.remedy)} first, then retry`);
274
+ }
275
+ if (flags.get("debug") && err.stack) {
276
+ console.error(c.gray(err.stack));
245
277
  }
278
+ // A typed error carries its own class (precondition vs environment).
279
+ // An untyped throw is an unexpected failure of the work itself, which
280
+ // is the GATE class: something is wrong here, fix it and retry (C7).
281
+ return err.exitCode ?? EXIT.GATE;
246
282
  }
247
283
 
248
284
  // The OPERATION an invocation names, and the arguments it carries — the two
@@ -4,10 +4,13 @@ import { readdirSync } from "node:fs";
4
4
  import { isDir, isFile, read, relPath, walk } from "./fs-ops.js";
5
5
  import * as idx from "./index-json.js";
6
6
  import { deriveIndex, indexesMatch, listHeader, specHeader } from "./scan.js";
7
+ import { checklistProgress } from "./doc-model.js";
7
8
  import { collectRuntimeFindings } from "./runtime.js";
8
9
  import { FIX_SHAPED } from "./lexicon.js";
9
10
  import { summarize as coverageSummary } from "./coverage-model.js";
10
11
  import { summarize as traceSummary } from "./trace-model.js";
12
+ import { projectWorkspaceLine } from "./workspace-view.js";
13
+ import { cdThen, qarg } from "./shell.js";
11
14
 
12
15
  // What comes next, as DATA (change 0032).
13
16
  //
@@ -214,9 +217,19 @@ export function computeActions(projectRoot, gates = {}) {
214
217
  continue;
215
218
  }
216
219
  const tasksPath = path.join(changesDir, id, "tasks.md");
217
- const unchecked = isFile(tasksPath)
218
- ? (read(tasksPath).match(/^-\s+\[ \]/gm) ?? []).length
219
- : 0;
220
+ const progress = isFile(tasksPath) ? checklistProgress(read(tasksPath)) : { total: 0, done: 0, open: [], placeholders: 0 };
221
+ const unchecked = progress.total - progress.done;
222
+ if (progress.placeholders > 0) {
223
+ // Scaffold boxes are not open work (third audit, change 0234): "complete 3
224
+ // open tasks" sent an agent to do work no one had written down.
225
+ actions.push(action({
226
+ id: "change-tasks-unwritten",
227
+ command: "work",
228
+ args: ["--resume", id],
229
+ why: `write the ${unchecked} open task${unchecked === 1 ? "" : "s"} in .doctrina/changes/${id}/tasks.md first — ${progress.placeholders === unchecked ? (unchecked === 1 ? "it is" : "all are") : `${progress.placeholders} are`} still the scaffold's placeholder${progress.placeholders === 1 ? "" : "s"}, and the delta with them`,
230
+ }));
231
+ continue;
232
+ }
220
233
  if (unchecked > 0) {
221
234
  // Deliberately NOT runnable, and deliberately without a command:
222
235
  // the only thing that clears this is doing the work. `change tick`
@@ -253,6 +266,33 @@ export function computeActions(projectRoot, gates = {}) {
253
266
  }
254
267
  }
255
268
 
269
+ // A delivery open on this repository (second audit, change 0233). `prime`
270
+ // named it, `next` did not: after the close of this repository's part,
271
+ // "Next: doctrina next" answered with this project's own chores, and the
272
+ // planner lost the delivery that was still going on at the workspace.
273
+ let inWorkspace = null;
274
+ try {
275
+ inWorkspace = projectWorkspaceLine(projectRoot);
276
+ } catch {
277
+ inWorkspace = null;
278
+ }
279
+ for (const d of inWorkspace?.deliveries ?? []) {
280
+ if (d.changes.length === 0) {
281
+ actions.push(action({
282
+ id: "delivery-change-missing",
283
+ why: `delivery ${d.id} is routed to this repository and has no change here yet`,
284
+ text: `doctrina work ${qarg(d.title)} --delivery ${d.id} — delivery ${d.id} is routed to this repository and has no change here yet`,
285
+ }));
286
+ } else if (d.open === 0) {
287
+ actions.push(action({
288
+ id: "delivery-continues-at-workspace",
289
+ severity: "advisory",
290
+ why: `this repository's part of delivery ${d.id} is closed; the delivery goes on at the workspace`,
291
+ text: `${cdThen(inWorkspace?.dir ?? "", "doctrina next")} — this repository's part of delivery ${d.id} is closed; the delivery goes on at the workspace`,
292
+ }));
293
+ }
294
+ }
295
+
256
296
  // ADRs stuck in proposed need a human decision; accepted-but-bare ADRs are
257
297
  // the rot the review flagged — a decision with nothing behind it (no Evidence,
258
298
  // no Landed) is drift waiting to be superseded. Surface the proposed ones
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import { readdirSync } from "node:fs";
4
- import { exists, isDir, isFile, read, relPath, walk } from "./fs-ops.js";
4
+ import { exists, isDir, isFile, mkdirp, pruneEmptyDirs, read, relPath, remove, walk, write } from "./fs-ops.js";
5
5
  import { locateTemplatesDir, substitute } from "./templates.js";
6
6
 
7
7
  // Agent adapters: the thin per-agent files that point at AGENTS.md.
@@ -128,6 +128,68 @@ export function renderAdapterFile(file, tokens) {
128
128
 
129
129
  export { exists };
130
130
 
131
+ // Files an adapter USED to write, and the file that replaced each (change
132
+ // 0238). Cursor's documentation no longer describes `.cursor/commands/`;
133
+ // Cursor 2.4 converts slash commands to skills (`/migrate-to-skills`), with
134
+ // `disable-model-invocation: true` to keep them explicit — which is what
135
+ // the cursor adapter writes now, under `.cursor/skills/`.
136
+ export const RETIRED_ADAPTER_FILES = Object.freeze({
137
+ cursor: ["context", "next", "status", "why", "work"].map((x) => ({
138
+ retired: `.cursor/commands/doctrina-${x}.md`,
139
+ replacedBy: `.cursor/skills/doctrina-${x}/SKILL.md`,
140
+ })),
141
+ });
142
+
143
+ /**
144
+ * Moving an installed adapter off its retired files: the replacement is
145
+ * written where it is missing; a retired file still exactly as the CLI
146
+ * wrote it is removed; one edited since is kept and named, never deleted.
147
+ * @param {string} projectRoot
148
+ * @param {Record<string, string>} tokens as `adapter add` renders with
149
+ * @param {string} [root] where the files live: the shadow folder of a shadowed repository
150
+ */
151
+ export function planAdapterMigration(projectRoot, tokens, root = projectRoot) {
152
+ /** @type {{ writes: Array<{ relativePath: string, content: string }>, removes: string[], kept: string[] }} */
153
+ const plan = { writes: [], removes: [], kept: [] };
154
+ const norm = (/** @type {string} */ s) => s.replace(/\r\n/g, "\n").trim();
155
+ for (const [name, list] of Object.entries(RETIRED_ADAPTER_FILES)) {
156
+ const spec = adapterFiles(projectRoot, name);
157
+ if (!spec) continue;
158
+ for (const { retired, replacedBy } of list) {
159
+ if (!isFile(path.join(root, retired))) continue;
160
+ const file = spec.files.find((f) => f.relativePath === replacedBy);
161
+ if (!file) continue;
162
+ const rendered = renderAdapterFile(file, tokens);
163
+ if (!isFile(path.join(root, replacedBy))) plan.writes.push({ relativePath: replacedBy, content: rendered });
164
+ const body = rendered.replace(/^---\n[\s\S]*?\n---\n+/, "");
165
+ (norm(read(path.join(root, retired))) === norm(body) ? plan.removes : plan.kept).push(retired);
166
+ }
167
+ }
168
+ return plan;
169
+ }
170
+
171
+ /**
172
+ * Apply a migration plan under `root` — the project, or the shadow folder
173
+ * a shadowed repository's files live in. Returns the removed files with
174
+ * what they held, for the caller that re-links a shadow.
175
+ * @returns {Array<{ rel: string, content: string }>}
176
+ */
177
+ export function applyAdapterMigration(root, plan) {
178
+ for (const w of plan.writes) {
179
+ mkdirp(path.dirname(path.join(root, w.relativePath)));
180
+ write(path.join(root, w.relativePath), w.content, { force: true });
181
+ }
182
+ const gone = [];
183
+ for (const rel of plan.removes) {
184
+ const at = path.join(root, rel);
185
+ if (!isFile(at)) continue;
186
+ gone.push({ rel, content: read(at) });
187
+ remove(at);
188
+ pruneEmptyDirs(path.dirname(at), root);
189
+ }
190
+ return gone;
191
+ }
192
+
131
193
  // ---------------------------------------------------------------------------
132
194
  // Configuration files an adapter SHARES with the user (change 0211).
133
195
  //