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
@@ -1,13 +1,16 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
+ import { attachCommand, shadowedCloneAt } from "../lib/workspace-view.js";
3
4
  import process from "node:process";
4
5
  import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
5
6
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry, substitute } from "../lib/templates.js";
6
7
  import {
7
- surfaceBlock, findSurfaceBlock, agentChangelogBlock, findAgentChangelogBlock,
8
+ surfaceBlock, findSurfaceBlock, agentChangelogBlock, findAgentChangelogBlock, SURFACE_PROFILES,
8
9
  } from "../lib/commands.js";
9
10
  import * as idx from "../lib/index-json.js";
10
11
  import { scaffoldConfig } from "../lib/config.js";
12
+ import { applyPreset, describePreset, planPreset, readPreset } from "../lib/preset.js";
13
+ import { deriveIndex } from "../lib/scan.js";
11
14
  import { today } from "../lib/dates.js";
12
15
  import { cliVersion } from "../lib/version.js";
13
16
  import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
@@ -30,17 +33,27 @@ const SUPPORTED_AGENTS = [
30
33
  "devin",
31
34
  "factory",
32
35
  "jules",
36
+ "kiro",
33
37
  ];
34
38
 
35
39
  export const flags = {
36
40
  boolean: ["json", "force", "non-interactive", "overwrite-content"],
37
- string: ["project-name", "project-description", "agent", "intake", "intake-text", "from", "date"],
41
+ string: ["project-name", "project-description", "agent", "intake", "intake-text", "from", "date", "surface", "preset"],
38
42
  };
39
43
 
40
44
  export async function run(_positional, flags) {
41
45
  const force = flagBool(flags, "force", false);
42
46
  const nonInteractive = flagBool(flags, "non-interactive", false);
43
47
  const projectRoot = process.cwd();
48
+ // A clone of a shadowed repository (third audit, change 0234): its context
49
+ // lives in the workspace, and a .doctrina/ planted here is one the
50
+ // repository may not carry — and one the workspace would never see.
51
+ const clone = force ? null : shadowedCloneAt(projectRoot);
52
+ if (clone && !path.resolve(projectRoot).startsWith(path.resolve(clone.wsDir) + path.sep)) {
53
+ console.error(c.red("error:") + ` this repository is the workspace's shadowed "${clone.name}" (registered at ${clone.path}) — its context lives in the workspace, not here`);
54
+ console.error(c.gray("hint: ") + `link it here instead: \`${attachCommand(clone, projectRoot)}\` — or pass --force to init a separate project anyway`);
55
+ return EXIT.PRECONDITION;
56
+ }
44
57
  const projectName = flagString(flags, "project-name") ?? path.basename(projectRoot);
45
58
  const date = flagString(flags, "date") ?? today();
46
59
 
@@ -132,6 +145,30 @@ export async function run(_positional, flags) {
132
145
  }
133
146
  }
134
147
 
148
+ // Which commands AGENTS.md lists (change 0251). A new project is born with
149
+ // the session loop only — `core` — and the rest one `--help` away; the
150
+ // choice is written into config.json, where `upgrade` reads it.
151
+ // The organisation's preset (change 0253), read BEFORE anything is
152
+ // written: a folder that is not one stops the init with nothing scaffolded.
153
+ let preset = null;
154
+ if (flags.has("preset")) {
155
+ try {
156
+ const dir = flagString(flags, "preset");
157
+ if (!dir) throw new Error("--preset needs the preset's folder");
158
+ preset = readPreset(dir);
159
+ } catch (err) {
160
+ console.error(c.red("error:") + ` ${/** @type {Error} */ (err).message}`);
161
+ return 2;
162
+ }
163
+ }
164
+ // The preset's surface is the default when the flag names none: the block
165
+ // is generated once, from the profile the project will keep.
166
+ const surface = flagString(flags, "surface") ?? preset?.config.surface ?? "core";
167
+ if (!SURFACE_PROFILES.includes(surface) || flagGivenWithoutValue(flags, "surface")) {
168
+ console.error(c.red("error:") + ` --surface takes ${SURFACE_PROFILES.join(" or ")}${flagGivenWithoutValue(flags, "surface") ? "" : ` (got "${surface}")`}`);
169
+ return 2;
170
+ }
171
+
135
172
  let agentSelector = flagString(flags, "agent");
136
173
  // Interactive wizard step: when no --agent was given and we are on a real
137
174
  // terminal, offer the adapter install instead of silently skipping it —
