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,11 +5,15 @@ import { exists } from "../lib/fs-ops.js";
5
5
  import * as idx from "../lib/index-json.js";
6
6
  import { deriveIndex, indexesMatch } from "../lib/scan.js";
7
7
  import { cliVersion } from "../lib/version.js";
8
- import { flagBool } from "../lib/args.js";
8
+ import { flagBool, flagString } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
10
10
  import { today } from "../lib/dates.js";
11
- import { notADoctrinaProject } from "../lib/exit-codes.js";
11
+ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { applyPreset, describePreset, planPreset, presetPending, readPreset } from "../lib/preset.js";
12
13
  import { planSkillExport, applySkillExport } from "../lib/skill-export.js";
14
+ import { applyAdapterMigration, planAdapterMigration } from "../lib/adapters.js";
15
+ import { projectName } from "../lib/project.js";
16
+ import { relinkShadow, shadowRootOf } from "../lib/workspace.js";
13
17
  import { collectFindings } from "../lib/templates-model.js";
14
18
  import * as templates from "./templates.js";
15
19
  import * as validate from "./validate.js";
@@ -27,7 +31,7 @@ import * as validate from "./validate.js";
27
31
  // Flags this command accepts. Declared HERE, with the command, so
28
32
  // adding a command never requires editing the entrypoint — the gap that
29
33
  // let six flags ship undeclared and silently swallow a positional (C3).
30
- export const flags = { boolean: ["json", "write"], string: [] };
34
+ export const flags = { boolean: ["json", "write"], string: ["preset"] };
31
35
 
