doctrina-cli 0.20.0 → 0.22.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 (102) hide show
  1. package/README.md +7 -7
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +202 -27
  5. package/src/commands/clarify.js +3 -206
  6. package/src/commands/close.js +1 -1
  7. package/src/commands/context.js +33 -2
  8. package/src/commands/contract.js +1 -0
  9. package/src/commands/coverage.js +69 -6
  10. package/src/commands/delivery.js +135 -17
  11. package/src/commands/doctor.js +83 -5
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/hooks.js +56 -11
  14. package/src/commands/index-rebuild.js +47 -1
  15. package/src/commands/init.js +64 -8
  16. package/src/commands/intake.js +16 -5
  17. package/src/commands/metrics.js +16 -1
  18. package/src/commands/next.js +41 -6
  19. package/src/commands/onboard.js +113 -0
  20. package/src/commands/prime.js +53 -4
  21. package/src/commands/review.js +40 -4
  22. package/src/commands/search.js +2 -2
  23. package/src/commands/skill.js +2 -1
  24. package/src/commands/status.js +28 -0
  25. package/src/commands/templates.js +2 -1
  26. package/src/commands/triage.js +16 -1
  27. package/src/commands/ui.js +512 -0
  28. package/src/commands/upgrade.js +62 -4
  29. package/src/commands/validate.js +18 -0
  30. package/src/commands/verify.js +35 -7
  31. package/src/commands/why.js +1 -1
  32. package/src/commands/work.js +90 -42
  33. package/src/commands/workspace.js +89 -66
  34. package/src/index.js +107 -62
  35. package/src/lib/actions.js +62 -3
  36. package/src/lib/adapters.js +83 -1
  37. package/src/lib/agent-bridge.js +400 -0
  38. package/src/lib/applied-deltas.js +82 -0
  39. package/src/lib/args.js +34 -2
  40. package/src/lib/change-numbers.js +115 -0
  41. package/src/lib/change-ops.js +2 -2
  42. package/src/lib/clarify-scan.js +221 -0
  43. package/src/lib/code-inventory.js +594 -104
  44. package/src/lib/commands.js +58 -4
  45. package/src/lib/config.js +27 -8
  46. package/src/lib/conflict-markers.js +61 -0
  47. package/src/lib/console-model.js +668 -0
  48. package/src/lib/console-prefs.js +67 -0
  49. package/src/lib/console-room.js +201 -0
  50. package/src/lib/console-state.js +150 -0
  51. package/src/lib/console-view.js +557 -0
  52. package/src/lib/context-slice.js +34 -3
  53. package/src/lib/coverage-model.js +29 -6
  54. package/src/lib/delivery.js +90 -12
  55. package/src/lib/docs-impact.js +37 -2
  56. package/src/lib/fs-ops.js +103 -12
  57. package/src/lib/gates.js +7 -1
  58. package/src/lib/git.js +18 -0
  59. package/src/lib/index-json.js +10 -1
  60. package/src/lib/index-merge.js +90 -0
  61. package/src/lib/install.js +80 -0
  62. package/src/lib/intake-model.js +5 -2
  63. package/src/lib/json-out.js +9 -1
  64. package/src/lib/ledger.js +2 -3
  65. package/src/lib/lock.js +58 -0
  66. package/src/lib/merge-setup.js +55 -0
  67. package/src/lib/metrics-model.js +2 -2
  68. package/src/lib/onboard.js +197 -0
  69. package/src/lib/preset.js +177 -0
  70. package/src/lib/prompt.js +1 -1
  71. package/src/lib/properties.js +149 -0
  72. package/src/lib/runtime.js +19 -7
  73. package/src/lib/scan.js +13 -8
  74. package/src/lib/shell.js +45 -0
  75. package/src/lib/snapshot.js +5 -1
  76. package/src/lib/spec-ops.js +2 -2
  77. package/src/lib/task-card.js +101 -8
  78. package/src/lib/templates-model.js +3 -2
  79. package/src/lib/triage-model.js +75 -0
  80. package/src/lib/validation-model.js +23 -5
  81. package/src/lib/views.js +5 -4
  82. package/src/lib/work-model.js +8 -2
  83. package/src/lib/workspace-agents.js +67 -20
  84. package/src/lib/workspace-guard.js +65 -0
  85. package/src/lib/workspace-locate.js +141 -0
  86. package/src/lib/workspace-tools.js +104 -17
  87. package/src/lib/workspace-view.js +391 -0
  88. package/src/lib/workspace.js +83 -131
  89. package/templates/AGENTS.md.template +9 -5
  90. package/templates/README.md +4 -2
  91. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  92. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  93. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  94. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  95. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  96. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  97. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  98. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  99. package/templates/adapters/kiro/README.md +22 -0
  100. package/templates/playbooks/absorb.md.template +39 -16
  101. package/templates/playbooks/bootstrap.md.template +18 -6
  102. package/templates/playbooks/delivery.md.template +12 -6
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { exists, isDir, mkdirp, relPath, write } from "../lib/fs-ops.js";
8
- import { flagString } from "../lib/args.js";
8
+ import { flagString, flagGivenWithoutValue } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
10
10
  import { suggest } from "../lib/suggest.js";