@@ -192,7 +229,7 @@ export async function run(_positional, flags) {
192
229
  // if the static template lags a release (ADR 0015).
193
230
  const agentsTemplatePath = path.join(templatesDir, "AGENTS.md.template");
194
231
  const agentsTemplateBody = read(agentsTemplatePath);
195
- const projectAgents = refreshSurfaceBlock(substitute(agentsTemplateBody, tokens));
232
+ const projectAgents = refreshSurfaceBlock(substitute(agentsTemplateBody, tokens), surface);
196
233
 
197
234
  // --force used to mean "overwrite everything", which made it a content
198
235
  // shredder: the only way to add an adapter to an existing project was
@@ -275,11 +312,21 @@ export async function run(_positional, flags) {
275
312
  {
276
313
  const dest = path.join(doctrinaDir, "config.json");
277
314
  if (!isFile(dest) || force) {
278
- write(dest, scaffoldConfig(), { force });
315
+ write(dest, scaffoldConfig({ surface }), { force });
279
316
  console.log(c.green("created") + ` ${relPath(projectRoot, dest)}`);
280
317
  }
281
318
  }
282
319
 
320
+ // The preset, over the scaffold: what it adds is indexed with the rest.
321
+ if (preset) {
322
+ const plan = planPreset(projectRoot, preset);
323
+ applyPreset(projectRoot, preset, plan);
324
+ for (const line of describePreset(preset, plan, true)) console.log(line);
325
+ const derived = deriveIndex(projectRoot, idx.load(projectRoot));
326
+ derived.last_updated = date;
327
+ idx.save(projectRoot, derived);
328
+ }
329
+
283
330
  // Adapters
284
331
  if (agentSelector) {
285
332
  const agents = agentSelector === "all" ? SUPPORTED_AGENTS : [agentSelector];
@@ -374,10 +421,10 @@ function normaliseScaffold(text) {
374
421
  // Replace the template's doctrina:surface span with the one generated from
375
422
  // the running CLI's catalog; pass the text through untouched when the
376
423
  // template carries no markers (older custom templates).
377
- function refreshSurfaceBlock(text) {
424
+ function refreshSurfaceBlock(text, profile = "full") {
378
425
  const block = findSurfaceBlock(text);
379
426
  if (!block) return text;
380
- let out = text.slice(0, block.start) + surfaceBlock() + text.slice(block.end);
427
+ let out = text.slice(0, block.start) + surfaceBlock(profile) + text.slice(block.end);
381
428
 
382
429
  // The agent-facing changelog is version-specific, so it is GENERATED at
383
430
  // init like the surface block — not carried in the template. Writing it
@@ -443,8 +490,15 @@ Scaffold AGENTS.md and the .doctrina/ skeleton in the current directory.
443
490
  Options:
444
491
  --project-name <name> Override the project name (default: basename of cwd)
445
492
  --project-description <text> One-sentence description
493
+ --surface core|full Which commands AGENTS.md lists: core (default) is the session
494
+ loop, with the rest deferred to \`doctrina --help\`; full lists
495
+ every command. Stored in .doctrina/config.json.
496
+ --preset <dir> Lay an organisation's preset over the scaffold: a folder with
497
+ preset.json (name, config) and optional templates/, decisions/,
498
+ skills/ and verify.json. Additive — see \`doctrina upgrade --preset\`
499
+ for a project that already exists.
446
500
  --agent <name> Install adapter for one agent
447
- (claude|codex|cursor|copilot|gemini|aider|windsurf|continue|amp|devin|factory|jules|all)
501
+ (claude|codex|cursor|copilot|gemini|aider|windsurf|continue|amp|devin|factory|jules|kiro|all)
448
502
  --intake <file> Full project description; stored verbatim at .doctrina/intake.md,
449
503
  used to derive the one-line description when absent, and the
450
504
  bootstrap playbook is printed inline — no second command
@@ -454,6 +508,8 @@ Options:
454
508
  --from <path> Local conventions directory; if it contains AGENTS.md and/or
455
509
  .doctrina/product.md, the content is folded into the new project
456
510
  --date <YYYY-MM-DD> Override the system date
457
- --force Overwrite existing files
511
+ --force Overwrite existing files that are still untouched scaffolds;
512
+ also init a second clone of a repository a workspace shadows
513
+ --overwrite-content With --force, also overwrite an authored AGENTS.md or product.md
458
514
  --non-interactive Fail instead of prompting for missing values
459
515
  `;
@@ -43,7 +43,7 @@ export async function run(positional, flags) {
43
43
  // a bootstrap that was already done.
44
44
  if (flagBool(flags, "converted", false)) {
45
45
  ensureDoctrinaProject(projectRoot);
46
- // Converting is the claim that the bootstrap is done — step 6 of the
46
+ // Converting is the claim that the bootstrap is done — step 8 of the
47
47
  // playbook, "fix everything the gates report", made checkable (change
48
48
  // 0194). It used to convert over a tree `validate` failed, so the specs
49
49
  // became "the source of truth" while an error still said they were not
@@ -95,7 +95,12 @@ export async function run(positional, flags) {
95
95
  console.log(c.gray("hint: ") + "start implementation with `doctrina work \"<brief prompt>\"`");
96
96
  return 0;
97
97
  }
98
- printBootstrapPlaybook(projectRoot);
98
+ // An intake written by `--from-code` is a code inventory, and the
99
+ // playbook that reads it is the absorb (second audit, change 0233). The
100
+ // bootstrap one — "fill every product.md section from the intake" —
101
+ // handed a resuming agent the inventory as if it were the product's intent.
102
+ if (/code inventory/i.test(listHeader(read(intakePath), "Source") ?? "")) printAbsorbPlaybook(projectRoot);
103
+ else printBootstrapPlaybook(projectRoot);
99
104
  return 0;
100
105
  }
101
106
 
@@ -180,15 +185,21 @@ function intakeFromCode(projectRoot, force) {
180
185
  source: "code inventory (`doctrina intake --from-code`)",
181
186
  projectName: name, date: today(), force: true,
182
187
  });
183
- console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)} — ${inv.files} files read: ${inv.stack.join(", ") || "no manifest"}; ${inv.tests.files} test files; ${inv.areas.length} candidate areas`);
188
+ const plural = (/** @type {number} */ n, /** @type {string} */ word) => `${n} ${word}${n === 1 ? "" : "s"}`;
189
+ console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)} — ${plural(inv.files, "file")} read: ${inv.stack.join(", ") || "no manifest"}; ${plural(inv.tests.files, "test file")}; ${plural(inv.areas.length, "candidate area")}`);
184
190
  console.log("");
191
+ printAbsorbPlaybook(projectRoot);
192
+ return EXIT.OK;
193
+ }
194
+
195
+ /** The absorb playbook — printed by `--from-code`, and again by a bare `intake` while it is pending. */
196
+ function printAbsorbPlaybook(projectRoot) {
185
197
  const ws = findWorkspace(projectRoot);
186
198
  printPlaybookTemplate(projectRoot, "absorb", {
187
199
  WORKSPACE_STEP: ws
188
- ? `\n8. This project belongs to the workspace at ${ws.dir} — confirm it reads\n ready: ${c.cyan("doctrina workspace list")}, and commit the workspace.`
200
+ ? `\n10. This project belongs to the workspace at ${ws.dir} — confirm it reads\n ready: ${c.cyan("doctrina workspace list")}, and commit the workspace.`
189
201
  : "",
190
202
  });
191
- return EXIT.OK;
192
203
  }
193
204
 
194
205
  export const help = `
@@ -12,6 +12,8 @@ import { notADoctrinaProject } from "../lib/exit-codes.js";
12
12
  import { USAGE_ENV, summarise } from "../lib/usage.js";
13
13
  import { OPERATIONS } from "../lib/commands.js";
14
14
  import { REEDIT_WINDOW_DAYS, sinceWindow, computeSnapshot, parseLog, readSeries, latestSnapshot, trend, round } from "../lib/metrics-model.js";
15
+ import { workspaceRootAt, workspaceSnapshot } from "../lib/workspace-view.js";
16
+ import { cdThen } from "../lib/shell.js";
15
17
 
16
18
  // Local-only adoption metrics derived from git history. No network calls,
17
19
  // no telemetry: the numbers stay in the repository, versioned like any
@@ -24,6 +26,19 @@ export const flags = { boolean: ["json", "save", "commands", "trend"], string: [
24
26
 
25
27
  export async function run(_positional, flags) {
26
28
  const projectRoot = process.cwd();
29
+ // At the workspace root (change 0235): adoption is measured per repository;
30
+ // the deliveries' own lead time is `delivery list`.
31
+ const wsRoot = workspaceRootAt(projectRoot);
32
+ if (wsRoot && !flagBool(flags, "commands", false)) {
33
+ const snap = workspaceSnapshot(wsRoot);
34
+ console.log(c.bold("Doctrina metrics") + c.gray(` — workspace "${snap.ws.name}"`));
35
+ console.log("");
36
+ console.log(`Deliveries ${snap.open.length} open · ${snap.deliveries.length - snap.open.length} closed — lead time per delivery: ${c.cyan("doctrina delivery list")}`);
37
+ console.log("");
38
+ console.log("Adoption is measured in each repository:");
39
+ for (const p of snap.projects.filter((x) => x.state !== "missing")) console.log(" " + c.cyan(cdThen(p.project.path, "doctrina metrics")));
40
+ return EXIT.OK;
41
+ }
27
42
  if (!exists(path.join(projectRoot, ".doctrina"))) {
28
43
  throw notADoctrinaProject();
29
44
  }
@@ -39,7 +54,7 @@ export async function run(_positional, flags) {
39
54
  if (flagBool(flags, "trend", false)) return printTrend(projectRoot);
40
55
  // The window is the invocation's: refused the same with or without history.
41
56
  const sinceRaw = flagString(flags, "since", "90");
42
- const since = sinceWindow(sinceRaw);
57
+ const since = sinceWindow(sinceRaw ?? "90");
43
58
  if (since === null) {
44
59
  console.error(c.red("error:") + ` --since expects a day count, a date (YYYY-MM-DD) or "<n> <unit>s ago", got "${sinceRaw}"`);
45
60
  console.error(c.gray("hint: ") + "examples: --since 30 · --since 2026-01-01 · --since \"3 months ago\"");
@@ -8,6 +8,7 @@ import { c } from "../lib/colors.js";
8
8
  import { emitJson } from "../lib/json-out.js";
9
9
  import { EXIT } from "../lib/exit-codes.js";
10
10
  import { action, computeActions, invocation } from "../lib/actions.js";
11
+ import { attachCommand, contextRouting, shadowedCloneAt, workspaceActions, workspaceFindings, workspaceHandoff, workspaceRootAt, workspaceSnapshot, workspaceStatus } from "../lib/workspace-view.js";
11
12
 
12
13
  // `next` answers "what now?". The ANSWER lives in `lib/actions.js` as
13
14
  // records; this module renders them and, under `--run`, executes one
@@ -22,18 +23,48 @@ import { action, computeActions, invocation } from "../lib/actions.js";
22
23
  // wrap it in the generic envelope.
23
24
  export const jsonNative = true;
24
25
 
26
+ // How many actions `next` prints at a workspace root.
27
+ const ROOT_ACTIONS_SHOWN = 10;
28
+
25
29
  export const flags = { boolean: ["json", "run"], string: [] };
26
30
 
27
31
  export async function run(_positional, flags) {
28
32
  const projectRoot = process.cwd();
29
33
  const json = flagBool(flags, "json", false);
34
+ // At a workspace root the planner's next step is the workspace's — never
35
+ // `init`, which would create a project inside the workspace (change 0230).
36
+ const wsRoot = workspaceRootAt(projectRoot);
37
+ if (wsRoot) {
38
+ const actions = workspaceActions(workspaceSnapshot(wsRoot));
39
+ if (json) emitJson("next", { workspace: wsRoot, actions });
40
+ else {
41
+ console.log(c.bold("Next actions in the workspace") + c.gray(" (in priority order):"));
42
+ console.log("");
43
+ // The first actions, not all of them (fifth audit, change 0236): forty
44
+ // open deliveries printed 162 lines into the planner's context. The
45
+ // order already puts what unblocks most first; --json keeps every one.
46
+ actions.slice(0, ROOT_ACTIONS_SHOWN).forEach((a, i) => console.log(`${i + 1}. ${a.text}\n ${c.cyan(a.command)}`));
47
+ if (actions.length > ROOT_ACTIONS_SHOWN) {
48
+ console.log("");
49
+ console.log(c.gray(`… and ${actions.length - ROOT_ACTIONS_SHOWN} more — every delivery's state: \`doctrina delivery list\`; every action: \`doctrina next --json\``));
50
+ }
51
+ }
52
+ return 0;
53
+ }
30
54
  if (!exists(path.join(projectRoot, ".doctrina"))) {
31
- const first = action({
32
- id: "not-a-project",
33
- command: "init",
34
- runnable: false,
35
- why: "this directory is not a Doctrina project yet",
36
- });
55
+ const clone = shadowedCloneAt(projectRoot);
56
+ const first = clone
57
+ ? action({
58
+ id: "shadowed-clone",
59
+ why: `this repository is the workspace's shadowed "${clone.name}" at another path — link its context here, never init it`,
60
+ text: `${attachCommand(clone, projectRoot)} — this repository is the workspace's shadowed "${clone.name}"${clone.same ? " and its links are missing" : ` (registered at ${clone.path})`}; never \`doctrina init\` here`,
61
+ })
62
+ : action({
63
+ id: "not-a-project",
64
+ command: "init",
65
+ runnable: false,
66
+ why: "this directory is not a Doctrina project yet",
67
+ });
37
68
  if (json) emitJson("next", { actions: [first] });
38
69
  else console.log(`1. ${first.text}`);
39
70
  return 0;
@@ -154,6 +185,10 @@ proposed status, accepted ADRs with nothing proving them (cite Evidence
154
185
  or run "decision land"), a skill-capture nudge when a past fix went
155
186
  uncaptured, and index drift last.
156
187
 
188
+ At a workspace root it lists the workspace's next actions instead —
189
+ absorb, route, open, hand the next card, close, clear a delivery's
190
+ record, close the delivery — and never names init there.
191
+
157
192
  Intended use: agents and humans run it to resume work without
158
193
  re-reading the whole tree.
159
194
 
@@ -0,0 +1,113 @@
1
+ // @ts-check
2
+ import process from "node:process";
3
+ import { flagBool } from "../lib/args.js";
4
+ import { c } from "../lib/colors.js";
5
+ import { emitJson } from "../lib/json-out.js";
6
+ import { EXIT } from "../lib/exit-codes.js";
7
+ import { TRACKS } from "../lib/triage-model.js";
8
+ import { firstTaskCandidates, onboardState, onboardSteps, tourLines } from "../lib/onboard.js";
9
+
10
+ // `onboard` walks the whole cycle once, on this repository (change 0256).
11
+ // It reads where the project stands and prints the walk; the agent is the
12
+ // guide and the human the one being shown. It writes nothing and works
13
+ // before `init`, because the first step is the install.
14
+
15
+ export const jsonNative = true;
16
+
17
+ export const flags = { boolean: ["json"], string: [] };
18
+
19
+ export async function run(_positional, cmdFlags) {
20
+ const projectRoot = process.cwd();
21
+ const state = onboardState(projectRoot);
22
+ const { steps, current } = onboardSteps(state);
23
+ const wantsCandidates = current === "first-task" && state.installed;
24
+ const candidates = wantsCandidates ? firstTaskCandidates(projectRoot) : [];
25
+
26
+ if (flagBool(cmdFlags, "json", false)) {
27
+ emitJson("onboard", {
28
+ current,
29
+ steps: steps.map((s) => ({ id: s.id, title: s.title, done: s.done, run: s.run, ask: s.ask || null })),
30
+ candidates,
31
+ closed: state.closed,
32
+ });
33
+ return EXIT.OK;
34
+ }
35
+
36
+ console.log(c.bold("Doctrina onboarding") + c.gray(" — the whole cycle once, on this repository"));
37
+ console.log("");
38
+ console.log("You are the guide. Do the step marked →, say in one sentence what it did,");
39
+ console.log("stop where it says ASK, then run `doctrina onboard` again: it reads the tree.");
40
+ console.log("");
41
+ steps.forEach((s, i) => {
42
+ const mark = s.done ? c.green("✓") : (s.id === current ? c.cyan("→") : " ");
43
+ console.log(` ${mark} ${i + 1}. ${s.done ? s.title.padEnd(31) + c.gray(s.evidence) : s.title}`);
44
+ });
45
+ console.log("");
46
+
47
+ if (current === null) {
48
+ console.log(c.bold("The cycle has run here") + c.gray(` — ${state.closed} change${state.closed === 1 ? "" : "s"} closed. For someone new to the project:`));
49
+ console.log("");
50
+ for (const line of tourLines(state)) console.log(` ${line}`);
51
+ console.log("");
52
+ printTracks();
53
+ return EXIT.OK;
54
+ }
55
+
56
+ const at = steps.findIndex((s) => s.id === current);
57
+ const step = steps[at];
58
+ console.log(c.bold(`Step ${at + 1} — ${step.title}`));
59
+ console.log(` ${c.gray("Tell the human:")} ${step.say}`);
60
+ if (wantsCandidates) {
61
+ console.log("");
62
+ if (candidates.length === 0) {
63
+ console.log(c.gray(" No candidate stands out in this repository (every criterion cites proof, no TODO in the code)."));
64
+ } else {
65
+ console.log(" Candidates read from this repository:");
66
+ for (const cand of candidates) console.log(` - ${cand.where.padEnd(24)} ${cand.text}`);
67
+ }
68
+ }
69
+ const after_ = "askAfter" in step && step.askAfter === true;
70
+ if (step.ask && !after_) console.log(` ${c.yellow("ASK")} the human ${step.ask}`);
71
+ console.log(step.ask && !after_ ? " Then run:" : " Run:");
72
+ for (const cmd of step.run) console.log(` ${c.cyan(cmd)}`);
73
+ if (step.ask && after_) console.log(` Then ${c.yellow("ASK")} the human ${step.ask}`);
74
+ if (current === "first-task") {
75
+ console.log("");
76
+ printTracks();
77
+ }
78
+ const after = steps[at + 1];
79
+ if (after) console.log(c.gray(`\nAfter it: step ${at + 2}, ${after.title.toLowerCase()} — \`doctrina onboard\` shows it.`));
80
+ return EXIT.OK;
81
+ }
82
+
83
+ function printTracks() {
84
+ console.log(c.gray("Not every edit is a change — the tracks, by what a request changes:"));
85
+ for (const t of TRACKS) console.log(c.gray(` ${t.id.padEnd(7)} ${t.changes}`));
86
+ }
87
+
88
+ export const help = `
89
+ Usage: doctrina onboard [--json]
90
+
91
+ Walk the whole Doctrina cycle once, on this repository. Prints the six
92
+ steps — install, describe the project, pick a first task and open it,
93
+ write the delta and the tasks, implement and prove, close — each marked
94
+ done when the tree says so, and for the first one that is not: what to
95
+ tell the human, where to stop and ask, and the commands to run. Run it
96
+ again after each step; it stores nothing and reads the tree every time.
97
+
98
+ At "pick a first task" it lists candidates read from the repository: an
99
+ acceptance criterion that cites no proof (or a proof that is missing),
100
+ then a TODO or FIXME left in the code. They are suggestions; the human
101
+ chooses, or names a task of their own.
102
+
103
+ In a project that already closed a change, every step is done and it
104
+ prints the tour for someone new to the project instead: the commands to
105
+ read the project with, and the last closed change as a worked example.
106
+
107
+ It works before \`doctrina init\` (the first step is the install) and
108
+ writes nothing.
109
+
110
+ Options:
111
+ --json Machine-readable: the steps with their state, the current
112
+ step, and the first-task candidates
113
+ `;
@@ -1,10 +1,11 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { exists, read } from "../lib/fs-ops.js";
4
+ import { exists, isFile, read } from "../lib/fs-ops.js";
5
5
  import { findWorkspace, readWorkspace } from "../lib/workspace.js";
6
6
  import { listDeliveries } from "../lib/delivery.js";
7
7
  import { workspacePrimer } from "../lib/workspace-agents.js";
8
+ import { projectWorkspaceLine } from "../lib/workspace-view.js";
8
9
  import { flagBool, flagString } from "../lib/args.js";
9
10
  import { c } from "../lib/colors.js";
10
11
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
@@ -51,6 +52,14 @@ export async function run(_positional, flags) {
51
52
  // --pulse: one line for a per-prompt hook — the open changes only, read
52
53
  // without collecting the gates, because it runs before every prompt.
53
54
  if (flagBool(flags, "pulse", false)) {
55
+ // Run by Cursor as the Claude adapter's UserPromptSubmit (sixth audit,
56
+ // change 0237): Cursor imports `.claude/settings.json` hooks and runs it
57
+ // as beforeSubmitPrompt, whose answer is `{"continue": …}` and cannot add
58
+ // context. The plain-text line Claude Code reads is not that.
59
+ if (underCursor()) {
60
+ console.log(JSON.stringify({ continue: true }));
61
+ return 0;
62
+ }
54
63
  for (const line of pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))) console.log(line);
55
64
  return 0;
56
65
  }
