doctrina-cli 0.18.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 (68) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +84 -0
  4. package/src/commands/analyze.js +14 -3
  5. package/src/commands/change.js +164 -17
  6. package/src/commands/clarify.js +1 -204
  7. package/src/commands/close.js +18 -1
  8. package/src/commands/context.js +81 -9
  9. package/src/commands/contract.js +1 -0
  10. package/src/commands/delivery.js +356 -0
  11. package/src/commands/doctor.js +21 -1
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/index-rebuild.js +23 -0
  14. package/src/commands/init.js +28 -1
  15. package/src/commands/intake.js +60 -3
  16. package/src/commands/metrics.js +43 -3
  17. package/src/commands/next.js +41 -6
  18. package/src/commands/prime.js +125 -7
  19. package/src/commands/review.js +39 -3
  20. package/src/commands/skill.js +2 -1
  21. package/src/commands/status.js +28 -0
  22. package/src/commands/upgrade.js +33 -0
  23. package/src/commands/validate.js +18 -0
  24. package/src/commands/verify.js +32 -4
  25. package/src/commands/work.js +126 -7
  26. package/src/commands/workspace.js +419 -0
  27. package/src/index.js +100 -66
  28. package/src/lib/actions.js +44 -4
  29. package/src/lib/adapters.js +143 -1
  30. package/src/lib/args.js +33 -1
  31. package/src/lib/clarify-scan.js +221 -0
  32. package/src/lib/code-inventory.js +770 -0
  33. package/src/lib/commands.js +52 -5
  34. package/src/lib/context-slice.js +149 -0
  35. package/src/lib/delivery.js +286 -0
  36. package/src/lib/docs-impact.js +36 -1
  37. package/src/lib/fs-ops.js +23 -0
  38. package/src/lib/intake-model.js +1 -1
  39. package/src/lib/json-out.js +9 -1
  40. package/src/lib/metrics-model.js +23 -1
  41. package/src/lib/playbook.js +1 -1
  42. package/src/lib/shell.js +45 -0
  43. package/src/lib/skill-export.js +95 -0
  44. package/src/lib/snapshot.js +43 -2
  45. package/src/lib/task-card.js +195 -0
  46. package/src/lib/views.js +52 -3
  47. package/src/lib/work-model.js +66 -2
  48. package/src/lib/workspace-agents.js +306 -0
  49. package/src/lib/workspace-guard.js +65 -0
  50. package/src/lib/workspace-locate.js +141 -0
  51. package/src/lib/workspace-tools.js +160 -0
  52. package/src/lib/workspace-view.js +391 -0
  53. package/src/lib/workspace.js +323 -0
  54. package/templates/AGENTS.md.template +17 -14
  55. package/templates/README.md +11 -1
  56. package/templates/adapters/claude/.claude/settings.json.template +18 -0
  57. package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
  58. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
  59. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  60. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  61. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  62. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  63. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  64. package/templates/delivery.md.template +39 -0
  65. package/templates/playbooks/absorb.md.template +63 -0
  66. package/templates/playbooks/bootstrap.md.template +18 -6
  67. package/templates/playbooks/delivery.md.template +44 -0
  68. package/templates/playbooks/work.md.template +1 -1
@@ -2,20 +2,24 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { readdirSync } from "node:fs";
5
- import { exists, isDir, isFile, mkdirp, read, relPath, write } from "../lib/fs-ops.js";
5
+ import { eolOf, exists, isDir, isFile, mkdirp, read, relPath, withEol, write } from "../lib/fs-ops.js";
6
6
  import { flagString, flagBool } from "../lib/args.js";
7
7
  import * as idx from "../lib/index-json.js";
8
8
  import { c } from "../lib/colors.js";
9
9
  import { assessBrief } from "../lib/clarity.js";
10
10
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
11
+ import { findWorkspace, projectAt, readWorkspace } from "../lib/workspace.js";
12
+ import { readDelivery, resolveDeliveryId, sectionText } from "../lib/delivery.js";
13
+ import { parseLedger } from "../lib/ledger.js";
11
14
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
15
+ import { listHeader } from "../lib/scan.js";
12
16
  import { changeNew, reindexChange, addAffectedSpec } from "../lib/change-ops.js";
13
17
  import { classify } from "../lib/triage-model.js";
14
18
  import { printPlaybookTemplate } from "../lib/playbook.js";
15
19
  import { specShape } from "../lib/spec-ops.js";
16
20
  import { changedFiles } from "../lib/git.js";
17
21
  import { slugFromPrompt, fold, CONFIDENT_MARGIN } from "../lib/lexicon.js";
