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
@@ -19,6 +19,8 @@ export const COMMAND_NAMES = [
19
19
  "prime", "context", "show", "search", "status", "next", "why", "handoff",
20
20
  // gates
21
21
  "analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor",
22
+ // orchestrate
23
+ "workspace", "delivery",
22
24
  // maintenance
23
25
  "templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade", "ci",
24
26
  ];
@@ -48,6 +50,7 @@ export const OPERATIONS = [
48
50
  ["change tick", "List/tick the unchecked boxes (tasks + verification; --all)"],
49
51
  ["change abandon", "Discard an open change cleanly (recorded in the ledger)"],
50
52
  ["change delta", "Add a requirement/criterion/bump to a change's delta (ops written for you)"],
53
+ ["change card", "One task as an executor receives it: the task, its why, the spec cut to it"],
51
54
  ["contract new", "Own the integration surface (ports, env, interfaces)"],
52
55
  ["contract list", "List contracts with status and last-updated date"],
53
56
  ["contract check", "Verify port collisions, env drift, referenced specs"],
@@ -82,6 +85,20 @@ export const OPERATIONS = [
82
85
  ["status", "One-glance project health dashboard"],
83
86
  ["why", "Explain provenance: a capability's chain, or an anchor's (SC1)"],
84
87
  ["handoff", "Markdown handoff note: open work, task state, resume command"],
88
+ ["workspace init", "Create a multi-repository workspace (a private git repo of context)"],
89
+ ["workspace add", "Register a repository: in-repo, or shadowed when it cannot carry .doctrina/"],
90
+ ["workspace attach", "Re-create a shadow project's links in its repository"],
91
+ ["workspace detach", "Remove a shadow project's links; the shadow stays"],
92
+ ["workspace list", "Projects with mode, state and open changes"],
93
+ ["workspace tool", "Declare a tool (MCP server) the work uses: role, read or write"],
94
+ ["workspace guard", "Pre-call hook: allow, deny or ask an MCP call from the declared access"],
95
+ ["delivery new", "Open a delivery across repositories; print the orchestration playbook"],
96
+ ["delivery route", "Choose the projects a delivery touches, dependencies first"],
97
+ ["delivery list", "Deliveries with status, ref and closed/total changes"],
98
+ ["delivery status", "Per project: the changes carrying a delivery and their tasks"],
99
+ ["delivery tick", "List or tick a delivery's Release claims"],
100
+ ["delivery check", "What still keeps a delivery from closing (exit 1 while anything does)"],
101
+ ["delivery close", "Close a delivery once every project's change is closed"],
85
102
  ["templates list", "List the templates shipped by the installed CLI"],
86
103
  ["templates check", "Compare the project against the recommended template shape"],
87
104
  ["templates update", "Additive fixer for check findings (preview; --write applies)"],
@@ -127,8 +144,8 @@ export const COMMAND_META = {
127
144
  why: { moment: "Orient", when: "you need to justify or trace a capability's existence", purpose: "provenance: intent, proof, ADRs, and history" },
128
145
  handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
129
146
 
130
- triage: { moment: "Change", when: "a request arrives — BEFORE scaffolding, especially if it smells like an incident", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
131
- work: { moment: "Change", when: "a request arrives that changes behaviour (triage says PRODUCT)", purpose: "scaffold a change and print the playbook to execute" },
147
+ triage: { moment: "Change", when: "diagnosing an incident or a runtime symptom — `work` classifies every other request itself", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
148
+ work: { moment: "Change", when: "a request changes behaviour — BEFORE editing code; it classifies the lane itself", purpose: "scaffold a change and print the playbook to execute" },
132
149
  spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
133
150
  change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "drive a change by hand — check --verbose previews every delta" },
134
151
  decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
@@ -146,6 +163,9 @@ export const COMMAND_META = {
146
163
  close: { moment: "Gate", when: "a change is implemented and ready to finish", purpose: "the whole closing sequence in one attested pass" },
147
164
  doctor: { moment: "Gate", when: "something looks wrong and you do not know which gate to ask", purpose: "aggregate diagnostic with per-finding remedies" },
148
165
 
166
+ delivery: { moment: "Orchestrate", when: "a story or screen must land in more than one repository", purpose: "plan, route, track and close one item of work across repositories" },
167
+ workspace: { moment: "Orchestrate", when: "the work spans several repositories, or a repository cannot carry .doctrina/", purpose: "register repositories, in-repo or shadowed, in one versioned workspace" },
168
+
149
169
  templates: { moment: "Maintain", when: "customising a scaffold, to see which template file wins", purpose: "where each shipped template resolves from" },
150
170
  upgrade: { moment: "Maintain", when: "you just updated the doctrina-cli package", purpose: "bring this project up to the installed CLI" },
151
171
  index: { moment: "Maintain", when: "the index drifted from the tree", purpose: "regenerate index.json from the artifacts on disk" },
@@ -158,7 +178,7 @@ export const COMMAND_META = {
158
178
  };
159
179
 
160
180
  // The order moments appear in the generated block.
161
- export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Maintain"];
181
+ export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Orchestrate", "Maintain"];
162
182
 
163
183
  // The Commands block of `doctrina --help`, generated from OPERATIONS so the
164
184
  // printed surface can never omit an operation the catalog knows about.
@@ -192,7 +212,10 @@ export function surfaceHelp() {
192
212
  const ops = live.filter(([op]) => op.split(" ")[0] === cmd);
193
213
  const subs = ops.map(([op]) => op.split(" ")[1]).filter(Boolean);
194
214
  const text = ops.length === 1 ? ops[0][1] : capitalise(COMMAND_META[cmd].purpose);
195
- out.push(line(subs.length > 0 ? `${cmd} ${subs.join("|")}` : cmd, text));
215
+ const op = subs.length > 0 ? `${cmd} ${subs.join("|")}` : cmd;
216
+ // A compact moment's long invocation stands alone rather than wrapping
217
+ // onto a second line (change 0220): `doctrina <cmd> --help` has the rest.
218
+ out.push(COMPACT_MOMENTS.has(moment) && op.length >= 20 ? ` ${op}` : line(op, text));
196
219
  }
197
220
  }
198
221
  const old = OPERATIONS.filter(([op]) => DEPRECATED[op]);
@@ -317,7 +340,10 @@ export const SURFACE_LINE_BUDGET = 40;
317
340
  // each its own line pushed the block past its budget. The budget is the
318
341
  // forcing function — a surface that cannot describe itself in 40 lines is
319
342
  // too large, and the answer is to cut commands (M8), not to raise it.
320
- const COMPACT_MOMENTS = new Set(["Maintain"]);
343
+ // "Orchestrate" is compact too (change 0220): an orchestrating session reads
344
+ // the workspace's own AGENTS.md, which carries the delivery playbook in full;
345
+ // inside one repository the commands only need to be discoverable.
346
+ const COMPACT_MOMENTS = new Set(["Orchestrate", "Maintain"]);
321
347
 
322
348
  // The full managed section, heading included, between the markers.
323
349
  //
@@ -517,6 +543,27 @@ export const AGENT_CHANGELOG = {
517
543
  // Each entry lists only ITS OWN delta. The series carries the rest
518
544
  // forward (see agentChangelogEntries), so a patch never has to restate
519
545
  // the minor that introduced the commands, and never silently erases it.
546
+ "0.21.0": [
547
+ "At a workspace root, `next`, `handoff`, `status`, `validate`, `context`, `doctor` and `metrics` answer for the workspace; never `doctrina init` there or in a shadowed repository's clone — the commands name `doctrina workspace attach` instead.",
548
+ "A task card carries the delta, the delivery's story and every binding decision, global ones included: as an executor, stop and report when the story or the task contradicts one; a card on placeholder tasks means the planner writes them first (`doctrina work --resume <id>`).",
549
+ "Run the command lines the CLI prints as printed: paths and titles are quoted for the shell, and tracker text stays data.",
550
+ "Cursor: the core loop is skills in `.cursor/skills/doctrina-*/`; run `doctrina upgrade --write` to move an older install. With a tool declared the guard fails closed — a refused call means fix the install or ask the human.",
551
+ "Absorb: after writing specs by hand run `doctrina validate --fix`; a stored code inventory resumes with a bare `doctrina intake`, never `--from-code` again.",
552
+ ],
553
+ "0.20.0": [
554
+ "Work across repositories is a delivery: from the workspace, `doctrina delivery new \"<title>\" --ref <id>`, then follow its playbook — route, one change per repository with `doctrina work \"<title>\" --delivery <id>`, close each, then `doctrina delivery close <id>`.",
555
+ "Write tasks as self-contained cards (indented `Files:`, `Requirement:`, `Verify:` lines) and hand an executor `doctrina change card <id>`; one writer per repository, and only the planner closes (ADR 0030).",
556
+ "An existing codebase with no specs: `doctrina intake --from-code`, then the absorb playbook — describe what the code does today and send what it cannot tell to the human as open questions.",
557
+ "A repository that may not carry `.doctrina/` is a workspace shadow: its `.doctrina/` is a link, hidden from its git — never commit it there; commit the workspace.",
558
+ "MCP tools declared in the workspace are read-only unless declared `write`: a refused or held call means ask the human, never route around it.",
559
+ ],
560
+ "0.19.0": [
561
+ "A request that changes behaviour is a change opened BEFORE code, whether or not it names Doctrina; say its id in your first sentence to the human, and repeat the `Tell the human:` line `work` and `close` print.",
562
+ "`doctrina context <cap> --for \"<task>\" --concat` is now lean (no AGENTS.md, ADR digests, the spec sliced to the task) — read it before editing; `prime` is state, not the spec. `--full` gives the whole text.",
563
+ "Project skills are exported to `.agents/skills/` and `.claude/skills/`, where your agent lists skills — use them; edit `.doctrina/skills/`, never the exports.",
564
+ "`work` holds (exit 3) a request that repeats an open change or one archived in the last 14 days: resume it, or tell the human it is done and where; `--force` only for genuinely new work.",
565
+ "Hooks: `doctrina adapter add claude|cursor` merges session/prompt hooks (`prime --hook`, `prime --pulse`) into the agent's settings.",
566
+ ],
520
567
  "0.18.0": [
521
568
  "`doctrina change delta <id> <capability> --requirement \"<section>: <text>\" --criterion \"<text>\" --bump minor` writes the ops lines for you and dry-runs them against the spec — stop hand-writing ops blocks.",
522
569
  "`close` runs `verify --reuse`: running `doctrina verify` yourself right before `doctrina close` is no longer paid twice, and a batch close runs the suite once. Use `verify --affected` while working when checks declare `\"paths\"`.",
@@ -0,0 +1,149 @@
1
+ // @ts-check
2
+ import { fold } from "./lexicon.js";
3
+ import { commentRanges, getSection } from "./doc-model.js";
4
+
5
+ // The lean read pack (change 0216).
6
+ //
7
+ // A field review: the agent ran `prime` and `work` and skipped `context`,
8
+ // although AGENTS.md calls it the read for ANY task. Measured on this
9
+ // repository, the reason is the price: `context gates --concat` was ~40k
10
+ // tokens — AGENTS.md again (the agent already carries it), the whole spec,
11
+ // and every accepted ADR in full — and `--for "<task>"` cut nothing, because
12
+ // retrieval only degraded the pack once the 50k budget overflowed. These
13
+ // cuts are the default now; `--full` restores the whole text.
14
+
15
+ // How much of an ADR's Decision a digest keeps.
16
+ const ADR_DIGEST_CHARS = 700;
17
+ // How many requirements and criteria a sliced spec keeps at most, each.
18
+ const SLICE_MAX_ITEMS = 25;
19
+
20
+ /**
21
+ * A change file as a reader needs it: without its HTML comments (change
22
+ * 0228). The proposal, tasks and delta templates carry their authoring
23
+ * instructions in comments — forty lines of "for ADDED… for MODIFIED…" in
24
+ * every delta — and the change in focus enters the pack whole, so a change
25
+ * with eight deltas spent ~2.6k tokens repeating the template to an agent
26
+ * that was reading, not writing. Null when the text has no comment.
27
+ *
28
+ * @param {string} text
29
+ * @returns {string | null}
30
+ */
31
+ export function withoutComments(text) {
32
+ const lf = String(text).replace(/\r\n/g, "\n");
33
+ const ranges = commentRanges(lf);
34
+ if (ranges.length === 0) return null;
35
+ let out = "";
36
+ let at = 0;
37
+ for (const [a, b] of ranges) {
38
+ out += lf.slice(at, a);
39
+ at = b;
40
+ }
41
+ out += lf.slice(at);
42
+ return out.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
43
+ }
44
+
45
+ /**
46
+ * An ADR as a reader deciding whether it binds the task needs it: the
47
+ * title and the opening of its Decision section. `doctrina show NNNN`
48
+ * reads the rest.
49
+ *
50
+ * @param {string} text
51
+ * @param {string} title
52
+ * @param {string} id four-digit ADR number
53
+ */
54
+ export function adrDigest(text, title, id) {
55
+ const decision = (getSection(text.replace(/\r\n/g, "\n"), "Decision") ?? "")
56
+ .split("\n").filter((l) => !l.trim().startsWith("<!--")).join("\n").trim();
57
+ const clipped = decision.length > ADR_DIGEST_CHARS
58
+ ? `${decision.slice(0, ADR_DIGEST_CHARS).replace(/\s+\S*$/, "")} …`
59
+ : decision;
60
+ return [`# ${title}`, "", clipped || "(no Decision section)", "",
61
+ `(digest — full text: \`doctrina show ${id}\`, or \`context --full\`)`].join("\n");
62
+ }
63
+
64
+ /**
65
+ * A spec cut to what the task is about: its headers, its Purpose, and the
66
+ * requirements and criteria that match the query best — the rest named by
67
+ * count, never silently dropped. Deterministic: an item's score is how many
68
+ * distinct query terms it contains; the items scoring at least half the
69
+ * best score are kept, best first in document order, up to a cap per list.
70
+ *
71
+ * @param {string} text
72
+ * @param {string[]} queryTerms already folded, stop words removed
73
+ * @param {string} rel the spec's path, for the footer
74
+ * @returns {{ text: string, kept: number, total: number } | null} null when nothing matched
75
+ */
76
+ export function sliceSpec(text, queryTerms, rel) {
77
+ if (queryTerms.length === 0) return null;
78
+ // Read without the template's comments (second audit, change 0233). The
79
+ // EARS comment lists the five forms as bullets — "- Ubiquitous: The system
80
+ // shall <action>." — and they were counted as requirements: every pack of
81
+ // a scaffolded spec reported "… 5 not matching the task" for bullets no
82
+ // one wrote, and carried the forty-line header comment besides.
83
+ const lines = (withoutComments(text) ?? text.replace(/\r\n/g, "\n")).split("\n");
84
+ // Items: a bullet or numbered entry with its continuation lines.
85
+ /** @type {Array<{ start: number, end: number, kind: "req"|"crit", score: number }>} */
86
+ const items = [];
87
+ let section = "";
88
+ for (let i = 0; i < lines.length; i++) {
89
+ const l = lines[i];
90
+ if (/^##\s/.test(l)) { section = l; continue; }
91
+ const req = /^##\s+Requirements/i.test(section) && /^\s*-\s+\S/.test(l);
92
+ const crit = /^##\s+Acceptance criteria/i.test(section) && /^\s*\d+\.\s+\S/.test(l);
93
+ if (!req && !crit) continue;
94
+ let end = i;
95
+ while (end + 1 < lines.length && /^\s+\S/.test(lines[end + 1]) && !/^\s*(-|\d+\.)\s/.test(lines[end + 1])) end += 1;
96
+ const body = fold(lines.slice(i, end + 1).join(" "));
97
+ const score = queryTerms.filter((t) => body.includes(t)).length;
98
+ items.push({ start: i, end, kind: req ? "req" : "crit", score });
99
+ i = end;
100
+ }
101
+ const best = Math.max(0, ...items.map((it) => it.score));
102
+ if (best === 0) return null;
103
+ const floor = Math.max(1, Math.ceil(best / 2));
104
+ const keep = new Set();
105
+ for (const kind of ["req", "crit"]) {
106
+ items.filter((it) => it.kind === kind && it.score >= floor)
107
+ .sort((a, b) => b.score - a.score || a.start - b.start)
108
+ .slice(0, SLICE_MAX_ITEMS)
109
+ .forEach((it) => keep.add(it.start));
110
+ }
111
+
112
+ const out = [];
113
+ section = "";
114
+ let omitted = 0;
115
+ const flushOmitted = () => {
116
+ if (omitted > 0) out.push(` … ${omitted} not matching the task`);
117
+ omitted = 0;
118
+ };
119
+ for (let i = 0; i < lines.length; i++) {
120
+ const l = lines[i];
121
+ if (/^##\s/.test(l)) {
122
+ flushOmitted();
123
+ section = l;
124
+ out.push(l);
125
+ continue;
126
+ }
127
+ const item = items.find((it) => it.start === i);
128
+ if (item) {
129
+ if (keep.has(i)) { flushOmitted(); out.push(...lines.slice(i, item.end + 1)); } else omitted += 1;
130
+ i = item.end;
131
+ continue;
132
+ }
133
+ // Headers, Purpose and subsection headings are kept; prose in the other
134
+ // sections (Out of scope, Maturity, …) is the first thing a task can do
135
+ // without.
136
+ const inLists = /^##\s+(Requirements|Acceptance criteria)/i.test(section);
137
+ const prose = section !== "" && !/^##\s+Purpose/i.test(section) && !inLists;
138
+ if (/^###\s/.test(l)) { flushOmitted(); out.push(l); continue; }
139
+ if (section === "" || /^##\s+Purpose/i.test(section)) { out.push(l); continue; }
140
+ if (inLists && l.trim() !== "") continue;
141
+ if (!prose) out.push(l);
142
+ }
143
+ flushOmitted();
144
+ const kept = keep.size;
145
+ const shownTerms = queryTerms.length > 12 ? `${queryTerms.slice(0, 12).join(" ")} …` : queryTerms.join(" ");
146
+ out.push("", `(sliced for "${shownTerms}" — ${kept} of ${items.length} requirements and criteria kept; ` +
147
+ `full text: ${rel}, or \`context --full\`)`);
148
+ return { text: out.join("\n").replace(/\n{3,}/g, "\n\n"), kept, total: items.length };
149
+ }
@@ -0,0 +1,286 @@
1
+ // @ts-check
2
+ import { readdirSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { eolOf, isDir, isFile, read, withEol, write } from "./fs-ops.js";
5
+ import { listHeader } from "./scan.js";
6
+ import { checklistProgress, maskComments, parseChecklist, setHeader } from "./doc-model.js";
7
+ import { parseLedger } from "./ledger.js";
8
+ import { terms } from "./lexicon.js";
9
+ import { slugify, padNumber } from "./dates.js";
10
+ import { artifactRootOf, projectState } from "./workspace.js";
11
+
12
+ // A DELIVERY is one item of work that crosses repositories (ADR 0030): a
13
+ // story, a screen, a fix. It lives in the workspace's `deliveries/` and
14
+ // holds what no single repository owns — the story, the sources, the plan
15
+ // across projects, the release. Each project it touches gets ONE change,
16
+ // opened in that repository and carrying `- **Delivery:** <id>`, so the
17
+ // delivery's state is READ from the projects, never copied into it.
18
+
19
+ export const DELIVERY_FILE = "delivery.md";
20
+ export const DELIVERY_ID = /^\d{4}-[a-z0-9][a-z0-9-]*$/;
21
+
22
+ export function deliveriesDir(wsDir) {
23
+ return path.join(wsDir, "deliveries");
24
+ }
25
+
26
+ /** Every delivery on disk, oldest first. */
27
+ export function listDeliveries(wsDir) {
28
+ const dir = deliveriesDir(wsDir);
29
+ if (!isDir(dir)) return [];
30
+ return readdirSync(dir).filter((d) => DELIVERY_ID.test(d) && isFile(path.join(dir, d, DELIVERY_FILE))).sort()
31
+ .map((id) => readDelivery(wsDir, id));
32
+ }
33
+
34
+ export function deliveryPath(wsDir, id) {
35
+ return path.join(deliveriesDir(wsDir), id, DELIVERY_FILE);
36
+ }
37
+
38
+ /** A delivery by id — or by its number alone (`1`, `0001`). Null when absent. */
39
+ export function resolveDeliveryId(wsDir, ref) {
40
+ if (!ref) return null;
41
+ if (DELIVERY_ID.test(ref) && isFile(deliveryPath(wsDir, ref))) return ref;
42
+ if (/^\d+$/.test(ref)) {
43
+ const prefix = padNumber(Number(ref)) + "-";
44
+ return listDeliveries(wsDir).find((d) => d.id.startsWith(prefix))?.id ?? null;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ export function readDelivery(wsDir, id) {
50
+ // CRLF on a Windows checkout: a `$` would leave the \r in the title.
51
+ const text = read(deliveryPath(wsDir, id)).replace(/\r\n/g, "\n");
52
+ const title = text.match(/^#\s+Delivery\s+\S+\s+[—-]\s+(.+)$/m)?.[1]?.trim() ?? id;
53
+ const projects = (listHeader(text, "Projects") ?? "").split(",").map((s) => s.trim()).filter((s) => s && s !== "—");
54
+ return {
55
+ id, title, text,
56
+ status: (listHeader(text, "Status") ?? "open").toLowerCase(),
57
+ date: listHeader(text, "Date") ?? null,
58
+ closed: listHeader(text, "Closed") ?? null,
59
+ ref: listHeader(text, "Ref") ?? "—",
60
+ projects,
61
+ };
62
+ }
63
+
64
+ export function nextDeliveryId(wsDir, title) {
65
+ const numbers = listDeliveries(wsDir).map((d) => Number(d.id.slice(0, 4)));
66
+ const next = (numbers.length ? Math.max(...numbers) : 0) + 1;
67
+ return `${padNumber(next)}-${slugify(title).slice(0, 40).replace(/-+$/, "") || "delivery"}`;
68
+ }
69
+
70
+ /** Rewrite one list header of a delivery (adding it when absent). */
71
+ export function setDeliveryHeader(wsDir, id, name, value) {
72
+ const p = deliveryPath(wsDir, id);
73
+ let text = read(p);
74
+ const next = setHeader(text, name, value);
75
+ if (next !== null) text = next;
76
+ else text = text.replace(/^(- \*\*Projects:\*\*[^\r\n]*)/m, `$1\n- **${name}:** ${value}`);
77
+ write(p, withEol(text, eolOf(read(p))), { force: true });
78
+ }
79
+
80
+ /**
81
+ * Projects ranked against a delivery's text — how many of its terms each
82
+ * project's name, role, description and capability names share. A hint for
83
+ * the orchestrator, never a decision (ADR 0005): `delivery route` is where
84
+ * the projects are chosen.
85
+ */
86
+ export function rankProjects(wsDir, ws, text) {
87
+ const want = new Set(terms(text));
88
+ const out = [];
89
+ for (const p of ws.projects) {
90
+ const caps = capabilityNames(wsDir, p);
91
+ const words = new Set(terms([p.name, p.role, p.about, ...caps].join(" ")));
92
+ const shared = [...want].filter((t) => words.has(t));
93
+ out.push({ project: p, score: shared.length, shared, capabilities: caps });
94
+ }
95
+ return out.sort((a, b) => b.score - a.score || a.project.name.localeCompare(b.project.name));
96
+ }
97
+
98
+ function capabilityNames(wsDir, project) {
99
+ const specs = path.join(artifactRootOf(wsDir, project), ".doctrina", "specs");
100
+ if (!isDir(specs)) return [];
101
+ return readdirSync(specs).filter((d) => isFile(path.join(specs, d, "spec.md")));
102
+ }
103
+
104
+ /** The projects in dependency order: a project after every project it depends on. */
105
+ export function dependencyOrder(ws, names) {
106
+ const byName = new Map(ws.projects.map((p) => [p.name, p]));
107
+ const out = [];
108
+ const seen = new Set();
109
+ const visit = (name, trail) => {
110
+ if (seen.has(name) || trail.has(name)) return;
111
+ trail.add(name);
112
+ for (const dep of byName.get(name)?.dependsOn ?? []) if (names.includes(dep)) visit(dep, trail);
113
+ seen.add(name);
114
+ out.push(name);
115
+ };
116
+ for (const n of names) visit(n, new Set());
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * The changes each routed project carries for this delivery — open ones in
122
+ * `changes/`, closed ones in `changes/archive/` — read from the projects.
123
+ */
124
+ export function deliveryChanges(wsDir, ws, delivery) {
125
+ const out = [];
126
+ for (const name of delivery.projects) {
127
+ const project = ws.projects.find((p) => p.name === name);
128
+ if (!project) {
129
+ out.push({ project: name, missing: "not registered in the workspace", changes: [] });
130
+ continue;
131
+ }
132
+ const changesDir = path.join(artifactRootOf(wsDir, project), ".doctrina", "changes");
133
+ const changes = [];
134
+ for (const { dir, archived } of [{ dir: changesDir, archived: false }, { dir: path.join(changesDir, "archive"), archived: true }]) {
135
+ if (!isDir(dir)) continue;
136
+ for (const entry of readdirSync(dir).sort()) {
137
+ const proposal = path.join(dir, entry, "proposal.md");
138
+ if (entry === "archive" || !isFile(proposal)) continue;
139
+ const text = read(proposal);
140
+ if ((listHeader(text, "Delivery") ?? "").trim() !== delivery.id) continue;
141
+ const tasks = path.join(dir, entry, "tasks.md");
142
+ const progress = isFile(tasks) ? checklistProgress(read(tasks)) : { total: 0, done: 0, placeholders: 0 };
143
+ changes.push({
144
+ id: archived ? entry.replace(/^\d{4}-\d{2}-\d{2}-/, "") : entry,
145
+ archived,
146
+ status: archived ? "archived" : (listHeader(text, "Status") ?? "proposed").toLowerCase(),
147
+ tasksDone: progress.done, tasksTotal: progress.total,
148
+ // Scaffold boxes no one wrote (third audit, change 0234): counted
149
+ // as tasks, they read "3 tasks left — hand the next card" for a
150
+ // change whose card command answers that none is written.
151
+ placeholders: progress.placeholders ?? 0,
152
+ });
153
+ }
154
+ }
155
+ // Abandoned for this delivery: the ledger keeps the delivery's id (change 0234).
156
+ const ledger = path.join(changesDir, "archive", "LEDGER.md");
157
+ const abandoned = isFile(ledger)
158
+ ? parseLedger(read(ledger)).entries.filter((e) => e.kind === "abandoned" && new RegExp(`\\(delivery ${delivery.id}\\)\\s*$`).test(e.title))
159
+ .map((e) => ({ id: e.id, reason: e.title.replace(/^abandoned\s*(—\s*)?/i, "").replace(/\s*\(delivery [^)]*\)\s*$/, "") }))
160
+ : [];
161
+ out.push({ project: name, path: project.path, absent: !isDir(project.path), changes, abandoned });
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /** A section's own text: comments and blank lines removed. Empty string when absent. */
167
+ export function sectionText(text, heading) {
168
+ const lines = String(text).replace(/\r\n/g, "\n").replace(/<!--[\s\S]*?-->/g, "").split("\n");
169
+ const at = lines.findIndex((l) => l.trim() === `## ${heading}`);
170
+ if (at < 0) return "";
171
+ const out = [];
172
+ for (const l of lines.slice(at + 1)) {
173
+ if (/^## /.test(l)) break;
174
+ if (l.trim()) out.push(l.trim());
175
+ }
176
+ return out.join("\n");
177
+ }
178
+
179
+ /** Days between two YYYY-MM-DD dates, or null. */
180
+ export function daysBetween(from, to) {
181
+ const a = Date.parse(String(from ?? "") + "T00:00:00Z");
182
+ const b = Date.parse(String(to ?? "") + "T00:00:00Z");
183
+ return Number.isNaN(a) || Number.isNaN(b) ? null : Math.round((b - a) / 86400000);
184
+ }
185
+
186
+ /**
187
+ * The record a delivery keeps of its own work (change 0224): the story it
188
+ * came from, every Release claim ticked, and at least one line of what was
189
+ * watched after the release. The changes prove the code; these prove the
190
+ * delivery reached its users and was looked at once it had.
191
+ */
192
+ export function recordBlockers(delivery) {
193
+ const out = [];
194
+ if (sectionText(delivery.text, "Story") === "") out.push("## Story is empty — copy the tracker item's story and acceptance criteria, verbatim");
195
+ // The same boxes `delivery tick` lists and ticks — one reader, so a box
196
+ // the check holds you to is always one the CLI can tick.
197
+ for (const box of releaseBoxes(delivery.text).filter((b) => !b.checked)) out.push(`## Release: unticked — ${box.text}`);
198
+ if (sectionText(delivery.text, "Observability") === "") out.push("## Observability is empty — record what was watched after the release (monitor, dashboard or query, the window, what it showed)");
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * Everything that keeps a delivery from closing. Each routed project must
204
+ * carry at least one change for it, and every such change must be archived;
205
+ * the delivery's own record must be written (recordBlockers).
206
+ */
207
+ export function deliveryBlockers(wsDir, ws, delivery) {
208
+ const blockers = [];
209
+ if (delivery.projects.length === 0) blockers.push("no project routed — `doctrina delivery route <id> <a,b>`");
210
+ for (const row of deliveryChanges(wsDir, ws, delivery)) {
211
+ if (row.missing) blockers.push(`${row.project}: ${row.missing}`);
212
+ else if (row.absent) blockers.push(`${row.project}: the repository is not at ${row.path} — \`doctrina workspace attach ${row.project} --path <dir>\``);
213
+ else if (row.changes.length === 0 && row.abandoned?.length) blockers.push(`${row.project}: its change ${row.abandoned.map((a) => a.id).join(", ")} was abandoned — re-route the delivery without ${row.project}, or open a new change there with \`doctrina work "<title>" --delivery ${delivery.id}\``);
214
+ else if (row.changes.length === 0) blockers.push(`${row.project}: no change carries this delivery — open one there with \`doctrina work "<title>" --delivery ${delivery.id}\``);
215
+ else for (const ch of row.changes.filter((x) => !x.archived)) {
216
+ blockers.push(`${row.project}: change ${ch.id} is ${ch.status} (${ch.tasksDone}/${ch.tasksTotal} tasks) — close it there with \`doctrina close ${ch.id}\``);
217
+ }
218
+ }
219
+ blockers.push(...recordBlockers(delivery));
220
+ return blockers;
221
+ }
222
+
223
+ /**
224
+ * The `## Release` boxes of a delivery, in order, with their line index in
225
+ * the LF-normalised text (change 0226). The other sections are prose the
226
+ * planner writes; these are claims, and a claim is ticked through the CLI.
227
+ */
228
+ export function releaseBoxes(text) {
229
+ // One owner for the box grammar (ADR 0021): the document model reads the
230
+ // boxes; a box inside a comment is the template's example, not a claim.
231
+ const lf = String(text).replace(/\r\n/g, "\n");
232
+ return parseChecklist(maskComments(lf), { section: "Release" })
233
+ .filter((b) => b.section === "Release")
234
+ .map((b, i) => ({ ordinal: i + 1, line: b.line, checked: b.checked, text: b.text }));
235
+ }
236
+
237
+ /** The text with the given Release boxes ticked (all when `ordinals` is "all"). */
238
+ export function tickRelease(text, ordinals) {
239
+ const lines = String(text).replace(/\r\n/g, "\n").split("\n");
240
+ for (const box of releaseBoxes(text)) {
241
+ if (ordinals === "all" || ordinals.includes(box.ordinal)) {
242
+ lines[box.line] = lines[box.line].replace(/\[\s\]/, "[x]");
243
+ }
244
+ }
245
+ return lines.join("\n");
246
+ }
247
+
248
+ /**
249
+ * Projects a delivery cannot be routed to yet (change 0226): one whose
250
+ * repository is missing, or one still `pending` — no spec to write a delta
251
+ * against, so the change opened there would have nothing to change.
252
+ * @returns {{ name: string, state: string, reason: string, path: string }[]}
253
+ */
254
+ export function unroutable(wsDir, ws, names) {
255
+ const out = [];
256
+ for (const name of names) {
257
+ const project = ws.projects.find((p) => p.name === name);
258
+ if (!project) continue;
259
+ const st = projectState(wsDir, project);
260
+ if (st.state !== "ready") out.push({ name, state: st.state, reason: st.reason, path: project.path });
261
+ }
262
+ return out;
263
+ }
264
+
265
+ /**
266
+ * Open changes in a routed repository that do NOT belong to this delivery
267
+ * (change 0226). Each is a second writer the planner must sequence: one
268
+ * writer per repository at a time holds across deliveries too.
269
+ * @returns {{ project: string, id: string, delivery: string|null }[]}
270
+ */
271
+ export function otherWriters(wsDir, ws, delivery) {
272
+ const out = [];
273
+ for (const name of delivery.projects) {
274
+ const project = ws.projects.find((p) => p.name === name);
275
+ if (!project) continue;
276
+ const changesDir = path.join(artifactRootOf(wsDir, project), ".doctrina", "changes");
277
+ if (!isDir(changesDir)) continue;
278
+ for (const entry of readdirSync(changesDir).sort()) {
279
+ const proposal = path.join(changesDir, entry, "proposal.md");
280
+ if (entry === "archive" || !isFile(proposal)) continue;
281
+ const of = (listHeader(read(proposal), "Delivery") ?? "").trim() || null;
282
+ if (of !== delivery.id) out.push({ project: name, id: entry, delivery: of });
283
+ }
284
+ }
285
+ return out;
286
+ }
@@ -257,10 +257,16 @@ export function documentedSurfaceSignals(changeDir, projectRoot = null) {
257
257
  signals.push(`endpoints: ${[...routes].sort().slice(0, 6).join(", ")}`);
258
258
  }
259
259
 
260
+ // An UPPER_SNAKE name is an environment variable when something says so
261
+ // (change 0239): the shape alone also matches an error code
262
+ // (`CONTA_BLOQUEADA`) or a constant (`MAX_FALHAS`), and every change that
263
+ // merely cited one was asked to document an "environment" it never touched.
264
+ const exampleNames = projectRoot === null ? new Set() : envExampleNames(projectRoot);
260
265
  const envVars = new Set();
261
266
  for (const m of code.matchAll(/\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/g)) {
262
267
  // Not twice: a declared variable is already reported by name above.
263
- if (!touchedDeclared.includes(m[1])) envVars.add(m[1]);
268
+ if (touchedDeclared.includes(m[1])) continue;
269
+ if (looksLikeEnv(m[1], text) || exampleNames.has(m[1])) envVars.add(m[1]);
264
270
  }
265
271
  if (envVars.size > 0) {
266
272
  signals.push(`environment: ${[...envVars].sort().slice(0, 8).join(", ")}`);
@@ -408,3 +414,32 @@ export function checkDocsImpact(projectRoot, changeDir) {
408
414
  reason: "alters a documented surface but no docs/ or README change accompanies it",
409
415
  };
410
416
  }
417
+
418
+ // The suffixes configuration read from the environment carries.
419
+ const ENV_SUFFIX = /_(URL|URI|DSN|HOST|PORT|ENDPOINT|KEY|TOKEN|SECRET|PASSWORD|PASS|USER|USERNAME|PATH|DIR|FILE|HOME|ENV|MODE|LEVEL|TIMEOUT|REGION|BUCKET|ENABLED|DISABLED|DEBUG|FLAG|PROFILE)$/;
420
+
421
+ /**
422
+ * Whether the change text marks a name as an environment variable: read the
423
+ * way code reads one (`process.env.X`, `$X`, `${X}`, `getenv("X")`,
424
+ * `os.environ["X"]`, `X=`), named on a line that speaks of the environment,
425
+ * or carrying a configuration suffix (`_URL`, `_TOKEN`, `_PORT`…).
426
+ * @param {string} name
427
+ * @param {string} text the change's authored text
428
+ */
429
+ export function looksLikeEnv(name, text) {
430
+ if (ENV_SUFFIX.test(name)) return true;
431
+ const n = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
432
+ if (new RegExp(`(process\\.env\\.|process\\.env\\[["'\`]|\\benv\\.|\\$\\{?|getenv\\(["']|environ(?:\\.get\\(|\\[)["']|ENV\\[["'])${n}\\b|\\b${n}=`).test(text)) return true;
433
+ return text.split("\n").some((line) => line.includes(name) && /\b(env|environment|environment variable|variável de ambiente|ambiente|secret|segredo|\.env)\b/i.test(line));
434
+ }
435
+
436
+ /** The variable names a project's env example files declare. */
437
+ function envExampleNames(projectRoot) {
438
+ const out = new Set();
439
+ for (const f of [".env.example", ".env.sample", ".env.template", ".env.dist", "example.env"]) {
440
+ const file = path.join(projectRoot, f);
441
+ if (!isFile(file)) continue;
442
+ for (const m of read(file).matchAll(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/gm)) out.add(m[1]);
443
+ }
444
+ return out;
445
+ }
package/src/lib/fs-ops.js CHANGED
@@ -19,6 +19,29 @@ import path from "node:path";
19
19
  // A file that had one loses it when a command rewrites it. That is a
20
20
  // normalisation, and the right one — nothing in this tree is served by
21
21
  // keeping it.
22
+ /**
23
+ * The line ending a file already uses: CRLF when it carries one, LF otherwise.
24
+ * A command that rewrites a file a person owns keeps the file's ending — a
25
+ * Windows checkout with `core.autocrlf` stores CRLF, and turning it into LF
26
+ * on a tick makes every line of the file show as changed in the diff.
27
+ * @param {string} text
28
+ * @returns {"\r\n"|"\n"}
29
+ */
30
+ export function eolOf(text) {
31
+ return /\r\n/.test(String(text)) ? "\r\n" : "\n";
32
+ }
33
+
34
+ /**
35
+ * `text` with every line ending made `eol` — what a rewrite hands to `write`
36
+ * after editing in LF, so an inserted line never mixes endings.
37
+ * @param {string} text
38
+ * @param {"\r\n"|"\n"} eol
39
+ */
40
+ export function withEol(text, eol) {
41
+ const lf = String(text).replace(/\r\n/g, "\n");
42
+ return eol === "\n" ? lf : lf.replace(/\n/g, "\r\n");
43
+ }
44
+
22
45
  export function read(p) {
23
46
  const text = readFileSync(p, "utf8");
24
47
  return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
@@ -15,7 +15,7 @@ import { stateWord } from "./spec-ops.js";
15
15
  // Clarification gate (review Topic A). A description too thin to spec from is
16
16
  // the moment to ask the user, not to let the agent invent requirements. Prints
17
17
  // the specific gaps before the bootstrap playbook; advisory, never blocking —
18
- // the intake is still captured verbatim (the playbook step 6 resolves it).
18
+ // the intake is still captured verbatim (the playbook step 8 resolves it).
19
19
  /**
20
20
  * Does this argument read as a FILE PATH, or as the description itself?
21
21
  *