@@ -60,17 +69,55 @@ export async function run(_positional, flags) {
60
69
  console.error(c.red("error:") + ` --hook needs one of ${HOOK_AGENTS.join(", ")} (got "${agent ?? ""}")`);
61
70
  return EXIT.USAGE;
62
71
  }
72
+ const as = hookFlavour(agent, projectRoot);
73
+ if (as === null) return 0;
63
74
  const text = ["Doctrina session primer (`doctrina prime`) — the project's gates, rules and open work:", "",
64
- ...renderView("prime", collectSnapshot(projectRoot)), "", ...pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))]
75
+ ...renderView("prime", collectSnapshot(projectRoot)), ...workspaceLines(projectRoot), "", ...pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))]
65
76
  .join("\n").replace(/\x1b\[[0-9;]*m/g, "").slice(0, HOOK_MAX_CHARS);
66
- console.log(agent === "cursor" ? JSON.stringify({ additional_context: text }) : text);
77
+ console.log(as === "cursor" ? JSON.stringify({ additional_context: text }) : text);
67
78
  return 0;
68
79
  }
69
80
  const view = flagBool(flags, "rules", false) ? "rules" : "prime";
70
81
  for (const line of renderView(view, collectSnapshot(projectRoot))) console.log(line);
82
+ if (view === "prime") for (const line of workspaceLines(projectRoot)) console.log(line);
71
83
  return 0;