18
- import { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
22
+ import { rankCapabilities, rankCapabilitiesByDiff, similarChanges, SIMILAR_CHANGE_DAYS } from "../lib/work-model.js";
19
23
  import { isChangeId, refuseChangeRef } from "../lib/project.js";
20
24
  export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
21
25
 
@@ -30,7 +34,7 @@ export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
30
34
  // Flags this command accepts. Declared HERE, with the command, so
31
35
  // adding a command never requires editing the entrypoint — the gap that
32
36
  // let six flags ship undeclared and silently swallow a positional (C3).
33
- export const flags = { boolean: ["json", "chore", "design", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "consolidate", "id", "resume", "title"] };
37
+ export const flags = { boolean: ["json", "chore", "design", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "consolidate", "delivery", "id", "resume", "title"] };
34
38
 
35
39
  export async function run(positional, flags) {
36
40
  const projectRoot = process.cwd();
@@ -61,11 +65,27 @@ export async function run(positional, flags) {
61
65
  ? `Consolidate the ${consolidate} spec: merge requirements and criteria that say the same thing, drop what a later one superseded — no behaviour changes.`
62
66
  : positional.join(" ").trim();
63
67
 
68
+ // --delivery <id> (change 0222): the change is this repository's part of a
69
+ // workspace delivery. Checked BEFORE anything is scaffolded, so a typo or a
70
+ // repository the delivery was never routed to opens nothing.
71
+ let delivery = null;
72
+ let deliveryStory = "";
73
+ if (flags.has("delivery")) {
74
+ const resolved = resolveDeliveryFor(projectRoot, flagString(flags, "delivery"));
75
+ if (resolved.error) {
76
+ console.error(c.red("error:") + ` ${resolved.error}`);
77
+ if (resolved.hint) console.error(c.gray("hint: ") + resolved.hint);
78
+ return resolved.code;
79
+ }
80
+ delivery = resolved.id;
81
+ deliveryStory = resolved.story ?? "";
82
+ }
83
+
64
84
  // --resume <id>: reprint the playbook for an existing open change rather than
65
85
  // open a new one (review G1 — `work "continue"` used to slug the word and
66
86
  // fabricate a junk `NNNN-continue` change the operator had to delete).
67
87
  if (flags.has("resume")) {
68
- return resumeChange(projectRoot, flagString(flags, "resume"));
88
+ return resumeChange(projectRoot, flagString(flags, "resume"), delivery);
69
89
  }
70
90
 
71
91
  // --from-diff backfills from code already written (review F8): the
@@ -101,6 +121,29 @@ export async function run(positional, flags) {
101
121
  }
102
122
  }
103
123
 
124
+ // DUPLICATE CHECK (change 0215). A request that mostly repeats a change
125
+ // already open, or archived in the last two weeks, is held before a second
126
+ // copy is scaffolded: an archived change is out of every default read, and
127
+ // a field review found the person asking for "the doctrina work" of work
128
+ // already closed. A term match can be wrong, so --force proceeds.
129
+ if (!fromDiff && !consolidate && prompt && !flagBool(flags, "force", false)) {
130
+ const similar = similarChanges(projectRoot, prompt);
131
+ if (similar.length > 0) {
132
+ console.error(c.yellow("hold:") + " this request reads like a change that already exists:");
133
+ for (const s of similar.slice(0, 3)) {
134
+ console.error(` ${c.cyan(s.id)} ${s.title}` + c.gray(` — ${s.where} (${s.path}); shared: ${s.shared.join(", ")}`));
135
+ }
136
+ console.error("");
137
+ const top = similar[0];
138
+ console.error(top.where === "archived"
139
+ ? `If it is the same work, it is done: tell the human it is archived at ${top.path}.`
140
+ : `If it is the same work, continue it: ${c.cyan(`doctrina work --resume ${top.id}`)}`);
141
+ console.error(c.gray("If it is new work, open it anyway: ") + c.cyan(`doctrina work --force "<request>"`) +
142
+ c.gray(` (open changes and the last ${SIMILAR_CHANGE_DAYS} days of the archive are compared)`));
143
+ return EXIT.PRECONDITION;
144
+ }
145
+ }
146
+
104
147
  // LANE CHECK (change 0029). `work` was the default answer to every
105
148
  // request, so a broken workflow or a suite that ran nothing became a
106
149
  // change with a proposal, tasks and a spec delta — ceremony spent on a
@@ -195,14 +238,20 @@ export async function run(positional, flags) {
195
238
  if (pinned) {
196
239
  updated = updated.replace(/^(-\s+\*\*Affects specs:\*\*).*$/m, `$1 ${pinned}`);
197
240
  }
198
- if (updated !== txt) write(proposalPath, updated, { force: true });
241
+ if (delivery) {
242
+ updated = updated.replace(/^(-[ \t]+\*\*Lane:\*\*[^\r\n]*)/m, `$1\n- **Delivery:** ${delivery}`);
243
+ }
244
+ if (updated !== txt) write(proposalPath, withEol(updated, eolOf(txt)), { force: true });
199
245
  }
200
246
 
201
247
  // Capability hint: term overlap for a prompt, changed-file overlap for a diff
202
248
  // (review F10 — rank by what the working tree touched, not just prompt words).
203
249
  const allChanged = fromDiff ? files : changedFiles(projectRoot).files;
204
250
  const diffMatches = pinned ? [] : rankCapabilitiesByDiff(projectRoot, allChanged);
205
- const promptMatches = pinned || fromDiff ? [] : rankCapabilities(projectRoot, effPrompt);
251
+ // A delivery's change is ranked by its story too (second audit, change
252
+ // 0233): the title of a tracker item rarely names the capability — "lock
253
+ // the account after 3 wrong passwords" — while its story says "login".
254
+ const promptMatches = pinned || fromDiff ? [] : rankCapabilities(projectRoot, deliveryStory ? `${effPrompt}\n${deliveryStory}` : effPrompt);
206
255
  const matches = pinned ? [] : (fromDiff ? diffMatches : promptMatches);
207
256
  const capability = pinned ?? matches[0]?.id ?? null;
208
257
  const clarity = assessBrief(effPrompt, { kind: "prompt" });
@@ -262,6 +311,12 @@ export async function run(positional, flags) {
262
311
  fromDiff, diffMatches: fromDiff ? [] : diffMatches,
263
312
  });
264
313
  }