11
11
  import { EXIT, PreconditionError } from "../lib/exit-codes.js";
@@ -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
@@ -26,7 +29,7 @@ const CLI_ENTRY = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
26
29
 
27
30
  export const flags = {
28
31
  boolean: ["json", "no-git", "remove"],
29
- string: ["name", "mode", "role", "about", "depends-on", "agent", "path", "server", "access", "for", "purpose", "hook"],
32
+ string: ["name", "mode", "role", "about", "depends-on", "agent", "path", "server", "access", "for", "purpose", "only", "hook"],
30
33
  };
31
34
 
32
35
  export async function run(positional, flags) {
@@ -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
  }
@@ -263,11 +290,18 @@ function workspaceTool(wsDir, name, flags) {
263
290
  console.log(c.green("removed") + ` tool ${name}`);
264
291
  return EXIT.OK;
265
292
  }
293
+ // `--only` with nothing after it is not "no list": taking it for one
294
+ // removed the list a strict tool had, with exit 0 (change 0250).
295
+ if (flagGivenWithoutValue(flags, "only")) {
296
+ console.error(c.red("error:") + " --only needs the calls it allows, e.g. --only \"getJiraIssue,search*\" — or --only none to take the list away");
297
+ return EXIT.USAGE;
298
+ }
266
299
  let entry;
267
300
  try {
268
301
  entry = toolEntry(name, {
269
302
  server: flagString(flags, "server"), access: flagString(flags, "access"),
270
303
  for: flagString(flags, "for"), purpose: flagString(flags, "purpose"),
304
+ only: flags.has("only") ? flagString(flags, "only") ?? "" : undefined,
271
305
  }, at >= 0 ? ws.tools[at] : null);
272
306
  } catch (err) {
273
307
  console.error(c.red("error:") + ` ${err.message}`);
@@ -277,47 +311,20 @@ function workspaceTool(wsDir, name, flags) {
277
311
  else ws.tools.push(entry);
278
312
  writeWorkspace(wsDir, ws);
279
313
  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"}`);
314
+ if (entry.only) console.log(c.gray(`only these calls pass through "${entry.server}": ${entry.only.join(", ")} — every other call is denied`));
280
315
  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}"`));
316
+ // Said now, not only at the next validate (change 0235): a key that matches
317
+ // no configuration is a guard that never fires.
318
+ const servers = mcpServerNames([wsDir, ...ws.projects.map((p) => p.path)]);
319
+ if (servers.found && !servers.names.has(entry.server)) {
320
+ 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>`);
321
+ }
281
322
  return EXIT.OK;
282
323
  }
283
324
 
284
325
  // Reads the agent's pre-call payload on stdin and answers in the agent's
285
326
  // own format. Never exits non-zero and never blocks on bad input: a broken
286
327
  // 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
328
 
322
329
  function workspaceList(wsDir) {
323
330
  const ws = readWorkspace(wsDir);
@@ -340,22 +347,31 @@ function workspaceList(wsDir) {
340
347
  const pending = ws.projects.filter((p) => projectState(wsDir, p).state === "pending");
341
348
  if (pending.length > 0) {
342
349
  console.log("");
343
- console.log(`Next: absorb ${pending.map((p) => p.name).join(", ")} — ` + c.cyan(`cd ${pending[0].path} && doctrina intake --from-code`));
350
+ console.log(`Next: absorb ${pending.map((p) => p.name).join(", ")} — ` + c.cyan(cdThen(pending[0].path, "doctrina intake --from-code")));
344
351
  }
345
352
  console.log("");
346
353
  if (ws.tools.length === 0) {
347
- console.log(c.gray("no tool declared — `doctrina workspace tool jira` (presets: " + Object.keys(TOOL_PRESETS).join(", ") + ")"));
354
+ // Named generically (change 0228): which tools a team connects is the
355
+ // team's choice, and a hint that names one product reads as the default.
356
+ console.log(c.gray("no tool declared — `doctrina workspace tool <name> --server <mcp-server> --for <roles>` (presets: " + Object.keys(TOOL_PRESETS).join(", ") + ")"));
348
357
  } else {
349
358
  console.log(c.bold("Tools"));
350
359
  for (const t of ws.tools) {
351
360
  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 ?? "")}`);
361
+ if (t.only?.length) console.log(c.gray(` ${"".padEnd(12)} only: ${t.only.join(", ")}`));
352
362
  }