72
84
  }
73
85
 
86
+ // Cursor runs the hooks Claude Code's `.claude/settings.json` declares too
87
+ // (its "third-party" import, on by default), and sets CURSOR_VERSION and
88
+ // CURSOR_PROJECT_DIR for every hook it runs (sixth audit, change 0237).
89
+ function underCursor(env = process.env) {
90
+ return Boolean(env.CURSOR_VERSION && env.CURSOR_PROJECT_DIR);
91
+ }
92
+
93
+ /**
94
+ * The format a session hook answers in, or null to answer nothing. The
95
+ * Claude adapter's SessionStart, run by Cursor, printed plain text where
96
+ * Cursor reads JSON — and beside the Cursor adapter's own sessionStart it
97
+ * put the primer in twice. Under Cursor it yields to that hook when the
98
+ * folder has one, and speaks Cursor's format when it has not.
99
+ * @returns {"claude" | "cursor" | null}
100
+ */
101
+ function hookFlavour(agent, dir) {
102
+ if (agent !== "claude" || !underCursor()) return /** @type {"claude" | "cursor"} */ (agent);
103
+ const own = path.join(dir, ".cursor", "hooks.json");
104
+ return isFile(own) && /prime --hook cursor/.test(read(own)) ? null : "cursor";
105
+ }
106
+
107
+ // A project inside a workspace says so (change 0230): a session opened in
108
+ // the repository — an executor's, or the human's — otherwise never learns a
109
+ // delivery is open on it, or where its planner works.
110
+ function workspaceLines(projectRoot) {
111
+ const w = projectWorkspaceLine(projectRoot);
112
+ if (!w) return [];
113
+ const out = ["", `Workspace ${w.name} (${w.dir}) — this repository is "${w.project}"`];
114
+ for (const d of w.deliveries) {
115
+ out.push(` open delivery ${d.id} — ${d.title}: ${d.changes.length ? `this project's change ${d.changes.join(", ")}` : "no change here yet"}`);
116
+ }
117
+ if (w.deliveries.length === 0) out.push(" no open delivery routes here");
118
+ return out;
119
+ }
120
+
74
121
  function primeWorkspace(wsDir, flags) {
75
122
  const ws = readWorkspace(wsDir);
76
123
  const open = listDeliveries(wsDir).filter((d) => d.status !== "closed");
@@ -87,8 +134,10 @@ function primeWorkspace(wsDir, flags) {
87
134
  console.error(c.red("error:") + ` --hook needs one of ${HOOK_AGENTS.join(", ")} (got "${agent ?? ""}")`);
88
135
  return EXIT.USAGE;
89
136
  }
137
+ const as = hookFlavour(agent, wsDir);
138
+ if (as === null) return 0;
90
139
  const text = [...lines, "", read(path.join(wsDir, "AGENTS.md"))].join("\n").replace(/\x1b\[[0-9;]*m/g, "").slice(0, HOOK_MAX_CHARS);
91
- console.log(agent === "cursor" ? JSON.stringify({ additional_context: text }) : text);
140
+ console.log(as === "cursor" ? JSON.stringify({ additional_context: text }) : text);
92
141
  return 0;
93
142
  }
94
143
  for (const line of lines) console.log(line);
@@ -6,6 +6,7 @@ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
6
6
  import { flagBool, flagString } from "../lib/args.js";
7
7
  import { c } from "../lib/colors.js";
8
8
  import { rankCapabilitiesByDiff } from "../lib/work-model.js";
9
+ import { applyOps, extractOps } from "../lib/spec-ops.js";
9
10
  import { readLedger, churnByCapability } from "../lib/ledger.js";
10
11
  import { changedFiles, windowCutoff, historyState, refExists } from "../lib/git.js";
11
12
  import { dependentsOf, deltaCapabilities } from "../lib/scan.js";
@@ -67,6 +68,8 @@ export async function run(_positional, flags) {
67
68
  const changeId = flagString(flags, "change");
68
69
  /** @type {Set<string> | null} */
69
70
  let changeCaps = null;
71
+ /** @type {Map<string, string>} the change's delta text per capability */
72
+ const pending = new Map();
70
73
  if (changeId !== undefined) {
71
74
  const refused = refuseChangeRef(changeId);
72
75
  if (refused !== null) return refused;
@@ -76,6 +79,19 @@ export async function run(_positional, flags) {
76
79
  return EXIT.USAGE;
77
80
  }
78
81
  changeCaps = new Set(deltaCapabilities(changeDir));
82
+ // The specs as this change leaves them: an ops delta applied in memory,
83
+ // as `change check` dry-runs it; a delta without ops (a manual merge)
84
+ // read beside the spec, since nothing can apply it.
85
+ for (const cap of changeCaps) {
86
+ const delta = path.join(changeDir, "specs", cap, "delta.md");
87
+ const spec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
88
+ if (!isFile(delta)) continue;
89
+ const deltaText = read(delta);
90
+ const current = isFile(spec) ? read(spec) : "";
91
+ const ops = extractOps(deltaText);
92
+ const after = ops.length > 0 ? applyOps(current, ops) : null;
93
+ pending.set(cap, after && after.errors.length === 0 ? after.text : `${current}\n${deltaText}`);
94
+ }
79
95
  }
80
96
 
81
97
  const { all, sourceFiles } = reviewScope(projectRoot, against);
@@ -100,13 +116,21 @@ export async function run(_positional, flags) {
100
116
  // capability whose spec.md itself changed.
101
117
  const specChanged = new Set(
102
118
  all.filter((f) => /(^|\/)\.doctrina\/specs\/[^/]+\/spec\.md$/.test(f.replace(/\\/g, "/")))
103
- .map((f) => f.replace(/\\/g, "/").match(/\.doctrina\/specs\/([^/]+)\/spec\.md$/)[1]),
119
+ .map((f) => f.replace(/\\/g, "/").match(/\.doctrina\/specs\/([^/]+)\/spec\.md$/)?.[1] ?? ""),
104
120
  );
105
- const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity });
121
+ const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity, pending });
106
122
  const touched = new Set([...specChanged, ...ranked.map((m) => m.id)]);