314
+ // The sentence the human needs (change 0214). A field review: the change
315
+ // existed only in the terminal, so the person asking saw code appear with
316
+ // no Doctrina trace — and later asked for "the doctrina work" of work
317
+ // already closed. The agent repeats this line; the CLI writes it.
318
+ console.log("");
319
+ console.log(c.bold("Tell the human: ") + `opened change ${id} — "${title}" (.doctrina/changes/${id}/)`);
265
320
  return 0;
266
321
  }
267
322
 
@@ -332,6 +387,28 @@ function laneRecord(verdict, { forced, chore, fromDiff }) {
332
387
  return line;
333
388
  }
334
389
 
390
+ // The delivery a `--delivery` names, from this repository's workspace: it
391
+ // must exist, and this repository must be one of the projects it was routed to.
392
+ function resolveDeliveryFor(projectRoot, ref) {
393
+ const found = findWorkspace(projectRoot);
394
+ if (!found) {
395
+ return { error: "--delivery needs a workspace, and this repository belongs to none", hint: "register it: `doctrina workspace add <path>` from the workspace", code: EXIT.PRECONDITION };
396
+ }
397
+ const id = resolveDeliveryId(found.dir, ref);
398
+ if (!id) return { error: `no delivery "${ref ?? ""}" in the workspace at ${found.dir}`, hint: "`doctrina delivery list` from the workspace", code: EXIT.USAGE };
399
+ const ws = readWorkspace(found.dir);
400
+ const project = projectAt(ws, projectRoot);
401
+ const d = readDelivery(found.dir, id);
402
+ if (!project || !d.projects.includes(project.name)) {
403
+ return {
404
+ error: `delivery ${id} is not routed to ${project ? project.name : "this repository"} (routed: ${d.projects.join(", ") || "none"})`,
405
+ hint: `route it first, from the workspace: \`doctrina delivery route ${id} ${[...d.projects, project?.name ?? "<name>"].join(",")}\``,
406
+ code: EXIT.PRECONDITION,
407
+ };
408
+ }
409
+ return { id, story: sectionText(d.text, "Story") ?? "" };
410
+ }
411
+
335
412
  function writeLane(projectRoot, id, verdict, opts) {
336
413
  const line = laneRecord(verdict, opts);
337
414
  if (!line) return;
@@ -367,6 +444,11 @@ function nextChangeNumber(projectRoot) {
367
444
  harvest(entry.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
368
445
  }
369
446
  }
447
+ // An abandoned change leaves no folder, only its ledger line (third audit,
448
+ // change 0234): its number was handed out again, and the ledger then named
449
+ // two different changes by one id — the abandoned part of a delivery among them.
450
+ const ledger = path.join(archiveDir, "LEDGER.md");
451
+ if (isFile(ledger)) for (const e of parseLedger(read(ledger)).entries) harvest(e.id);
370
452
  return String(max + 1).padStart(4, "0");
371
453
  }
372
454
 