353
363
  }
364
+ // The orchestrator files carry what changes without a workspace write — a
365
+ // project absorbed in its own repository turns ready, an executor model
366
+ // edited into workspace.json (change 0228). The hub kept saying "pending"
367
+ // and the subagent kept `model: inherit` until the next add or tool.
368
+ // Listing refreshes them; only a file whose content differs is written.
369
+ regenerate(wsDir);
354
370
  return EXIT.OK;
355
371
  }
356
372
 
357
373
  export const help = `
358
- Usage: doctrina workspace <init|add|attach|detach|list> [options]
374
+ Usage: doctrina workspace <init|add|attach|detach|list|tool|guard> [options]
359
375
 
360
376
  The context of several repositories in one place (ADR 0029). Keep the
361
377
  workspace directory as a PRIVATE git repository: it versions the
@@ -373,35 +389,42 @@ artifacts of repositories that cannot carry .doctrina/ themselves.
373
389
  scaffolded under projects/<name>/, linked into the repository, and
374
390
  hidden from its git through .git/info/exclude.
375
391
 
376
- attach <name> [--path <dir>]
392
+ attach <name> [--path <dir>] [--depends-on <a,b>]
377
393
  Re-create a shadow's links (after cloning the workspace on another
378
- machine, or with --path after the repository moved).
394
+ machine, or with --path after the repository moved). --depends-on
395
+ replaces the projects it builds on — the order \`delivery route\` uses.
379
396
 
380
397
  detach <name>
381
398
  Remove a shadow's links and its exclude block; the shadow stays.
382
399
 
383
400
  list
384
401
  Every project with its mode, state (ready / pending / missing) and
385
- open changes, then the declared tools.
402
+ open changes, then the declared tools; refreshes the orchestrator
403
+ files when a project's state or workspace.json changed since.
386
404
 
387
405
  tool <name> [--server <mcp-name>] [--access read|write] [--for <roles>]
388
- [--purpose "<what for>"] [--remove]
406
+ [--purpose "<what for>"] [--only "<calls>"] [--remove]
389
407
  Declare (or update, or --remove) a tool the work uses. Presets:
390
408
  jira, confluence, figma, playwright, datadog, databricks, sql.
391
409
  Roles: planner, executor, qa, observer, reviewer. Access defaults
392
410
  to read: the guard refuses a write through a read-only tool and
393
411
  makes a declared write wait for the human.
412
+ --only "getJiraIssue,search*" lists the calls the tool may make,
413
+ by MCP tool name (\`*\` for any run of characters): every other
414
+ call through its server is denied, instead of being judged by the
415
+ verb in its name. --only none takes the list away.
394
416
 
395
417
  guard --hook cursor|claude
396
418
  The pre-call hook the workspace installs (Cursor beforeMCPExecution,
397
419
  Claude Code PreToolUse): reads the call on stdin and answers allow,
398
420
  deny or ask from the declared access. Never blocks on bad input.
399
421
 
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.
422
+ Every write (init, add, attach, detach, tool) and every list regenerates
423
+ the orchestrator files at the workspace root: AGENTS.md,
424
+ .cursor/rules/00-doctrina-workspace.mdc, .cursor/hooks.json and
425
+ .claude/settings.json (merged), .claude/agents/doctrina-*.md (read by
426
+ Claude Code and Cursor) and <name>.code-workspace, which opens every
427
+ repository in one window.
405
428
 
406
429
  The workspace is found from $DOCTRINA_WORKSPACE, the current directory
407
430
  or an ancestor holding ${WORKSPACE_FILE}, a shadow's .doctrina link, or
package/src/index.js CHANGED
@@ -11,54 +11,54 @@ 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
+ onboard: () => import("./commands/onboard.js"),
38
+ ui: () => import("./commands/ui.js"),
39
+ work: () => import("./commands/work.js"),
40
+ coverage: () => import("./commands/coverage.js"),
41
+ verify: () => import("./commands/verify.js"),
42
+ contract: () => import("./commands/contract.js"),
43
+ trace: () => import("./commands/trace.js"),
44
+ status: () => import("./commands/status.js"),
45
+ close: () => import("./commands/close.js"),
46
+ review: () => import("./commands/review.js"),
47
+ watch: () => import("./commands/watch.js"),
48
+ why: () => import("./commands/why.js"),
49
+ prime: () => import("./commands/prime.js"),
50
+ handoff: () => import("./commands/handoff.js"),
51
+ show: () => import("./commands/show.js"),
52
+ doctor: () => import("./commands/doctor.js"),
53
+ report: () => import("./commands/report.js"),
54
+ completion: () => import("./commands/completion.js"),
55
+ intent: () => import("./commands/intent.js"),
56
+ upgrade: () => import("./commands/upgrade.js"),
57
+ adapter: () => import("./commands/adapter.js"),
58
+ triage: () => import("./commands/triage.js"),
59
+ ci: () => import("./commands/ci.js"),
60
+ workspace: () => import("./commands/workspace.js"),
61
+ delivery: () => import("./commands/delivery.js"),
62
62
  };
63
63
 
64
64
  const TOP_HELP = `Usage: doctrina <command> [options] (\`doctrina <command> --help\` for its flags)
@@ -71,6 +71,15 @@ ${exitCodeHelp()}
71
71
  `;
72
72
 
73
73
  async function main(argv) {
74
+ // The MCP guard's own door (fifth audit, change 0236): it answers before
75
+ // every tool call an agent makes, so it loads the guard and its registry
76
+ // reader only. `--help` and `--json` take the ordinary path.
77
+ if (argv[0] === "workspace" && argv[1] === "guard" && !argv.some((a) => /^(--help|-h|--json|--debug)$/.test(a))) {
78
+ const at = argv.indexOf("--hook");
79
+ const hook = at >= 0 ? argv[at + 1] : argv.find((a) => a.startsWith("--hook="))?.slice("--hook=".length);
80
+ const { runGuard } = await import("./lib/workspace-guard.js");
81
+ return runGuard(hook);
82
+ }
74
83
  // `--version` is only "print the CLI version" when NO command is given.
75
84
  // With a command it is that command's flag to consume — the old global
76
85
  // intercept made `doctrina spec set x --version 0.10.0` print the CLI
@@ -89,7 +98,8 @@ async function main(argv) {
89
98
  // Declarations now live with the command, so a new command cannot
90
99
  // reintroduce the gap by forgetting to edit this file.
91
100
  const bootstrap = parseArgs(argv, { boolean: [...GLOBAL_FLAGS.boolean] });
92
- const commandModule = COMMANDS[bootstrap.positional[0]];
101
+ const loader = Object.hasOwn(COMMANDS, bootstrap.positional[0] ?? "") ? COMMANDS[bootstrap.positional[0]] : null;
102
+ const commandModule = loader ? await loader() : null;
93
103
  const spec = commandModule?.flags;
94
104
  const { positional, flags } = parseArgs(argv, {
95
105
  boolean: [...GLOBAL_FLAGS.boolean, ...(spec?.boolean ?? [])],
@@ -102,12 +112,20 @@ async function main(argv) {
102
112
  }
103
113
 
104
114
  if (positional.length === 0) {
115
+ // In a terminal, `doctrina` alone opens the console (change 0257): a
116
+ // person who typed the name and nothing else is asking where to start.
117
+ // A pipe, a script or an agent still gets the help it always got.
118
+ if (argv.length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
119
+ const ui = await COMMANDS.ui();
120
+ return ui.run([], new Map());
121
+ }
105
122
  process.stdout.write(TOP_HELP);
106
123
  return 0;
107
124
  }
108
125
 
109
126
  const commandName = positional[0];
110
- const command = COMMANDS[commandName];
127
+ const command = commandName === bootstrap.positional[0] ? commandModule
128
+ : (Object.hasOwn(COMMANDS, commandName) ? await COMMANDS[commandName]() : null);
111
129
  const removed = removalFor(positional);
112
130
  if (removed) {
113
131
  const name = removed === removalFor([commandName]) ? commandName : positional.slice(0, 2).join(" ");
@@ -207,12 +225,13 @@ async function main(argv) {
207
225
  ? command.jsonNative(positional.slice(1))
208
226
  : command.jsonNative === true;
209
227
  if (wantsJson(flags) && !jsonNative) {
210
- const { code, stdout, stderr } = await captureOutput(
228
+ const { code, stdout, stderr, error } = await captureOutput(
211
229
  () => command.run(positional.slice(1), flags),
212
230
  );
213
231
  emitJson(operationName(positional), {
214
232
  stdout: stdout.map(stripAnsi),
215
233
  stderr: stderr.map(stripAnsi),
234
+ ...(error ? { error: String(error.message ?? error) } : {}),
216
235
  }, { ok: code === EXIT.OK, exitCode: code, args: operationArgs(positional) });
217
236
  return code;
218
237
  }
@@ -222,27 +241,53 @@ async function main(argv) {
222
241
  if (wantsJson(flags)) {
223
242
  deferJson();
224
243
  let code = EXIT.OK;
244
+ let thrown = null;
225
245
  try {
226
246
  code = (await command.run(positional.slice(1), flags)) ?? EXIT.OK;
227
- } finally {
228
- flushJson(code);
247
+ } catch (err) {
248
+ thrown = err;
249
+ code = err.exitCode ?? EXIT.GATE;
250
+ }
251
+ // A native command that failed before building its payload still
252
+ // answers in JSON (third audit, change 0234): "not a Doctrina project"
253
+ // or an unknown delivery printed English only, and a loop parsing the
254
+ // payload read nothing at all.
255
+ if (!flushJson(code)) {
256
+ emitJson(operationName(positional), {
257
+ error: thrown ? String(thrown.message) : "the command stopped before it built its payload — stderr says why",
258
+ ...(thrown?.remedy ? { remedy: thrown.remedy } : {}),
259
+ }, { ok: code === EXIT.OK, exitCode: code, args: operationArgs(positional) });
229
260
  }
261
+ if (thrown) await reportError(thrown, flags);
230
262
  return code;
231
263
  }
232
264
  return await command.run(positional.slice(1), flags);
233
265
  } 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;
266
+ return await reportError(err, flags);
267
+ }
268
+ }
269
+
270
+ /** The human report of a thrown error, and the exit class it carries. */
271
+ async function reportError(err, flags) {
272
+ console.error(c.red("error:") + ` ${err.message}`);
273
+ // A clone of a shadowed repository is not a project to init (change 0234).
274
+ // Loaded only on this error path, like the commands (change 0236).
275
+ const view = /^not a Doctrina project/.test(String(err.message)) ? await import("./lib/workspace-view.js") : null;
276
+ const clone = view ? view.shadowedCloneAt(process.cwd()) : null;
277
+ if (clone) {
278
+ console.error(c.gray("hint: ") + (clone.same
279
+ ? `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()))}`
280
+ : `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()))}`));
281
+ } else if (err.remedy) {
282
+ console.error(c.gray("hint: ") + `run ${c.cyan(err.remedy)} first, then retry`);
283
+ }
284
+ if (flags.get("debug") && err.stack) {
285
+ console.error(c.gray(err.stack));
245
286
  }
287
+ // A typed error carries its own class (precondition vs environment).
288
+ // An untyped throw is an unexpected failure of the work itself, which
289
+ // is the GATE class: something is wrong here, fix it and retry (C7).
290
+ return err.exitCode ?? EXIT.GATE;
246
291
  }
247
292
 
248
293
  // The OPERATION an invocation names, and the arguments it carries — the two
@@ -1,13 +1,17 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
+ import { conflictedArtifacts } from "./conflict-markers.js";
3
4
  import { readdirSync } from "node:fs";
4
5
  import { isDir, isFile, read, relPath, walk } from "./fs-ops.js";
5
6
  import * as idx from "./index-json.js";
6
7
  import { deriveIndex, indexesMatch, listHeader, specHeader } from "./scan.js";
8
+ import { checklistProgress } from "./doc-model.js";
7
9
  import { collectRuntimeFindings } from "./runtime.js";
8
10
  import { FIX_SHAPED } from "./lexicon.js";
9
11
  import { summarize as coverageSummary } from "./coverage-model.js";
10
12
  import { summarize as traceSummary } from "./trace-model.js";
13
+ import { projectWorkspaceLine } from "./workspace-view.js";
14
+ import { cdThen, qarg } from "./shell.js";
11
15
 
12
16
  // What comes next, as DATA (change 0032).
13
17
  //
@@ -95,6 +99,24 @@ export function computeActions(projectRoot, gates = {}) {
95
99
  /** @type {Action[]} */
96
100
  const actions = [];
97
101
 
102
+ // A MERGE THAT STOPPED INSIDE AN ARTIFACT comes before everything (change
103
+ // 0250): every action below is computed from files that hold both halves
104
+ // of a conflict, and the next thing an agent does with a spec is read it.
105
+ try {
106
+ const conflicted = conflictedArtifacts(projectRoot);
107
+ if (conflicted.length > 0) {
108
+ const first = path.relative(projectRoot, conflicted[0].file).split(path.sep).join("/");
109
+ actions.push(action({
110
+ id: "merge-conflict",
111
+ command: "validate",
112
+ gate: "structure",
113
+ runnable: false,
114
+ why: `${conflicted.length} artifact${conflicted.length === 1 ? " carries" : "s carry"} git's merge conflict markers (first: ${first}:${conflicted[0].line})`,
115
+ text: `resolve the merge conflict in ${first} (line ${conflicted[0].line})${conflicted.length > 1 ? ` and ${conflicted.length - 1} more` : ""} — keep what both branches meant and delete the marker lines; \`doctrina validate\` names each file`,
116
+ }));
117
+ }
118
+ } catch { /* an unreadable tree is reported by the checks below */ }
119
+
98
120
  // RUNTIME FIRST (change 0029). A declared wiring that does not hold is
99
121
  // not a queue item — it is the reason the last run lied. After a job goes
100
122
  // green having executed nothing, "open a change on the observability
@@ -214,9 +236,19 @@ export function computeActions(projectRoot, gates = {}) {
214
236
  continue;
215
237
  }
216
238
  const tasksPath = path.join(changesDir, id, "tasks.md");
217
- const unchecked = isFile(tasksPath)
218
- ? (read(tasksPath).match(/^-\s+\[ \]/gm) ?? []).length
219
- : 0;
239
+ const progress = isFile(tasksPath) ? checklistProgress(read(tasksPath)) : { total: 0, done: 0, open: [], placeholders: 0 };
240
+ const unchecked = progress.total - progress.done;
241
+ if (progress.placeholders > 0) {
242
+ // Scaffold boxes are not open work (third audit, change 0234): "complete 3
243
+ // open tasks" sent an agent to do work no one had written down.
244
+ actions.push(action({
245
+ id: "change-tasks-unwritten",
246
+ command: "work",
247
+ args: ["--resume", id],
248
+ 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`,
249
+ }));
250
+ continue;
251
+ }
220
252
  if (unchecked > 0) {
221
253
  // Deliberately NOT runnable, and deliberately without a command:
222
254
  // the only thing that clears this is doing the work. `change tick`
@@ -253,6 +285,33 @@ export function computeActions(projectRoot, gates = {}) {
253
285
  }
254
286
  }
255
287
 
288
+ // A delivery open on this repository (second audit, change 0233). `prime`
289
+ // named it, `next` did not: after the close of this repository's part,
290
+ // "Next: doctrina next" answered with this project's own chores, and the
291
+ // planner lost the delivery that was still going on at the workspace.
292
+ let inWorkspace = null;
293
+ try {
294
+ inWorkspace = projectWorkspaceLine(projectRoot);
295
+ } catch {
296
+ inWorkspace = null;
297
+ }
298
+ for (const d of inWorkspace?.deliveries ?? []) {
299
+ if (d.changes.length === 0) {
300
+ actions.push(action({
301
+ id: "delivery-change-missing",
302
+ why: `delivery ${d.id} is routed to this repository and has no change here yet`,
303
+ text: `doctrina work ${qarg(d.title)} --delivery ${d.id} — delivery ${d.id} is routed to this repository and has no change here yet`,
304
+ }));
305
+ } else if (d.open === 0) {
306
+ actions.push(action({
307
+ id: "delivery-continues-at-workspace",
308
+ severity: "advisory",
309
+ why: `this repository's part of delivery ${d.id} is closed; the delivery goes on at the workspace`,
310
+ text: `${cdThen(inWorkspace?.dir ?? "", "doctrina next")} — this repository's part of delivery ${d.id} is closed; the delivery goes on at the workspace`,
311
+ }));
312
+ }
313
+ }
314
+
256
315
  // ADRs stuck in proposed need a human decision; accepted-but-bare ADRs are
257
316
  // the rot the review flagged — a decision with nothing behind it (no Evidence,
258
317
  // no Landed) is drift waiting to be superseded. Surface the proposed ones