107
123
  const stale = ranked.map((m) => m.id).filter((id) => !specChanged.has(id) && !changeCaps?.has(id));
124
+ // Without --change, an OPEN change that carries a delta for the capability
125
+ // updates its spec at close (fifth audit, change 0236): the bare review
126
+ // reported "its spec was not updated" for exactly the work a planner had
127
+ // in flight — the real agent read it as a break and went to fix the spec.
128
+ const inFlight = changeCaps === null ? openDeltaOwners(projectRoot) : new Map();
108
129
  if (changeCaps === null) {
109
- for (const id of stale) {
130
+ for (const id of stale.filter((x) => inFlight.has(x))) {
131
+ notes.push(`code under capability "${id}" changed; its spec is updated when ${inFlight.get(id)?.join(", ")} closes (\`doctrina review --change <id>\` reviews it as that close does)`);
132
+ }
133
+ for (const id of stale.filter((x) => !inFlight.has(x))) {
110
134
  breaks.push(`code under capability "${id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${id}/spec.md\` still describes the code (update it, or note why not)`);
111
135
  }
112
136
  } else if (stale.length > 0) {
@@ -151,7 +175,7 @@ export async function run(_positional, flags) {
151
175
  // file in the change. A review of an adapter change reported
152
176
  // "Capabilities touched: docs" and said nothing about the adapter.
153
177
  const orphans = sourceFiles.filter(
154
- (f) => rankCapabilitiesByDiff(projectRoot, [f], { limit: 1 }).length === 0,
178
+ (f) => rankCapabilitiesByDiff(projectRoot, [f], { limit: 1, pending }).length === 0,
155
179
  );
156
180
  if (orphans.length > 0) {
157
181
  const shown = orphans.slice(0, ORPHAN_LIST_LIMIT).map((f) => `\`${f}\``).join(", ");
@@ -274,3 +298,15 @@ from specs that declare no Source.
274
298
  Read-only. Exits 0 as a report; with --strict, exits 1 when any hard break
275
299
  exists (CI gate). Notes (advisory) never fail the gate.
276
300
  `;
301
+
302
+ /** Capability → the open changes that carry a delta for it. */
303
+ function openDeltaOwners(projectRoot) {
304
+ const out = new Map();
305
+ const changes = path.join(projectRoot, ".doctrina", "changes");
306
+ if (!isDir(changes)) return out;
307
+ for (const id of readdirSync(changes).sort()) {
308
+ if (id === "archive" || id.startsWith(".") || !isDir(path.join(changes, id))) continue;
309
+ for (const cap of deltaCapabilities(path.join(changes, id))) out.set(cap, [...(out.get(cap) ?? []), id]);
310
+ }
311
+ return out;
312
+ }
@@ -59,8 +59,8 @@ export async function run(positional, flags) {
59
59
  for (const cat of categories) {
60
60
  const files = cat.file
61
61
  ? [path.join(projectRoot, cat.file)].filter(exists)
62
- : isDir(path.join(projectRoot, cat.dir))
63
- ? walk(path.join(projectRoot, cat.dir)).filter((f) => f.endsWith(".md"))
62
+ : isDir(path.join(projectRoot, cat.dir ?? ""))
63
+ ? walk(path.join(projectRoot, cat.dir ?? "")).filter((f) => f.endsWith(".md"))
64
64
  : [];
65
65
 
66
66
  const findings = [];
@@ -455,7 +455,8 @@ knowledge loaded only when relevant.
455
455
 
456
456
  Subcommands:
457
457
  new <name> Scaffold a new skill from the template and index
458
- it. Name must match [a-z][a-z0-9-]*.
458
+ it. Name must match [a-z][a-z0-9-]*. --force
459
+ overwrites an existing skill.
459
460
  list Print one line per skill with name + description.
460
461
  Read-only.
461
462
  sync Copy each skill's frontmatter description into