@@ -411,7 +493,7 @@ function printResumeSuggestion(open) {
411
493
 
412
494
  // Reprint the work playbook for an already-open change, ranking capabilities
413
495
  // from its recorded title (the original prompt). Creates nothing.
414
- function resumeChange(projectRoot, resumeId) {
496
+ function resumeChange(projectRoot, resumeId, delivery = null) {
415
497
  const open = openChanges(projectRoot);
416
498
  if (!resumeId || resumeId === true) {
417
499
  console.error(c.red("error:") + " --resume needs a change id");
@@ -426,6 +508,25 @@ function resumeChange(projectRoot, resumeId) {
426
508
  if (open.length) console.error(c.gray("open: ") + open.map((ch) => ch.id).join(", "));
427
509
  return 1;
428
510
  }
511
+ // --resume <id> --delivery <id> (change 0226): a change opened before its
512
+ // delivery existed — or without the flag — joins it, so the delivery's
513
+ // status and check see it. A change already in another delivery is not
514
+ // moved silently.
515
+ if (delivery) {
516
+ const proposalPath = path.join(projectRoot, ".doctrina", "changes", resumeId, "proposal.md");
517
+ const text = isFile(proposalPath) ? read(proposalPath) : "";
518
+ const current = (listHeader(text, "Delivery") ?? "").trim();
519
+ if (current && current !== delivery) {
520
+ console.error(c.red("error:") + ` ${resumeId} already belongs to delivery ${current}`);
521
+ return EXIT.PRECONDITION;
522
+ }
523
+ if (!current) {
524
+ const stamped = text.replace(/^(-[ \t]+\*\*Lane:\*\*[^\r\n]*)/m, `$1\n- **Delivery:** ${delivery}`);
525
+ const updated = stamped !== text ? stamped : text.replace(/^(-[ \t]+\*\*Status:\*\*[^\r\n]*)/m, `$1\n- **Delivery:** ${delivery}`);
526
+ write(proposalPath, withEol(updated, eolOf(text)), { force: true });
527
+ console.log(c.green("joined") + ` ${resumeId} to delivery ${delivery}`);
528
+ }
529
+ }
429
530
  const prompt = entry?.title ?? resumeId;
430
531
  const matches = rankCapabilities(projectRoot, prompt);
431
532
  const capability = matches[0]?.id ?? null;
@@ -496,6 +597,9 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
496
597
  CHANGE_ID: id,
497
598
  PROMPT: prompt,
498
599
  CAPABILITY: capability ?? "<capability>",
600
+ // The read pack already cut to the task (change 0217): `--for` is what
601
+ // slices the spec, and an agent copying a step should copy the cut.
602
+ FOR: forQuery(prompt),
499
603
  FROM_DIFF_NOTE: fromDiff
500
604
  ? c.gray("Code-first: write the spec that describes what the working tree already does.")
501
605
  : "",
@@ -534,6 +638,15 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
534
638
  });
535
639
  }
536
640
 
641
+ // The prompt as a `context --for` argument: one line, no double quotes, short
642
+ // enough to read — the slicer only needs its content words.
643
+ export function forQuery(prompt) {
644
+ // `$` and `\\` too (change 0235): between the double quotes the playbook
645
+ // prints, `$(…)` in a prompt still ran.
646
+ const flat = String(prompt ?? "").replace(/["`$\\]/g, "").replace(/\s+/g, " ").trim();
647
+ return flat.length > 80 ? flat.slice(0, 80).replace(/\s+\S*$/, "") : flat;
648
+ }
649
+
537
650
  // Which capability the change is about, as far as the CLI can tell: pinned by
538
651
  // the operator, ranked by term overlap, or nothing — a hint in every case,
539
652
  // never a decision (ADR 0005).
@@ -587,6 +700,8 @@ Options:
587
700
  (backlog entry; reprint later with --resume <id>)
588
701
  --id <id> Override the derived change id
589
702
  --resume <id> Reprint the playbook for an open change; create nothing
703
+ --delivery <id> Stamp the change with the workspace delivery it is part
704
+ of (ADR 0030); with --resume, join an open change to it
590
705
  --from-diff Backfill: scaffold from the working-tree changes (no
591
706
  prompt needed) and print a code-first playbook (F8)
592
707
  --consolidate <cap> Open a change that consolidates <cap>'s spec — merge
@@ -601,6 +716,10 @@ Options:
601
716
  existing change folder, and proceed past the lane hold
602
717
  below
603
718
 
719
+ A request that mostly repeats a change already open, or archived in the
720
+ last 14 days, is HELD the same way (exit 3), naming it: the same work is
721
+ resumed, or reported as done where it is archived; --force opens it anyway.
722
+
604
723
  A prompt that reads as a RUNTIME problem — a workflow, an env value, a run
605
724
  that executed nothing — is HELD before anything is scaffolded, and pointed
606
725
  at \`doctrina triage\` instead (exit 3: a precondition, not a bad prompt).
@@ -0,0 +1,419 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { spawnSync } from "node:child_process";
5
+ import { readFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { exists, isDir, mkdirp, relPath, write } from "../lib/fs-ops.js";
8
+ import { flagString } from "../lib/args.js";
9
+ import { c } from "../lib/colors.js";
10
+ import { suggest } from "../lib/suggest.js";
11
+ import { EXIT, PreconditionError } from "../lib/exit-codes.js";
12
+ import {
13
+ PROJECT_MODES, PROJECT_NAME, WORKSPACE_FILE, applyLinks, blankWorkspace, excludeEntries,
14
+ findWorkspace, isWorkspaceDir, linkPlan, projectAt, projectState, readWorkspace, removeLinks,
15
+ shadowDirOf, writeExclude, writePointer, writeWorkspace, gitInit,
16
+ } from "../lib/workspace.js";
17
+ import { TOOL_PRESETS, toolEntry } from "../lib/workspace-tools.js";
18
+ import { workspaceGuard } from "../lib/workspace-guard.js";
19
+ import { writeOrchestratorFiles } from "../lib/workspace-agents.js";
20
+ import { cdThen } from "../lib/shell.js";
21
+ import { mcpServerNames } from "../lib/workspace-view.js";
22
+
23
+ // `doctrina workspace` — the context of several repositories in one place
24
+ // (ADR 0029). The decisions live in lib/workspace.js; this module renders
25
+ // them and runs the one child process it needs (`init` inside a shadow).
26
+
27
+ const SUBCOMMANDS = ["init", "add", "attach", "detach", "list", "tool", "guard"];
28
+ const CLI_ENTRY = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "index.js");
29
+
30
+ export const flags = {
31
+ boolean: ["json", "no-git", "remove"],
32
+ string: ["name", "mode", "role", "about", "depends-on", "agent", "path", "server", "access", "for", "purpose", "hook"],
33
+ };
34
+
35
+ export async function run(positional, flags) {
36
+ const sub = positional[0];
37
+ if (!SUBCOMMANDS.includes(sub)) {
38
+ console.error(c.red("error:") + ` unknown workspace subcommand "${sub ?? ""}"`);
39
+ const guess = suggest(sub, SUBCOMMANDS);
40
+ console.error(c.gray("hint: ") + (guess ? `did you mean \`doctrina workspace ${guess}\`?` : `available: ${SUBCOMMANDS.join(", ")}`));
41
+ return EXIT.USAGE;
42
+ }
43
+ if (sub === "init") return workspaceInit(positional[1], flags);
44
+ const found = findWorkspace(process.cwd());
45
+ // The guard answers an agent's pre-call hook, so it never fails the call
46
+ // for want of a workspace: no workspace, no opinion.
47
+ if (sub === "guard") return workspaceGuard(found?.dir ?? null, flagString(flags, "hook"));
48
+ if (!found) {
49
+ throw new PreconditionError(
50
+ "no workspace here (no workspace.json in this directory or above, and $DOCTRINA_WORKSPACE is unset)",
51
+ "doctrina workspace init <dir>",
52
+ );
53
+ }
54
+ let code;
55
+ if (sub === "add") code = workspaceAdd(found.dir, positional[1], flags);
56
+ else if (sub === "attach") code = workspaceAttach(found.dir, positional[1], flags);
57
+ else if (sub === "detach") code = workspaceDetach(found.dir, positional[1]);
58
+ else if (sub === "tool") code = workspaceTool(found.dir, positional[1], flags);
59
+ else return workspaceList(found.dir);
60
+ if (code === EXIT.OK) regenerate(found.dir);
61
+ return code;
62
+ }
63
+
64
+ function workspaceInit(dirArg, flags) {
65
+ const dir = path.resolve(process.cwd(), dirArg ?? ".");
66
+ if (isWorkspaceDir(dir)) {
67
+ console.error(c.red("error:") + ` ${dir} is already a workspace`);
68
+ console.error(c.gray("hint: ") + "add a project to it: `doctrina workspace add <path>`");
69
+ return EXIT.PRECONDITION;
70
+ }
71
+ const name = flagString(flags, "name") ?? path.basename(dir);
72
+ mkdirp(path.join(dir, "projects"));
73
+ mkdirp(path.join(dir, "deliveries"));
74
+ writeWorkspace(dir, blankWorkspace(name));
75
+ if (!exists(path.join(dir, ".gitignore"))) {
76
+ write(path.join(dir, ".gitignore"), "node_modules/\n.env\n.env.*\n*.log\n");
77
+ }
78
+ for (const keep of ["projects", "deliveries"]) {
79
+ if (!exists(path.join(dir, keep, ".gitkeep"))) write(path.join(dir, keep, ".gitkeep"), "");
80
+ }
81
+ console.log(c.green("created") + ` workspace "${name}" at ${dir}`);
82
+ if (!flags.get("no-git") && !exists(path.join(dir, ".git"))) {
83
+ console.log(gitInit(dir)
84
+ ? c.green("created") + " a git repository for it — keep it private: it versions the context of repositories that cannot carry their own"
85
+ : c.yellow("warn:") + " git is not available; the workspace is not versioned yet (`git init` it yourself)");
86
+ }
87
+ writePointer(dir);
88
+ regenerate(dir);
89
+ console.log("");
90
+ console.log("Next: register each repository the work touches —");
91
+ console.log(" " + c.cyan("doctrina workspace add <path> --role front|back|app|qa --about \"<what it is>\""));
92
+ console.log(c.gray(" A repository with its own .doctrina/ is registered in-repo; one without is shadowed: its"));
93
+ console.log(c.gray(" artifacts live here and are linked into it, invisible to its git."));
94
+ return EXIT.OK;
95
+ }
96
+
97
+ function workspaceAdd(wsDir, pathArg, flags) {
98
+ if (!pathArg) {
99
+ console.error(c.red("error:") + " workspace add needs the repository's path");
100
+ console.error(c.gray("hint: ") + "doctrina workspace add ../front-core --role front --about \"design system and shell\"");
101
+ return EXIT.USAGE;
102
+ }
103
+ const repo = path.resolve(process.cwd(), pathArg);
104
+ if (!isDir(repo)) {
105
+ console.error(c.red("error:") + ` not a directory: ${pathArg}`);
106
+ return EXIT.PRECONDITION;
107
+ }
108
+ const ws = readWorkspace(wsDir);
109
+ const name = flagString(flags, "name") ?? path.basename(repo).toLowerCase();
110
+ if (!PROJECT_NAME.test(name)) {
111
+ console.error(c.red("error:") + ` project name "${name}" must match ${PROJECT_NAME} — pass --name`);
112
+ return EXIT.USAGE;
113
+ }
114
+ if (ws.projects.some((p) => p.name === name)) {
115
+ console.error(c.red("error:") + ` a project named "${name}" is already registered`);
116
+ console.error(c.gray("hint: ") + `relink it with \`doctrina workspace attach ${name}\`, or pass another --name`);
117
+ return EXIT.PRECONDITION;
118
+ }
119
+ const already = projectAt(ws, repo);
120
+ if (already) {
121
+ console.error(c.red("error:") + ` ${repo} is already registered as "${already.name}"`);
122
+ return EXIT.PRECONDITION;
123
+ }
124
+ const hasOwn = isDir(path.join(repo, ".doctrina"));
125
+ const mode = flagString(flags, "mode") ?? (hasOwn ? "in-repo" : "shadow");
126
+ if (!PROJECT_MODES.includes(mode)) {
127
+ console.error(c.red("error:") + ` --mode must be one of ${PROJECT_MODES.join(", ")} (got "${mode}")`);
128
+ return EXIT.USAGE;
129
+ }
130
+ if (mode === "shadow" && hasOwn) {
131
+ console.error(c.red("error:") + " this repository already carries its own .doctrina/ — register it in-repo");
132
+ return EXIT.PRECONDITION;
133
+ }
134
+ const dependsOn = (flagString(flags, "depends-on") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
135
+ const unknown = dependsOn.filter((d) => !ws.projects.some((p) => p.name === d));
136
+ if (unknown.length > 0) {
137
+ console.error(c.red("error:") + ` --depends-on names no registered project: ${unknown.join(", ")}`);
138
+ return EXIT.USAGE;
139
+ }
140
+ const project = {
141
+ name, path: repo, mode,
142
+ role: flagString(flags, "role") ?? "",
143
+ about: flagString(flags, "about") ?? "",
144
+ dependsOn,
145
+ };
146
+ if (mode === "shadow" && !scaffoldShadow(wsDir, project, flagString(flags, "agent") ?? "cursor")) return EXIT.GATE;
147
+ ws.projects.push(project);
148
+ writeWorkspace(wsDir, ws);
149
+ console.log(c.green("registered") + ` ${name} (${mode}) → ${repo}`);
150
+ if (mode === "shadow") attachShadow(wsDir, project);
151
+ printProjectNext(wsDir, project);
152
+ return EXIT.OK;
153
+ }
154
+
155
+ function attachShadow(wsDir, project) {
156
+ const shadow = shadowDirOf(wsDir, project.name);
157
+ const plan = linkPlan(shadow, project.path);
158
+ const done = applyLinks(plan, shadow, project.path);
159
+ for (const d of done) {
160
+ console.log(c.green(d.how.padEnd(11)) + ` ${d.rel}` + (d.how === "copied" ? c.yellow(" (a copy: rerun attach after the shadow changes)") : ""));
161
+ }
162
+ for (const e of plan.filter((p) => p.action === "conflict")) {
163
+ console.log(c.yellow("kept ") + ` ${e.rel} — the repository already has its own; the shadow's copy is not linked`);
164
+ }
165
+ const excludeFile = writeExclude(project.path, excludeEntries(plan, project.path));
166
+ console.log(excludeFile
167
+ ? c.gray(`hidden from the repository's git by ${relPath(project.path, excludeFile)} (never committed)`)
168
+ : c.yellow("warn:") + " not a git repository — nothing hides the links from version control");
169
+ }
170
+
171
+ function printProjectNext(wsDir, project) {
172
+ const st = projectState(wsDir, project);
173
+ if (st.state === "pending") {
174
+ console.log("");
175
+ console.log(`Next: ${project.name} has no specs yet (${st.reason}) — absorb it from its code:`);
176
+ console.log(" " + c.cyan(cdThen(project.path, "doctrina intake --from-code")));
177
+ }
178
+ }
179
+
180
+ function findProject(ws, name) {
181
+ const project = ws.projects.find((p) => p.name === name);
182
+ if (project) return project;
183
+ const guess = suggest(name ?? "", ws.projects.map((p) => p.name));
184
+ throw Object.assign(new Error(`no project named "${name ?? ""}" in this workspace` + (guess ? ` — did you mean "${guess}"?` : "")), { exitCode: EXIT.USAGE });
185
+ }
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
+
204
+ function workspaceAttach(wsDir, name, flags) {
205
+ const ws = readWorkspace(wsDir);
206
+ const project = findProject(ws, name);
207
+ const newPath = flagString(flags, "path");
208
+ if (newPath) {
209
+ const repo = path.resolve(process.cwd(), newPath);
210
+ if (!isDir(repo)) {
211
+ console.error(c.red("error:") + ` not a directory: ${newPath}`);
212
+ return EXIT.PRECONDITION;
213
+ }
214
+ project.path = repo;
215
+ writeWorkspace(wsDir, ws);
216
+ console.log(c.green("moved") + ` ${project.name} → ${repo}`);
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
+ }
232
+ if (project.mode !== "shadow") {
233
+ console.log(c.gray(`${project.name} is in-repo — its .doctrina/ is its own; nothing to link`));
234
+ return EXIT.OK;
235
+ }
236
+ if (!isDir(project.path)) {
237
+ console.error(c.red("error:") + ` ${project.name}'s repository is not at ${project.path}`);
238
+ console.error(c.gray("hint: ") + `point it at the clone: \`doctrina workspace attach ${project.name} --path <dir>\``);
239
+ return EXIT.PRECONDITION;
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
+ }
249
+ attachShadow(wsDir, project);
250
+ return EXIT.OK;
251
+ }
252
+
253
+ function workspaceDetach(wsDir, name) {
254
+ const ws = readWorkspace(wsDir);
255
+ const project = findProject(ws, name);
256
+ if (project.mode !== "shadow") {
257
+ console.log(c.gray(`${project.name} is in-repo — nothing was linked`));
258
+ return EXIT.OK;
259
+ }
260
+ const removed = isDir(project.path) ? removeLinks(shadowDirOf(wsDir, project.name), project.path) : [];
261
+ for (const r of removed) console.log(c.green("unlinked") + ` ${r}`);
262
+ if (isDir(project.path)) writeExclude(project.path, []);
263
+ console.log(c.gray(`the shadow stays in ${relPath(wsDir, shadowDirOf(wsDir, project.name))}/ — \`doctrina workspace attach ${project.name}\` links it again`));
264
+ return EXIT.OK;
265
+ }
266
+
267
+ // The orchestrator's files follow the registry on every write (change 0223).
268
+ function regenerate(wsDir) {
269
+ const ws = readWorkspace(wsDir);
270
+ const states = new Map(ws.projects.map((p) => [p.name, projectState(wsDir, p).state]));
271
+ const written = writeOrchestratorFiles(ws, wsDir, states);
272
+ if (written.length > 0) console.log(c.gray(`orchestrator files: ${written.join(", ")}`));
273
+ }
274
+
275
+ function workspaceTool(wsDir, name, flags) {
276
+ if (!name) {
277
+ console.error(c.red("error:") + " workspace tool needs the tool's name");
278
+ console.error(c.gray("hint: ") + `presets: ${Object.keys(TOOL_PRESETS).join(", ")} — or --server <name in your agent's MCP config>`);
279
+ return EXIT.USAGE;
280
+ }
281
+ const ws = readWorkspace(wsDir);
282
+ const at = ws.tools.findIndex((t) => t.name === name);
283
+ if (flags.get("remove")) {
284
+ if (at < 0) {
285
+ console.error(c.red("error:") + ` no tool named "${name}"`);
286
+ return EXIT.USAGE;
287
+ }
288
+ ws.tools.splice(at, 1);
289
+ writeWorkspace(wsDir, ws);
290
+ console.log(c.green("removed") + ` tool ${name}`);
291
+ return EXIT.OK;
292
+ }
293
+ let entry;
294
+ try {
295
+ entry = toolEntry(name, {
296
+ server: flagString(flags, "server"), access: flagString(flags, "access"),
297
+ for: flagString(flags, "for"), purpose: flagString(flags, "purpose"),
298
+ }, at >= 0 ? ws.tools[at] : null);
299
+ } catch (err) {
300
+ console.error(c.red("error:") + ` ${err.message}`);
301
+ return EXIT.USAGE;
302
+ }
303
+ if (at >= 0) ws.tools[at] = entry;
304
+ else ws.tools.push(entry);
305
+ writeWorkspace(wsDir, ws);
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"}`);
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
+ }
314
+ return EXIT.OK;
315
+ }
316
+
317
+ // Reads the agent's pre-call payload on stdin and answers in the agent's
318
+ // own format. Never exits non-zero and never blocks on bad input: a broken
319
+ // guard must not take the agent's tools away.
320
+
321
+ function workspaceList(wsDir) {
322
+ const ws = readWorkspace(wsDir);
323
+ console.log(c.bold(`Workspace "${ws.name}"`) + c.gray(` — ${wsDir}`));
324
+ if (ws.projects.length === 0) {
325
+ console.log("");
326
+ console.log(c.gray("no project yet — `doctrina workspace add <path>`"));
327
+ return EXIT.OK;
328
+ }
329
+ console.log("");
330
+ for (const p of ws.projects) {
331
+ const st = projectState(wsDir, p);
332
+ const colour = st.state === "ready" ? c.green : st.state === "pending" ? c.yellow : c.red;
333
+ const open = st.open.length ? `, ${st.open.length} open change${st.open.length === 1 ? "" : "s"}` : "";
334
+ console.log(` ${c.cyan(p.name.padEnd(16))} ${p.mode.padEnd(8)} ${colour(st.state.padEnd(8))} ${st.reason}${open}`);
335
+ const meta = [p.role && `role ${p.role}`, p.dependsOn?.length && `depends on ${p.dependsOn.join(", ")}`].filter(Boolean).join(" · ");
336
+ console.log(c.gray(` ${"".padEnd(16)} ${p.path}${meta ? " · " + meta : ""}`));
337
+ if (p.about) console.log(c.gray(` ${"".padEnd(16)} ${p.about}`));
338
+ }
339
+ const pending = ws.projects.filter((p) => projectState(wsDir, p).state === "pending");
340
+ if (pending.length > 0) {
341
+ console.log("");
342
+ console.log(`Next: absorb ${pending.map((p) => p.name).join(", ")} — ` + c.cyan(cdThen(pending[0].path, "doctrina intake --from-code")));
343
+ }
344
+ console.log("");
345
+ if (ws.tools.length === 0) {
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(", ") + ")"));
349
+ } else {
350
+ console.log(c.bold("Tools"));
351
+ for (const t of ws.tools) {
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 ?? "")}`);
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);
361
+ return EXIT.OK;
362
+ }
363
+
364
+ export const help = `
365
+ Usage: doctrina workspace <init|add|attach|detach|list|tool|guard> [options]
366
+
367
+ The context of several repositories in one place (ADR 0029). Keep the
368
+ workspace directory as a PRIVATE git repository: it versions the
369
+ artifacts of repositories that cannot carry .doctrina/ themselves.
370
+
371
+ init [<dir>] [--name <n>] [--no-git]
372
+ Create the workspace (workspace.json, projects/, deliveries/) and
373
+ \`git init\` it. Records it in $DOCTRINA_HOME (default ~/.doctrina)
374
+ so an in-repo project can find it.
375
+
376
+ add <path> [--name <n>] [--mode in-repo|shadow] [--role <r>]
377
+ [--about "<what it is>"] [--depends-on <a,b>] [--agent cursor]
378
+ Register a repository. One with its own .doctrina/ is in-repo. One
379
+ without is a SHADOW: its AGENTS.md, .doctrina/ and agent adapter are
380
+ scaffolded under projects/<name>/, linked into the repository, and
381
+ hidden from its git through .git/info/exclude.
382
+
383
+ attach <name> [--path <dir>] [--depends-on <a,b>]
384
+ Re-create a shadow's links (after cloning the workspace on another
385
+ machine, or with --path after the repository moved). --depends-on
386
+ replaces the projects it builds on — the order \`delivery route\` uses.
387
+
388
+ detach <name>
389
+ Remove a shadow's links and its exclude block; the shadow stays.
390
+
391
+ list
392
+ Every project with its mode, state (ready / pending / missing) and
393
+ open changes, then the declared tools; refreshes the orchestrator
394
+ files when a project's state or workspace.json changed since.
395
+
396
+ tool <name> [--server <mcp-name>] [--access read|write] [--for <roles>]
397
+ [--purpose "<what for>"] [--remove]
398
+ Declare (or update, or --remove) a tool the work uses. Presets:
399
+ jira, confluence, figma, playwright, datadog, databricks, sql.
400
+ Roles: planner, executor, qa, observer, reviewer. Access defaults
401
+ to read: the guard refuses a write through a read-only tool and
402
+ makes a declared write wait for the human.
403
+
404
+ guard --hook cursor|claude
405
+ The pre-call hook the workspace installs (Cursor beforeMCPExecution,
406
+ Claude Code PreToolUse): reads the call on stdin and answers allow,
407
+ deny or ask from the declared access. Never blocks on bad input.
408
+
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.
415
+
416
+ The workspace is found from $DOCTRINA_WORKSPACE, the current directory
417
+ or an ancestor holding ${WORKSPACE_FILE}, a shadow's .doctrina link, or
418
+ the pointer \`init\` wrote.
419
+ `;