32
36
  export async function run(_positional, flags) {
33
37
  const projectRoot = process.cwd();
@@ -55,6 +59,27 @@ export async function run(_positional, flags) {
55
59
 
56
60
  let pending = 0;
57
61
 
62
+ // 0. The organisation's preset, when one is named (change 0253): laid
63
+ // over the project before the scaffold is measured, so the steps below
64
+ // check the project as it will be.
65
+ const presetDir = flagString(flags, "preset");
66
+ if (flags.has("preset")) {
67
+ let preset;
68
+ try {
69
+ if (!presetDir) throw new Error("--preset needs the preset's folder");
70
+ preset = readPreset(presetDir);
71
+ } catch (err) {
72
+ console.error(c.red("error:") + ` ${/** @type {Error} */ (err).message}`);
73
+ return EXIT.USAGE;
74
+ }
75
+ const plan = planPreset(projectRoot, preset);
76
+ if (writeMode) applyPreset(projectRoot, preset, plan);
77
+ else if (presetPending(plan) > 0) pending += 1;
78
+ console.log(c.gray("──── preset"));
79
+ for (const line of describePreset(preset, plan, writeMode)) console.log(line);
80
+ console.log("");
81
+ }
82
+
58
83
  // 2. Additive template updates (missing recommended sections in AGENTS.md /
59
84
  // product.md, missing index.json fields). Reuses `templates update`
60
85
  // verbatim: preview without --write, apply with it.
@@ -78,6 +103,8 @@ export async function run(_positional, flags) {
78
103
  // The skill exports (change 0212) follow the index: regenerated by the
79
104
  // write, reported by the preview.
80
105
  const exportPlan = planSkillExport(projectRoot);
106
+ // Adapter files that moved (change 0238): Cursor's slash commands became skills.
107
+ const movePlan = planAdapterMigration(projectRoot, { PROJECT_NAME: projectName(projectRoot), PROJECT_DESCRIPTION: "", DATE: today() }, shadowRootOf(projectRoot) ?? projectRoot);
81
108
  const exportChanges = exportPlan.writes.length + exportPlan.removes.length;
82
109
 
83
110
  // 3. Index: rebuild from the tree, which also migrates the framework stamp.
@@ -96,6 +123,29 @@ export async function run(_positional, flags) {
96
123
  pending += 1;
97
124
  }
98
125
  }
126
+ const moves = movePlan.writes.length + movePlan.removes.length;
127
+ // What the installed adapters gained since this project added them.
128
+ if (movePlan.added.length > 0) {
129
+ const names = movePlan.added.map((a) => a.relativePath).join(", ");
130
+ if (writeMode) console.log(c.green("added ") + ` ${movePlan.added.length} adapter file${movePlan.added.length === 1 ? "" : "s"} this CLI ships now: ${names}`);
131
+ else {
132
+ console.log(c.yellow("would ") + `add ${movePlan.added.length} adapter file${movePlan.added.length === 1 ? "" : "s"} this CLI ships now: ${names}`);
133
+ pending += 1;
134
+ }
135
+ }
136
+ if (moves > 0 || (writeMode && movePlan.added.length > 0)) {
137
+ if (writeMode && moves === 0) applyAdapterMigration(shadowRootOf(projectRoot) ?? projectRoot, movePlan);
138
+ else if (writeMode) {
139
+ const gone = applyAdapterMigration(shadowRootOf(projectRoot) ?? projectRoot, movePlan);
140
+ relinkShadow(projectRoot, gone);
141
+ console.log(c.green("moved ") + ` ${movePlan.writes.length} adapter file${movePlan.writes.length === 1 ? "" : "s"} to the skills the agent reads now` +
142
+ (movePlan.removes.length ? `, ${movePlan.removes.length} retired cop${movePlan.removes.length === 1 ? "y" : "ies"} removed` : ""));
143
+ } else {
144
+ console.log(c.yellow("would ") + `move ${movePlan.removes.length || movePlan.writes.length} Cursor command${(movePlan.removes.length || movePlan.writes.length) === 1 ? "" : "s"} to .cursor/skills/ (Cursor reads skills now)`);
145
+ pending += 1;
146
+ }
147
+ }
148
+ for (const rel of movePlan.kept) console.log(c.yellow("kept ") + ` ${rel} — edited since install; its skill replaces it, so move your edits and remove it`);
99
149
  if (!drifted && !staleStamp) {
100
150
  console.log(c.green("ok") + " index.json matches the tree and the stamp is current");
101
151
  } else if (writeMode) {
@@ -155,7 +205,7 @@ export async function run(_positional, flags) {
155
205
  }
156
206
 
157
207
  export const help = `
158
- Usage: doctrina upgrade [--write]
208
+ Usage: doctrina upgrade [--write] [--preset <dir>]
159
209
 
160
210
  Bring an existing project up to the installed CLI after an npm update.
161
211
  The project keeps the scaffold of the version that init-ed it; this is
@@ -174,6 +224,14 @@ the one command that closes the gap, orchestrating the existing pieces:
174
224
  3. validate (--fix) — surface anything the upgrade cannot fix
175
225
  (hand-authored drift, new validate checks)
176
226
 
227
+ --preset <dir> first lays an organisation's preset over the project: a
228
+ folder with preset.json ({ "name", "description", "config" }) and optional
229
+ templates/, decisions/, skills/ and verify.json. Additive — a file the
230
+ project has is kept, a config key it declares is kept, an ADR number it
231
+ uses is not overwritten — and the plan names each one. A preset's
232
+ verify.json is copied only when the project has none, and the commands it
233
+ would run are printed.
234
+
177
235
  Preview by default (exits 1 when steps are pending or a manual repair is
178
236
  left); --write applies. The preview is what \`templates check\` reported
179
237
  and --write what \`templates update --write\` did — both deprecated.
@@ -7,6 +7,7 @@ import { c } from "../lib/colors.js";
7
7
  import { emitJson } from "../lib/json-out.js";
8
8
  import { notADoctrinaProject } from "../lib/exit-codes.js";
9
9
  import { collectValidation } from "../lib/validation-model.js";
10
+ import { contextRouting, workspaceActions, workspaceFindings, workspaceHandoff, workspaceRootAt, workspaceSnapshot, workspaceStatus } from "../lib/workspace-view.js";
10
11
 
11
12
  // The structural gate. The checks themselves live in lib/validation-model.js
12
13
  // so a driver can ASK for them instead of spawning this binary and parsing
@@ -27,6 +28,19 @@ export async function run(_positional, flags) {
27
28
  // Outside a Doctrina project this is a PRECONDITION, not a pile of
28
29
  // gate failures about missing files: the remedy is `doctrina init`,
29
30
  // not editing artifacts that do not exist yet (C7).
31
+ // At a workspace root: the workspace's own integrity — registry, shadow
32
+ // links, deliveries (change 0230). Each project validates inside itself.
33
+ const wsRoot = workspaceRootAt(projectRoot);
34
+ if (wsRoot) {
35
+ const { errors, warnings } = workspaceFindings(workspaceSnapshot(wsRoot));
36
+ for (const e of errors) console.log(c.red("error: ") + e);
37
+ for (const w of warnings) console.log(c.yellow("warn: ") + w);
38
+ console.log("");
39
+ console.log(errors.length === 0
40
+ ? c.green("ok") + ` the workspace is consistent${warnings.length ? ` (${warnings.length} warning${warnings.length === 1 ? "" : "s"})` : ""} — each project validates inside itself: \`cd <path> && doctrina validate\``
41
+ : c.red("fail") + ` ${errors.length} workspace error${errors.length === 1 ? "" : "s"}`);
42
+ return errors.length > 0 || (flagBool(flags, "strict", false) && warnings.length > 0) ? 1 : 0;
43
+ }
30
44
  if (!isDir(path.join(projectRoot, ".doctrina"))) throw notADoctrinaProject();
31
45
 
32
46
  const { errors, warnings, fixes } = collectValidation(projectRoot, {
@@ -82,6 +96,10 @@ Run schema, artifact-existence, and structural checks against the
82
96
  .doctrina/ tree in the current working directory. Exits 0 if no errors
83
97
  (warnings allowed), 1 otherwise. With --strict, a warning fails it too.
84
98
 
99
+ At a workspace root it checks the workspace instead — registered
100
+ projects and their dependencies, shadow links, deliveries — and leaves
101
+ each project's validation to \`cd <path> && doctrina validate\`.
102
+
85
103
  Two structural checks worth naming:
86
104
 
87
105
  Pipeline a spec's optional "### Pipeline" block declares ordered
@@ -5,6 +5,7 @@ import { readdirSync } from "node:fs";
5
5
  import { wantsJson } from "../lib/json-out.js";
6
6
  import { spawn, spawnSync } from "node:child_process";
7
7
  import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
8
+ import { inventory } from "../lib/code-inventory.js";
8
9
  import { flagBool, flagString } from "../lib/args.js";
9
10
  import { today } from "../lib/dates.js";
10
11
  import { c } from "../lib/colors.js";
@@ -38,6 +39,16 @@ const STARTER = {
38
39
  ],
39
40
  };
40
41
 
42
+ const SEEDED_COMMENT =
43
+ "doctrina verify runs each check below, in order, through your shell. " +
44
+ "These were seeded from the project's own manifests by the code " +
45
+ "inventory — confirm each one runs what its name says, and remove or add " +
46
+ "checks until this file is the project's executable definition of 'the " +
47
+ "code actually works'. A non-zero exit fails the gate. A check may add an " +
48
+ "\"expect\" block — {\"fail_if_output_matches\": \"<regex>\"} and/or " +
49
+ "{\"require_output_matches\": \"<regex>\"} — so that a run which exits 0 " +
50
+ "having executed NOTHING fails the gate instead of passing it.";
51
+
41
52
  // Flags this command accepts. Declared HERE, with the command, so
42
53
  // adding a command never requires editing the entrypoint — the gap that
43
54
  // let six flags ship undeclared and silently swallow a positional (C3).
@@ -69,9 +80,24 @@ export async function run(_positional, flags) {
69
80
  console.error(c.red("error:") + ` ${CONFIG_REL} already exists (pass --force to overwrite)`);
70
81
  return 1;
71
82
  }
72
- write(configPath, JSON.stringify(STARTER, null, 2) + "\n", { force: true });
73
- console.log(c.green("created") + ` ${CONFIG_REL}`);
74
- console.log(c.gray("Edit the `checks` array with your real commands, then run `doctrina verify`."));
83
+ // Seeded from the code inventory when it found the project's own build
84
+ // and test commands (change 0228): the absorb playbook says "declare the
85
+ // build gate from the inventory's candidate commands", and the inventory
86
+ // lists them under that heading — while --init wrote `exit 1` anyway,
87
+ // leaving the agent to copy by hand what the CLI had just read. With no
88
+ // candidate the starter stays fail-closed: a gate nobody declared must
89
+ // not pass.
90
+ // A check that belongs to one unit of a monorepo runs there (`cwd`).
91
+ const seeded = inventory(projectRoot).checks.map((ch) => ({ name: ch.name, run: ch.command, ...(ch.cwd ? { cwd: ch.cwd } : {}) }));
92
+ const config = seeded.length > 0
93
+ ? { ...STARTER, $comment: SEEDED_COMMENT, checks: seeded }
94
+ : STARTER;
95
+ write(configPath, JSON.stringify(config, null, 2) + "\n", { force: true });
96
+ console.log(c.green("created") + ` ${CONFIG_REL}` +
97
+ (seeded.length > 0 ? c.gray(` — seeded from the code inventory: ${seeded.map((s) => s.cwd ? `${s.run} (in ${s.cwd}/)` : s.run).join(", ")}`) : ""));
98
+ console.log(c.gray(seeded.length > 0
99
+ ? "Confirm each check runs what its name says (add an \"expect\" block where a run could execute nothing), then run `doctrina verify`."
100
+ : "Edit the `checks` array with your real commands, then run `doctrina verify`."));
75
101
  return 0;
76
102
  }
77
103
 
@@ -223,8 +249,8 @@ export async function run(_positional, flags) {
223
249
  results.push({ name, ok: true, kind: "manual", signoff: "fresh" });
224
250
  } else if (verdict.state === "expired") {
225
251
  console.log(c.yellow(`○ ${name} — sign-off expired`) +
226
- c.gray(` (signed ${so.date} at ${short(so.sha)}; changed since: ${verdict.changed.slice(0, 3).join(", ")}` +
227
- `${verdict.changed.length > 3 ? `, +${verdict.changed.length - 3} more` : ""})`) + resign);
252
+ c.gray(` (signed ${so.date} at ${short(so.sha)}; changed since: ${(verdict.changed ?? []).slice(0, 3).join(", ")}` +
253
+ `${(verdict.changed ?? []).length > 3 ? `, +${(verdict.changed ?? []).length - 3} more` : ""})`) + resign);
228
254
  results.push({ name, ok: false, kind: "manual", pending: true, signoff: "expired" });
229
255
  } else if (verdict.state === "unverifiable") {
230
256
  console.log(c.yellow(`○ ${name} — sign-off cannot be verified`) +
@@ -294,7 +320,7 @@ export async function run(_positional, flags) {
294
320
  const verdict = judgeOutput(expectation, output);
295
321
  if (!verdict.ok) {
296
322
  ok = false;
297
- expectationNote = verdict.reason;
323
+ expectationNote = verdict.reason ?? null;
298
324
  }
299
325
  }
300
326
 
@@ -488,7 +514,9 @@ copy accumulates for the match. An "expect" pattern that is not a valid
488
514
  regular expression fails at config time, not silently.
489
515
 
490
516
  Flags:
491
- --init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
517
+ --init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force):
518
+ the build and test commands the code inventory finds in the
519
+ project's manifests, or a fail-closed example when it finds none
492
520
  --list Print the configured checks without running them
493
521
  --clean Don't run checks; lint the project's package.json files for
494
522
  reproducibility footguns (entry points in a build dir with no
@@ -174,7 +174,7 @@ function whyAnchor(projectRoot, id, specsDir, known) {
174
174
  for (const capName of known.sort()) {
175
175
  const text = read(path.join(specsDir, capName, "spec.md"));
176
176
  const realizesRaw = specHeader(text, "Realizes");
177
- const ids = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? []) : [];
177
+ const ids = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? /** @type {string[]} */ ([])) : /** @type {string[]} */ ([]);
178
178
  if (ids.includes(id)) realizing.push({ cap: capName, text });
179
179
  }
180
180
 
@@ -1,24 +1,26 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { readdirSync } from "node:fs";
5
- import { exists, isDir, isFile, mkdirp, read, relPath, write } from "../lib/fs-ops.js";
4
+ import { eolOf, exists, isDir, isFile, mkdirp, read, relPath, withEol, write } from "../lib/fs-ops.js";
6
5
  import { flagString, flagBool } from "../lib/args.js";
7
6
  import * as idx from "../lib/index-json.js";
8
7
  import { c } from "../lib/colors.js";
9
8
  import { assessBrief } from "../lib/clarity.js";
10
9
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
11
10
  import { findWorkspace, projectAt, readWorkspace } from "../lib/workspace.js";
12
- import { readDelivery, resolveDeliveryId } from "../lib/delivery.js";
11
+ import { readDelivery, resolveDeliveryId, sectionText } from "../lib/delivery.js";
13
12
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
13
+ import { listHeader } from "../lib/scan.js";
14
14
  import { changeNew, reindexChange, addAffectedSpec } from "../lib/change-ops.js";
15
- import { classify } from "../lib/triage-model.js";
15
+ import { classify, directSignals, trackLines } from "../lib/triage-model.js";
16
16
  import { printPlaybookTemplate } from "../lib/playbook.js";
17
17
  import { specShape } from "../lib/spec-ops.js";
18
18
  import { changedFiles } from "../lib/git.js";
19
19
  import { slugFromPrompt, fold, CONFIDENT_MARGIN } from "../lib/lexicon.js";
20
20
  import { rankCapabilities, rankCapabilitiesByDiff, similarChanges, SIMILAR_CHANGE_DAYS } from "../lib/work-model.js";
21
21
  import { isChangeId, refuseChangeRef } from "../lib/project.js";
22
+ import { nextChangeNumber } from "../lib/change-numbers.js";
23
+ import { withProjectLock } from "../lib/lock.js";
22
24
  export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
23
25
 
24
26
  // `work` is the second half of the no-ceremony path (ADR 0005): a brief
@@ -67,6 +69,7 @@ export async function run(positional, flags) {
67
69
  // workspace delivery. Checked BEFORE anything is scaffolded, so a typo or a
68
70
  // repository the delivery was never routed to opens nothing.
69
71
  let delivery = null;
72
+ let deliveryStory = "";
70
73
  if (flags.has("delivery")) {
71
74
  const resolved = resolveDeliveryFor(projectRoot, flagString(flags, "delivery"));
72
75
  if (resolved.error) {
@@ -75,13 +78,14 @@ export async function run(positional, flags) {
75
78
  return resolved.code;
76
79
  }
77
80
  delivery = resolved.id;
81
+ deliveryStory = resolved.story ?? "";
78
82
  }
79
83
 
80
84
  // --resume <id>: reprint the playbook for an existing open change rather than
81
85
  // open a new one (review G1 — `work "continue"` used to slug the word and
82
86
  // fabricate a junk `NNNN-continue` change the operator had to delete).
83
87
  if (flags.has("resume")) {
84
- return resumeChange(projectRoot, flagString(flags, "resume"));
88
+ return resumeChange(projectRoot, flagString(flags, "resume"), delivery);
85
89
  }
86
90
 
87
91
  // --from-diff backfills from code already written (review F8): the
@@ -172,6 +176,26 @@ export async function run(positional, flags) {
172
176
  }
173
177
  }
174
178
 
179
+ // THE DIRECT TRACK (change 0255). An edit that changes nothing a spec says
180
+ // opens nothing: the answer is the three tracks and the two commands that
181
+ // remain. Only the words that cannot mean behaviour decide it, and naming
182
+ // a capability, a delivery or an id says the author already chose.
183
+ if (prompt && verdict && !fromDiff && !chore && !forced &&
184
+ !flags.has("capability") && !flags.has("delivery") && !flags.has("id")) {
185
+ const hits = directSignals(prompt, verdict);
186
+ if (hits.length > 0) {
187
+ console.log(c.green("direct:") + ` no change to open — this reads as an edit that changes nothing a spec says ${c.gray(`(signals: ${[...new Set(hits)].slice(0, 6).join(", ")})`)}`);
188
+ console.log(` Edit it, run ${c.cyan("doctrina verify")}, and commit. No proposal, no delta, no close.`);
189
+ console.log("");
190
+ console.log("The tracks, by what the request changes:");
191
+ for (const line of trackLines()) console.log(line);
192
+ console.log("");
193
+ console.log(c.gray("If it does change behaviour, or deserves a record: ") +
194
+ c.cyan(`doctrina work --force "<request>"`) + c.gray(" · ") + c.cyan(`doctrina work --chore "<request>"`));
195
+ return EXIT.OK;
196
+ }
197
+ }
198
+
175
199
  const pinned = flagString(flags, "capability");
176
200
  if (pinned && !/^[a-z][a-z0-9-]*$/.test(pinned)) {
177
201
  console.error(c.red("error:") + ` invalid capability "${pinned}" (lowercase letters, digits, hyphens)`);
@@ -192,14 +216,19 @@ export async function run(positional, flags) {
192
216
  const slug = flags.has("title")
193
217
  ? slugify(title)
194
218
  : (prompt ? slugFromPrompt(prompt) : "backfill");
195
- const id = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slug}`;
196
- if (!isChangeId(id)) {
219
+ // The number is read and the folder created under one lock (change 0250):
220
+ // read apart, eight parallel `work` opened three changes numbered 0003.
221
+ const opened = withProjectLock(projectRoot, () => {
222
+ const candidate = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slug}`;
223
+ if (!isChangeId(candidate)) return { id: candidate, code: null };
224
+ return { id: candidate, code: changeNew([candidate, title], flags) };
225
+ });
226
+ const id = opened.id;
227
+ if (opened.code === null) {
197
228
  console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
198
229
  return 2;
199
230
  }
200
-
201
- const code = changeNew([id, title], flags);
202
- if (code !== 0) return code;
231
+ if (opened.code !== 0) return opened.code;
203
232
 
204
233
  // The CLI reduces a prompt to an id deterministically; it cannot write a
205
234
  // good short name, and ADR 0005 says it must not try. So it says how to get
@@ -237,14 +266,17 @@ export async function run(positional, flags) {
237
266
  if (delivery) {
238
267
  updated = updated.replace(/^(-[ \t]+\*\*Lane:\*\*[^\r\n]*)/m, `$1\n- **Delivery:** ${delivery}`);
239
268
  }
240
- if (updated !== txt) write(proposalPath, updated, { force: true });
269
+ if (updated !== txt) write(proposalPath, withEol(updated, eolOf(txt)), { force: true });
241
270
  }
242
271
 
243
272
  // Capability hint: term overlap for a prompt, changed-file overlap for a diff
244
273
  // (review F10 — rank by what the working tree touched, not just prompt words).
245
274
  const allChanged = fromDiff ? files : changedFiles(projectRoot).files;
246
275
  const diffMatches = pinned ? [] : rankCapabilitiesByDiff(projectRoot, allChanged);
247
- const promptMatches = pinned || fromDiff ? [] : rankCapabilities(projectRoot, effPrompt);
276
+ // A delivery's change is ranked by its story too (second audit, change
277
+ // 0233): the title of a tracker item rarely names the capability — "lock
278
+ // the account after 3 wrong passwords" — while its story says "login".
279
+ const promptMatches = pinned || fromDiff ? [] : rankCapabilities(projectRoot, deliveryStory ? `${effPrompt}\n${deliveryStory}` : effPrompt);
248
280
  const matches = pinned ? [] : (fromDiff ? diffMatches : promptMatches);
249
281
  const capability = pinned ?? matches[0]?.id ?? null;
250
282
  const clarity = assessBrief(effPrompt, { kind: "prompt" });
@@ -323,7 +355,7 @@ export async function run(positional, flags) {
323
355
  // see beats a missing file nothing reports.
324
356
  //
325
357
  // Returns what was written, or null when the file already exists.
326
- function scaffoldDelta(projectRoot, { id, capability, guess = null, runnerUp = null }) {
358
+ function scaffoldDelta(projectRoot, { id, capability, guess = /** @type {any} */ (null), runnerUp = /** @type {any} */ (null) }) {
327
359
  const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", capability, "delta.md");
328
360
  if (exists(deltaPath)) return null;
329
361
  const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
@@ -399,7 +431,7 @@ function resolveDeliveryFor(projectRoot, ref) {
399
431
  code: EXIT.PRECONDITION,
400
432
  };
401
433
  }
402
- return { id };
434
+ return { id, story: sectionText(d.text, "Story") ?? "" };
403
435
  }
404
436
 
405
437
  function writeLane(projectRoot, id, verdict, opts) {
@@ -408,8 +440,8 @@ function writeLane(projectRoot, id, verdict, opts) {
408
440
  const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
409
441
  if (!isFile(proposalPath)) return;
410
442
  const text = read(proposalPath);
411
- // CRLF-safe: the scaffolded proposals are CRLF, and a `.*$` pattern
412
- // silently matches nothing against them — the `patch-doctrina-files-as-crlf`
443
+ // CRLF-safe: a scaffolded proposal may be CRLF, and a `.*$` pattern
444
+ // silently matches nothing against one — the `check-the-line-ending-before-patching`
413
445
  // skill exists for exactly this mistake.
414
446
  const updated = text.replace(/^(-[ \t]+\*\*Lane:\*\*)[^\r\n]*/m, `$1 ${line}`);
415
447
  if (updated !== text) write(proposalPath, updated, { force: true });
@@ -417,29 +449,6 @@ function writeLane(projectRoot, id, verdict, opts) {
417
449
 
418
450
  // Next sequential NNNN across open changes and the archive, so work-driven
419
451
  // ids stay ordered no matter how earlier changes were named.
420
- function nextChangeNumber(projectRoot) {
421
- const changesDir = path.join(projectRoot, ".doctrina", "changes");
422
- let max = 0;
423
- const harvest = (name) => {
424
- const m = name.match(/^(\d{1,4})-/) ?? name.match(/^(\d{1,4})$/);
425
- if (m) max = Math.max(max, Number(m[1]));
426
- };
427
- if (isDir(changesDir)) {
428
- for (const entry of readdirSync(changesDir)) {
429
- if (entry === "archive" || entry.startsWith(".")) continue;
430
- if (isDir(path.join(changesDir, entry))) harvest(entry);
431
- }
432
- }
433
- const archiveDir = path.join(changesDir, "archive");
434
- if (isDir(archiveDir)) {
435
- for (const entry of readdirSync(archiveDir)) {
436
- if (!isDir(path.join(archiveDir, entry))) continue;
437
- harvest(entry.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
438
- }
439
- }
440
- return String(max + 1).padStart(4, "0");
441
- }
442
-
443
452
  // Open (non-archived) changes from the index — the set `work --resume` and
444
453
  // the thin-prompt guard offer to continue.
445
454
  function openChanges(projectRoot) {
@@ -481,7 +490,7 @@ function printResumeSuggestion(open) {
481
490
 
482
491
  // Reprint the work playbook for an already-open change, ranking capabilities
483
492
  // from its recorded title (the original prompt). Creates nothing.
484
- function resumeChange(projectRoot, resumeId) {
493
+ function resumeChange(projectRoot, resumeId, delivery = null) {
485
494
  const open = openChanges(projectRoot);
486
495
  if (!resumeId || resumeId === true) {
487
496
  console.error(c.red("error:") + " --resume needs a change id");
@@ -496,6 +505,25 @@ function resumeChange(projectRoot, resumeId) {
496
505
  if (open.length) console.error(c.gray("open: ") + open.map((ch) => ch.id).join(", "));
497
506
  return 1;
498
507
  }
508
+ // --resume <id> --delivery <id> (change 0226): a change opened before its
509
+ // delivery existed — or without the flag — joins it, so the delivery's
510
+ // status and check see it. A change already in another delivery is not
511
+ // moved silently.
512
+ if (delivery) {
513
+ const proposalPath = path.join(projectRoot, ".doctrina", "changes", resumeId, "proposal.md");
514
+ const text = isFile(proposalPath) ? read(proposalPath) : "";
515
+ const current = (listHeader(text, "Delivery") ?? "").trim();
516
+ if (current && current !== delivery) {
517
+ console.error(c.red("error:") + ` ${resumeId} already belongs to delivery ${current}`);
518
+ return EXIT.PRECONDITION;
519
+ }
520
+ if (!current) {
521
+ const stamped = text.replace(/^(-[ \t]+\*\*Lane:\*\*[^\r\n]*)/m, `$1\n- **Delivery:** ${delivery}`);
522
+ const updated = stamped !== text ? stamped : text.replace(/^(-[ \t]+\*\*Status:\*\*[^\r\n]*)/m, `$1\n- **Delivery:** ${delivery}`);
523
+ write(proposalPath, withEol(updated, eolOf(text)), { force: true });
524
+ console.log(c.green("joined") + ` ${resumeId} to delivery ${delivery}`);
525
+ }
526
+ }
499
527
  const prompt = entry?.title ?? resumeId;
500
528
  const matches = rankCapabilities(projectRoot, prompt);
501
529
  const capability = matches[0]?.id ?? null;
@@ -560,7 +588,7 @@ function printChorePlaybook(projectRoot, { id, prompt }) {
560
588
  printPlaybookTemplate(projectRoot, "chore", { CHANGE_ID: id, PROMPT: prompt });
561
589
  }
562
590
 
563
- function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity, scaffolded = null, fromDiff = false, diffMatches = [] }) {
591
+ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity, scaffolded = /** @type {any} */ (null), fromDiff = false, diffMatches = /** @type {any[]} */ ([]) }) {
564
592
  printPlaybookTemplate(projectRoot, "work", {
565
593
  TITLE: fromDiff ? "Backfill playbook" : "Work playbook",
566
594
  CHANGE_ID: id,
@@ -610,7 +638,9 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
610
638
  // The prompt as a `context --for` argument: one line, no double quotes, short
611
639
  // enough to read — the slicer only needs its content words.
612
640
  export function forQuery(prompt) {
613
- const flat = String(prompt ?? "").replace(/["`]/g, "").replace(/\s+/g, " ").trim();
641
+ // `$` and `\\` too (change 0235): between the double quotes the playbook
642
+ // prints, `$(…)` in a prompt still ran.
643
+ const flat = String(prompt ?? "").replace(/["`$\\]/g, "").replace(/\s+/g, " ").trim();
614
644
  return flat.length > 80 ? flat.slice(0, 80).replace(/\s+\S*$/, "") : flat;
615
645
  }
616
646
 
@@ -667,6 +697,8 @@ Options:
667
697
  (backlog entry; reprint later with --resume <id>)
668
698
  --id <id> Override the derived change id
669
699
  --resume <id> Reprint the playbook for an open change; create nothing
700
+ --delivery <id> Stamp the change with the workspace delivery it is part
701
+ of (ADR 0030); with --resume, join an open change to it
670
702
  --from-diff Backfill: scaffold from the working-tree changes (no
671
703
  prompt needed) and print a code-first playbook (F8)
672
704
  --consolidate <cap> Open a change that consolidates <cap>'s spec — merge
@@ -685,6 +717,22 @@ A request that mostly repeats a change already open, or archived in the
685
717
  last 14 days, is HELD the same way (exit 3), naming it: the same work is
686
718
  resumed, or reported as done where it is archived; --force opens it anyway.
687
719
 
720
+ TRACKS. How much ceremony a request earns is decided by what it changes,
721
+ not by the size of the diff:
722
+
723
+ direct nothing a spec says — a typo, a comment, formatting, a fix that
724
+ makes the code do what the spec already says
725
+ edit · doctrina verify · commit (no change is opened)
726
+ chore the build, the tooling or the docs, worth a record — no spec
727
+ changes: doctrina work --chore "<request>"
728
+ change behaviour — a sentence in a spec becomes false, or one is
729
+ missing: doctrina work "<request>"
730
+
731
+ A prompt whose words can only mean a direct edit (a typo, whitespace, a
732
+ stale comment, dead code) opens nothing: work prints the tracks and exits
733
+ 0. --force opens the change anyway, --chore records it, and naming
734
+ --capability, --delivery or --id skips the reading.
735
+
688
736
  A prompt that reads as a RUNTIME problem — a workflow, an env value, a run
689
737
  that executed nothing — is HELD before anything is scaffolded, and pointed
690
738
  at \`doctrina triage\` instead (exit 3: a precondition, not a bad prompt).