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
package/src/lib/prompt.js CHANGED
@@ -25,7 +25,7 @@ export async function ask(question, { defaultValue = "" } = {}) {
25
25
  // Ask for consent. Off a terminal there is nobody to ask, so the answer is
26
26
  // `whenNonInteractive` — which callers set to false for destructive
27
27
  // operations: silence is not consent.
28
- export async function confirm(question, { defaultYes = false, whenNonInteractive = null } = {}) {
28
+ export async function confirm(question, { defaultYes = false, whenNonInteractive = /** @type {boolean | null} */ (null) } = {}) {
29
29
  if (!isInteractive()) {
30
30
  return whenNonInteractive === null ? defaultYes : whenNonInteractive;
31
31
  }
@@ -0,0 +1,149 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, read } from "./fs-ops.js";
5
+ import { parseAcceptanceCriteria } from "./criteria.js";
6
+
7
+ // A REQUIREMENT THAT IS AN INVARIANT ASKS FOR A PROPERTY (change 0254).
8
+ //
9
+ // "The system shall never return a code already in use" is a claim about
10
+ // EVERY input, and the evidence a criterion cited for it was an example
11
+ // test: three inputs somebody thought of. Coverage called that proven. A
12
+ // property-based test generates the inputs — hundreds of them, the ones
13
+ // nobody thought of — which is the kind of proof a universal claim wants.
14
+ //
15
+ // Kiro derives such tests from EARS requirements with a model. The CLI has
16
+ // none and does not pretend to: it does the two deterministic halves. It
17
+ // says WHICH requirements read as invariants (`coverage --properties`), and
18
+ // it refuses to count a criterion marked `[property]` as proven unless the
19
+ // test it cites really is a property-based one. Writing the property is the
20
+ // agent's work, as writing the test always was.
21
+
22
+ /** Marks a criterion whose proof must generate its inputs. */
23
+ export const PROPERTY_MARKER = /^property\b/;
24
+
25
+ /** What a property-based test looks like, per library. */
26
+ const LIBRARIES = [
27
+ { name: "Hypothesis", re: /@given\s*\(|\bfrom\s+hypothesis\b|\bimport\s+hypothesis\b/ },
28
+ { name: "fast-check", re: /\bfc\.(assert|property|asyncProperty)\s*\(|["']fast-check["']/ },
29
+ { name: "jqwik", re: /@Property\b|\bnet\.jqwik\b/ },
30
+ { name: "proptest", re: /\bproptest!\s*\{/ },
31
+ { name: "quickcheck", re: /\bquickcheck!\s*\{|#\[quickcheck\]|\bquickCheck\b/ },
32
+ { name: "rapid", re: /\brapid\.Check\s*\(|\bquick\.Check\s*\(/ },
33
+ { name: "FsCheck", re: /\bProp\.ForAll\b|\[Property\b/ },
34
+ { name: "ScalaCheck", re: /\bforAll\s*[({]/ },
35
+ ];
36
+
37
+ /** The property library a test file uses, or null when it generates nothing. */
38
+ export function propertyLibraryIn(text) {
39
+ return LIBRARIES.find((lib) => lib.re.test(String(text)))?.name ?? null;
40
+ }
41
+
42
+ // The shapes a universal claim takes in a requirement, with the property
43
+ // each one suggests. English and Portuguese, as the specs are.
44
+ const SHAPES = [
45
+ { id: "round-trip",
46
+ re: /\b(round[- ]trip|and back\b|restor\w+ (?:the |its )?(?:original|same|previous)|revers(?:ible|ibly|es)\b|decod\w+|deserializ\w+|ida e volta|restaura\w* (?:o |a )?(?:original|mesm[oa]|anterior))/i,
47
+ property: "for any input x, undoing what was done to x gives x back" },
48
+ { id: "idempotence",
49
+ re: /\b(idempoten\w+|twice|a second time|more than once|applied again|run again|duas vezes|de novo|mais de uma vez|outra vez)\b/i,
50
+ property: "for any input x, doing it twice leaves what doing it once left" },
51
+ { id: "bound",
52
+ re: /\b(at most|at least (?!one\b)|no more than|no fewer than|not exceed|never exceed|within (?:\d|(?:\S+ ){0,3}(?:budget|limit|threshold|ratio|bound|quota|window|deadline)\b)|between \d|no máximo|pelo menos (?!um\b|uma\b)|no mínimo|não excede|entre \d)/i,
53
+ property: "for any input, the result stays inside the stated bound" },
54
+ { id: "uniqueness",
55
+ re: /(?<![\w-])(unique|distinct(?!ive| from)|no two|never the same|(?:not|never) .{0,40}already (?:in use|used|taken|issued|exist\w*|registered)|único|única|distint\w+|nunca .{0,20}repet\w+|já (?:em uso|usad\w+|exist\w+))/i,
56
+ property: "for any sequence of inputs, no two results collide" },
57
+ { id: "order",
58
+ re: /\b(sorted|ordered|in order(?! to\b)|ascending|descending|monoton\w+|ordenad\w+|em ordem|crescente|decrescente)\b/i,
59
+ property: "for any input, the output is in the stated order" },
60
+ { id: "invariant",
61
+ re: /\b(always|never|for every|for any|for all|whatever|sempre|nunca|para todo|para toda|para qualquer|qualquer que seja)\b/i,
62
+ property: "for any input, the stated condition holds" },
63
+ ];
64
+
65
+ /** Every requirement bullet under `## Requirements`, with its section and ordinal. */
66
+ export function requirementsOf(specText) {
67
+ const out = [];
68
+ let inside = false;
69
+ let section = null;
70
+ let n = 0;
71
+ let cur = null;
72
+ for (const line of String(specText).split(/\r?\n/)) {
73
+ if (/^##\s+/.test(line) && !/^###/.test(line)) {
74
+ inside = /^##\s+Requirements\b/i.test(line);
75
+ section = null;
76
+ cur = null;
77
+ continue;
78
+ }
79
+ if (!inside) continue;
80
+ const h = line.match(/^###\s+(\S+)/);
81
+ if (h) {
82
+ section = h[1].toLowerCase().replace(/[^a-z-]/g, "").split("-")[0];
83
+ n = 0;
84
+ cur = null;
85
+ continue;
86
+ }
87
+ if (section && /^-\s+\S/.test(line)) {
88
+ n += 1;
89
+ cur = { section, n, text: line.replace(/^-\s+/, "").trim() };
90
+ out.push(cur);
91
+ } else if (cur && /^\s+\S/.test(line)) {
92
+ cur.text += " " + line.trim();
93
+ } else if (line.trim() === "") {
94
+ cur = null;
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /** The first shape a requirement's text takes, or null when it states no universal claim. */
101
+ export function propertyShape(text) {
102
+ const prose = String(text).replace(/`[^`]*`/g, "");
103
+ return SHAPES.find((s) => s.re.test(prose)) ?? null;
104
+ }
105
+
106
+ /** The property library this project's manifests point at, with how sure that is. */
107
+ export function projectPropertyLibrary(projectRoot) {
108
+ const text = (/** @type {string} */ rel) => (isFile(path.join(projectRoot, rel)) ? read(path.join(projectRoot, rel)) : "");
109
+ const py = text("pyproject.toml") + text("requirements.txt") + text("requirements-dev.txt") + text("setup.cfg") + text("Pipfile");
110
+ const js = text("package.json");
111
+ if (/\bhypothesis\b/i.test(py)) return { name: "Hypothesis", declared: true };
112
+ if (/"fast-check"/.test(js)) return { name: "fast-check", declared: true };
113
+ if (py.trim() !== "") return { name: "Hypothesis", declared: false };
114
+ if (js.trim() !== "") return { name: "fast-check", declared: false };
115
+ if (isFile(path.join(projectRoot, "pom.xml")) || isFile(path.join(projectRoot, "build.gradle")) || isFile(path.join(projectRoot, "build.gradle.kts"))) return { name: "jqwik", declared: false };
116
+ if (isFile(path.join(projectRoot, "Cargo.toml"))) return { name: "proptest", declared: false };
117
+ if (isFile(path.join(projectRoot, "go.mod"))) return { name: "rapid", declared: false };
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Per capability: the requirements that read as invariants, and how many of
123
+ * its criteria are already properties.
124
+ * @param {string} projectRoot
125
+ * @param {{ only?: Set<string> | null }} [opts]
126
+ */
127
+ export function propertyCandidates(projectRoot, { only = /** @type {Set<string> | null} */ (null) } = {}) {
128
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
129
+ const out = [];
130
+ if (!isDir(specsDir)) return out;
131
+ for (const cap of readdirSync(specsDir).sort()) {
132
+ if (only && !only.has(cap)) continue;
133
+ const file = path.join(specsDir, cap, "spec.md");
134
+ if (!isFile(file)) continue;
135
+ const text = read(file);
136
+ const candidates = [];
137
+ for (const req of requirementsOf(text)) {
138
+ const shape = propertyShape(req.text);
139
+ if (shape) candidates.push({ ref: `${req.section} ${req.n}`, text: req.text, shape: shape.id, property: shape.property });
140
+ }
141
+ // The specific shapes first: "at most", "twice", "and back" name the
142
+ // property to write; a bare "always" only says that there is one.
143
+ const rank = (/** @type {string} */ id) => SHAPES.findIndex((s) => s.id === id);
144
+ candidates.sort((a, b) => rank(a.shape) - rank(b.shape));
145
+ const properties = parseAcceptanceCriteria(text).filter((row) => row.marker !== null && PROPERTY_MARKER.test(row.marker)).length;
146
+ out.push({ cap, candidates, properties });
147
+ }
148
+ return out;
149
+ }
@@ -203,7 +203,7 @@ export function readWorkflowEnv(text) {
203
203
  for (let i = 0; i < lines.length; i++) {
204
204
  const line = lines[i];
205
205
  if (!line.trim() || /^\s*#/.test(line)) continue;
206
- const indent = line.match(/^\s*/)[0].length;
206
+ const indent = (line.match(/^\s*/) ?? [""])[0].length;
207
207
 
208
208
  if (/^jobs:\s*$/.test(line)) {
209
209
  sawJobs = true;
@@ -681,17 +681,29 @@ function compileGlob(glob) {
681
681
  */
682
682
  export function filesMatching(projectRoot, glob) {
683
683
  const re = globToRegExp(glob);
684
+ const text = String(glob);
685
+ const segments = text.split("/");
684
686
  // Walk from the deepest literal directory prefix so a narrow glob never
685
- // costs a full-tree walk.
686
- const prefix = String(glob).split("/").filter((s) => !/[*?]/.test(s));
687
+ // costs a full-tree walk. The prefix ends at the first segment that is a
688
+ // pattern: taking every literal segment, wherever it stood, let
689
+ // `a/*/b/c.js` start from `a/b` when that directory existed.
687
690
  let base = projectRoot;
688
- for (const seg of prefix) {
691
+ let consumed = 0;
692
+ for (const seg of segments) {
693
+ if (/[*?{]/.test(seg)) break;
689
694
  const next = path.join(base, seg);
690
- if (isDir(next)) base = next;
691
- else break;
695
+ if (!isDir(next)) break;
696
+ base = next;
697
+ consumed += 1;
692
698
  }
693
699
  if (!isDir(base)) return [];
694
- return walk(base).filter((f) => re.test(relPath(projectRoot, f).replaceAll("\\", "/")));
700
+ // ...and no deeper than the pattern can reach (change 0249). A pattern
701
+ // with no `**` names a fixed number of levels; `tsconfig.json` names one.
702
+ // Without the bound, each root-level file a spec declared walked the whole
703
+ // repository — `node_modules` and `.git` included — to find it.
704
+ const spans = text.endsWith("/") || segments.includes("**") || /\{[^}]*\/[^}]*\}/.test(text);
705
+ const depth = spans ? Infinity : Math.max(1, segments.length - consumed);
706
+ return walk(base, { depth }).filter((f) => re.test(relPath(projectRoot, f).replaceAll("\\", "/")));
695
707
  }
696
708
 
697
709
  // ---------------------------------------------------------------------------
package/src/lib/scan.js CHANGED
@@ -24,6 +24,7 @@ export function listHeader(text, name) {
24
24
  return getHeader(text, name);
25
25
  }
26
26
 
27
+ /** @returns {string[]} */
27
28
  export function parseDependsOn(text) {
28
29
  const raw = specHeader(text, "Depends on");
29
30
  if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
@@ -77,6 +78,7 @@ function dirEntries(dir) {
77
78
  // The capabilities an ADR governs, from its optional "Scope:" header
78
79
  // (M4). Returns [] when absent, "n/a", or "—" — an unscoped ADR is global
79
80
  // and belongs in every pack, which is what keeps this backward compatible.
81
+ /** @returns {string[]} */
80
82
  export function parseAdrScope(text) {
81
83
  const raw = listHeader(text, "Scope");
82
84
  if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
@@ -175,7 +177,7 @@ export function changeEntry(proposal, id, prev, date) {
175
177
  // carried the lane: an archived change lost the lane it was born in, so the
176
178
  // report's "Lanes" section counted the whole history as unknown (change
177
179
  // 0192), which is the question change 0042 recorded the lane to answer.
178
- export function archivedChangeEntry(proposal, { id, archiveName, applied, specsAffected, prev = null, title = undefined }) {
180
+ export function archivedChangeEntry(proposal, { id, archiveName, applied, specsAffected, prev = /** @type {any} */ (null), title = /** @type {string | null | undefined} */ (undefined) }) {
179
181
  return {
180
182
  id,
181
183
  title: (title === undefined ? parseChangeTitle(proposal) : title) ?? prev?.title ?? id,
@@ -213,12 +215,12 @@ export function deriveIndex(projectRoot, current) {
213
215
  version: "0.1.0",
214
216
  last_updated: date,
215
217
  },
216
- specs: [],
217
- decisions: [],
218
- changes: [],
219
- changes_archive: [],
220
- skills: [],
221
- contracts: [],
218
+ specs: /** @type {any[]} */ ([]),
219
+ decisions: /** @type {any[]} */ ([]),
220
+ changes: /** @type {any[]} */ ([]),
221
+ changes_archive: /** @type {any[]} */ ([]),
222
+ skills: /** @type {any[]} */ ([]),
223
+ contracts: /** @type {any[]} */ ([]),
222
224
  },
223
225
  };
224
226
 
@@ -533,7 +535,10 @@ export function collectStagedIndexDrift(projectRoot) {
533
535
  const nothing = { ok: true, applicable: false, missing: [], unindexed: [], reason: null };
534
536
  if (!isRepo(projectRoot)) return { ...nothing, reason: "not a git repository" };
535
537
 
536
- const blob = git(projectRoot, ["show", ":.doctrina/index.json"]);
538
+ // `:./` reads the path from the current directory. A bare `:.doctrina/…`
539
+ // is read from the top of the repository, so for a project in a subfolder
540
+ // the check answered "no staged index" for every commit (change 0250).
541
+ const blob = git(projectRoot, ["show", ":./.doctrina/index.json"]);
537
542
  if (blob.state !== GIT_STATE.OK || !blob.stdout.trim()) {
538
543
  // Not staged at all — this commit does not carry an index, so it cannot
539
544
  // carry one that disagrees with itself.
@@ -0,0 +1,45 @@
1
+ // @ts-check
2
+ import process from "node:process";
3
+
4
+ // THE COMMANDS THE CLI PRINTS ARE RUN AS PRINTED (fourth audit, change 0235).
5
+ //
6
+ // `next`, `delivery status`, the playbooks and the hub hand an agent a line
7
+ // to paste into its shell: `cd <repo> && doctrina work "<title>" --delivery
8
+ // <id>`. The title comes from the tracker — text the delivery playbook calls
9
+ // data, never an instruction — and it was interpolated between double quotes
10
+ // as it came: a story titled `Fix $(curl … | sh)` became a command the agent
11
+ // ran, and a repository under `~/my api/` became a `cd` into `~/my`.
12
+
13
+ // Inside double quotes a POSIX shell still expands these.
14
+ const DOUBLE_QUOTE_UNSAFE = /["`$\\!]/;
15
+ const BARE_PATH = /^[A-Za-z0-9_./:\\~+@%=,-]+$/;
16
+
17
+ /**
18
+ * One shell argument, quoted so the shell reads it back verbatim. Plain text
19
+ * keeps the double quotes every surface has always printed; text a double
20
+ * quote would still expand is single-quoted — POSIX `'\''` for an embedded
21
+ * quote, PowerShell's `''` on Windows.
22
+ * @param {string} value
23
+ * @param {NodeJS.Platform} [platform]
24
+ */
25
+ export function qarg(value, platform = process.platform) {
26
+ const s = String(value ?? "");
27
+ if (!DOUBLE_QUOTE_UNSAFE.test(s)) return `"${s}"`;
28
+ const inner = platform === "win32" ? s.replace(/'/g, "''") : s.replace(/'/g, "'\\''");
29
+ return `'${inner}'`;
30
+ }
31
+
32
+ /**
33
+ * A path as a shell argument: bare when nothing in it needs quoting.
34
+ * @param {string} dir
35
+ * @param {NodeJS.Platform} [platform]
36
+ */
37
+ export function qpath(dir, platform = process.platform) {
38
+ const s = String(dir ?? "");
39
+ return BARE_PATH.test(s) ? s : qarg(s, platform);
40
+ }
41
+
42
+ /** `cd <dir> && <command>`, with the directory quoted when it must be. */
43
+ export function cdThen(dir, command) {
44
+ return `cd ${qpath(dir)} && ${command}`;
45
+ }
@@ -155,7 +155,11 @@ export function openChanges(projectRoot) {
155
155
  // The capabilities its deltas target — what `prime` turns into the
156
156
  // exact read command for the change (change 0217).
157
157
  const capabilities = deltaCapabilities(path.join(changesDir, id));
158
- out.push({ id, title, status, tasksDone, tasksTotal, unchecked, date, ageDays: ageInDays(date), capabilities });
158
+ // The delivery a change belongs to (change 0226): a session opened in the
159
+ // repository — an executor's, or a human's — sees it is one part of a
160
+ // larger item of work, not a change on its own.
161
+ const delivery = proposal ? (listHeader(proposal, "Delivery") ?? null) : null;
162
+ out.push({ id, title, status, tasksDone, tasksTotal, unchecked, date, ageDays: ageInDays(date), capabilities, delivery });
159
163
  }
160
164
  return out;
161
165
  }
@@ -50,7 +50,7 @@ const HEADER_RE = (name) =>
50
50
  // see ADR 0007"): only the WORD is checked.
51
51
  export const STATUS_VALUES = ["draft", "active", "deprecated"];
52
52
  export const IMPLEMENTATION_VALUES = ["planned", "partial", "implemented", "verified"];
53
- export const CRITERION_MARKS = ["verified", "unverified", "orchestration"];
53
+ export const CRITERION_MARKS = ["verified", "unverified", "orchestration", "property"];
54
54
 
55
55
  /** The state word of a header/mark value: the first token, note stripped. */
56
56
  export function stateWord(value) {
@@ -440,7 +440,7 @@ function locateRequirements(text) {
440
440
  if (h) {
441
441
  if (current) current.endIndex = i;
442
442
  const token = SECTIONS[h[1].toLowerCase().replace(/[^a-z-]/g, "")] ?? null;
443
- current = { headingIndex: i, endIndex: lines.length, bullets: [] };
443
+ current = { headingIndex: i, endIndex: lines.length, bullets: /** @type {number[]} */ ([]) };
444
444
  if (token) sections[token] = current;
445
445
  continue;
446
446
  }
@@ -1,9 +1,12 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
- import { isFile, read } from "./fs-ops.js";
4
- import { parseChangeTitle, parseChecklist } from "./doc-model.js";
5
- import { listHeader, deltaCapabilities } from "./scan.js";
6
- import { sliceSpec } from "./context-slice.js";
3
+ import { isDir, isFile, read, walk } from "./fs-ops.js";
4
+ import { getSection, parseChangeTitle, parseChecklist } from "./doc-model.js";
5
+ import { listHeader, deltaCapabilities, parseAdrScope } from "./scan.js";
6
+ import { extractOps } from "./spec-ops.js";
7
+ import { findWorkspace } from "./workspace.js";
8
+ import { deliveryPath, resolveDeliveryId, sectionText } from "./delivery.js";
9
+ import { sliceSpec, withoutComments } from "./context-slice.js";
7
10
  import { terms } from "./lexicon.js";
8
11
 
9
12
  // A TASK CARD (change 0222, ADR 0030): one task of a change, carrying what
@@ -55,7 +58,7 @@ function firstParagraph(text, heading) {
55
58
  * open one. Null `card` with a reason when there is none to give.
56
59
  * @returns {{ card: null, reason: string } | { card: object, reason: null }}
57
60
  */
58
- export function taskCard(projectRoot, changeId, ordinal = null) {
61
+ export function taskCard(projectRoot, changeId, ordinal = /** @type {number | null} */ (null)) {
59
62
  const changeDir = path.join(projectRoot, ".doctrina", "changes", changeId);
60
63
  const tasksPath = path.join(changeDir, "tasks.md");
61
64
  if (!isFile(tasksPath)) return { card: null, reason: "the change has no tasks.md" };
@@ -64,6 +67,10 @@ export function taskCard(projectRoot, changeId, ordinal = null) {
64
67
  let box;
65
68
  if (ordinal === null) {
66
69
  box = boxes.find((b) => !b.checked && !b.placeholder);
70
+ // Only scaffold boxes left is not "every task is ticked" (third audit,
71
+ // change 0234): the planner was told to review and close a change
72
+ // whose tasks no one had written.
73
+ if (!box && boxes.some((b) => !b.checked && b.placeholder)) return { card: null, reason: "tasks.md holds only the scaffold's placeholder boxes — write the tasks as cards first" };
67
74
  if (!box) return { card: null, reason: "every task is ticked — the change is ready for review and close" };
68
75
  } else {
69
76
  box = boxes.find((b) => b.ordinal === ordinal);
@@ -73,14 +80,40 @@ export function taskCard(projectRoot, changeId, ordinal = null) {
73
80
  // CRLF on a Windows checkout: the Why is read paragraph by paragraph.
74
81
  const proposal = isFile(path.join(changeDir, "proposal.md")) ? read(path.join(changeDir, "proposal.md")).replace(/\r\n/g, "\n") : "";
75
82
  const fields = cardFields(box.details);
76
- const query = terms([box.text, ...box.details].join(" "));
83
+ // What this change DOES to each spec (change 0231). The slice below is
84
+ // the spec as it stands — the current truth, before apply — so the
85
+ // requirement the executor is there to implement lived only in the delta,
86
+ // and reached the card only if the planner copied it into a line by hand.
87
+ const changes = [];
88
+ for (const cap of deltaCapabilities(changeDir)) {
89
+ const delta = path.join(changeDir, "specs", cap, "delta.md");
90
+ if (!isFile(delta)) continue;
91
+ for (const op of extractOps(read(delta))) {
92
+ if (op.verb === "append-requirement") changes.push({ capability: cap, line: `adds ${/^[aeiou]/.test(op.section) ? "an" : "a"} ${op.section} requirement: ${op.value}` });
93
+ else if (op.verb === "replace-requirement") changes.push({ capability: cap, line: `replaces ${op.section} requirement ${op.n} with: ${op.value}` });
94
+ else if (op.verb === "remove-requirement") changes.push({ capability: cap, line: `removes ${op.section} requirement ${op.n}` });
95
+ else if (op.verb === "append-criterion") changes.push({ capability: cap, line: `adds the criterion: ${op.value}` });
96
+ else if (op.verb === "replace-criterion") changes.push({ capability: cap, line: `replaces criterion ${op.n} with: ${op.value}` });
97
+ else if (op.verb === "remove-criterion") changes.push({ capability: cap, line: `removes criterion ${op.n}` });
98
+ }
99
+ }
100
+ // The task's words AND what the change writes into the spec (second
101
+ // audit, change 0233): a card for "lock the account on the third wrong
102
+ // password" cut the spec's only /login requirement, because the task never
103
+ // said "login" — the delta it implements did. Requirements only: a
104
+ // criterion's "[unverified] … — verified by `…`" matched any spec's
105
+ // placeholder on the boilerplate alone.
106
+ const query = terms([box.text, ...box.details, parseChangeTitle(proposal) ?? "", ...changes.filter((ch) => / requirement[^:]*: /.test(ch.line)).map((ch) => ch.line.slice(ch.line.indexOf(": ") + 2))].join(" "));
77
107
  const slices = [];
78
108
  for (const cap of deltaCapabilities(changeDir)) {
79
109
  const rel = `.doctrina/specs/${cap}/spec.md`;
80
110
  const specPath = path.join(projectRoot, rel);
81
111
  if (!isFile(specPath)) continue;
82
- const slice = sliceSpec(read(specPath), query, rel);
83
- if (slice) slices.push({ capability: cap, ...slice });
112
+ // Nothing in the spec matched the task: the whole spec, as `context`
113
+ // gives it (change 0231). Dropping it left a card with no spec at all.
114
+ const text = read(specPath);
115
+ const slice = sliceSpec(text, query, rel);
116
+ slices.push(slice ? { capability: cap, ...slice } : { capability: cap, text: withoutComments(text) ?? text, kept: null, total: null, whole: true });
84
117
  }
85
118
  return {
86
119
  card: {
@@ -96,7 +129,67 @@ export function taskCard(projectRoot, changeId, ordinal = null) {
96
129
  fields,
97
130
  why: firstParagraph(proposal, "Why"),
98
131
  slices,
132
+ changes,
133
+ story: deliveryStory(projectRoot, listHeader(proposal, "Delivery")),
134
+ decisions: bindingDecisions(projectRoot, deltaCapabilities(changeDir)),
99
135
  },
100
136
  reason: null,
101
137
  };
102
138
  }
139
+
140
+ // The story the change serves, when it belongs to a delivery (change 0231):
141
+ // its acceptance criteria are what the work is checked against, and they
142
+ // live in the workspace, where an executor opened in the repository never
143
+ // looks.
144
+ const STORY_MAX_CHARS = 1500;
145
+ function deliveryStory(projectRoot, deliveryId) {
146
+ if (!deliveryId) return null;
147
+ const found = findWorkspace(projectRoot);
148
+ if (!found) return null;
149
+ const id = resolveDeliveryId(found.dir, deliveryId.trim());
150
+ if (!id || !isFile(deliveryPath(found.dir, id))) return null;
151
+ const story = sectionText(read(deliveryPath(found.dir, id)), "Story");
152
+ if (!story) return null;
153
+ return story.length > STORY_MAX_CHARS ? `${story.slice(0, STORY_MAX_CHARS).replace(/\s+\S*$/, "")} …` : story;
154
+ }
155
+
156
+ // The accepted decisions that bind the capabilities the change touches:
157
+ // those whose Scope names one, and the unscoped ones that cite one. A card
158
+ // the executor follows without them is how an implementation re-decides
159
+ // what an ADR settled.
160
+ function bindingDecisions(projectRoot, capabilities) {
161
+ const dir = path.join(projectRoot, ".doctrina", "decisions");
162
+ if (!isDir(dir) || capabilities.length === 0) return { list: [], more: 0 };
163
+ /** @type {Array<{ id: string, title: string, decision: string, rank: number }>} */
164
+ const found = [];
165
+ for (const f of walk(dir).sort()) {
166
+ const m = path.basename(f).match(/^(\d{4})-.*\.md$/);
167
+ if (!m) continue;
168
+ const text = read(f).replace(/\r\n/g, "\n");
169
+ if ((listHeader(text, "Status") ?? "").trim().toLowerCase() !== "accepted") continue;
170
+ const scope = parseAdrScope(text);
171
+ const cites = (/** @type {string} */ cap) => new RegExp(`(^|[^a-z0-9-])${cap.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9-]|$)`, "im").test(text);
172
+ // An unscoped ADR is GLOBAL — it binds every capability, as `decision
173
+ // scope` documents and the context pack loads it (sixth audit, change
174
+ // 0237). The card bound one only when its text named the capability, so
175
+ // a repository-wide rule ("error codes are MAIUSCULAS_SNAKE") never
176
+ // reached the executor, who reads nothing else. Scoped first, then the
177
+ // global ones that name the capability, then the rest.
178
+ const rank = scope.length > 0 ? (capabilities.some((cap) => scope.includes(cap)) ? 0 : -1) : (capabilities.some(cites) ? 1 : 2);
179
+ if (rank < 0) continue;
180
+ const decision = (getSection(text, "Decision") ?? "").replace(/<!--[\s\S]*?-->/g, "").replace(/\s+/g, " ").trim();
181
+ found.push({
182
+ id: m[1],
183
+ title: text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m)?.[1]?.trim() ?? path.basename(f),
184
+ decision: decision.length > DECISION_EXCERPT ? `${decision.slice(0, DECISION_EXCERPT).replace(/\s+\S*$/, "")} …` : decision,
185
+ rank,
186
+ });
187
+ }
188
+ const sorted = found.sort((a, b) => a.rank - b.rank || a.id.localeCompare(b.id));
189
+ return { list: sorted.slice(0, DECISIONS_ON_CARD), more: Math.max(0, sorted.length - DECISIONS_ON_CARD) };
190
+ }
191
+
192
+ // How many binding decisions a card names, and how much of each it quotes.
193
+ const DECISIONS_ON_CARD = 8;
194
+ const DECISION_EXCERPT = 240;
195
+
@@ -5,6 +5,7 @@
5
5
  // `commands/templates.js` (audit finding F7). Collecting is not rendering, so
6
6
  // the collection lives here and both surfaces read it.
7
7
  import path from "node:path";
8
+ import { loadConfig } from "./config.js";
8
9
  import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "./fs-ops.js";
9
10
  import { adapterFiles, isHubPointer, listAdapterNames } from "./adapters.js";
10
11
  import { readTemplate } from "./templates.js";
@@ -28,7 +29,7 @@ import { agentsMdBudget } from "./validation-model.js";
28
29
  * @returns {{ used: number, budget: number, slack: number, declared: boolean }}
29
30
  */
30
31
  export function surfaceBudget(projectRoot = null) {
31
- const used = surfaceMarkdown().split("\n").length;
32
+ const used = surfaceMarkdown(projectRoot ? loadConfig(projectRoot).surface : "full").split("\n").length;
32
33
  const { value: budget, declared } = projectRoot
33
34
  ? declaredBudget(projectRoot, "surface-block-lines", SURFACE_LINE_BUDGET)
34
35
  : { value: SURFACE_LINE_BUDGET, declared: false };
@@ -129,7 +130,7 @@ export function collectFindings(projectRoot) {
129
130
  const block = findSurfaceBlock(text);
130
131
  if (!block) {
131
132
  findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina upgrade --write" });
132
- } else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
133
+ } else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock(loadConfig(projectRoot).surface))) {
133
134
  findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina upgrade --write" });
134
135
  } else {
135
136
  ok.push("AGENTS.md: doctrina:surface block current");
@@ -119,3 +119,78 @@ export function classify(prompt) {
119
119
 
120
120
  return { lane, scores: lanes, confident: best > 0 && best - runnerUp >= 2 };
121
121
  }
122
+
123
+ // THE TRACKS, BY WHAT THE REQUEST CHANGES (change 0255).
124
+ //
125
+ // The three lanes above say what KIND of request it is. They never said how
126
+ // much ceremony it earns, and the default answer was all of it: "fix the
127
+ // typo in the README" opened a proposal, three tasks and a thirteen-step
128
+ // close. `metrics` already printed "a one-line fix may rightly need none"
129
+ // under its adherence rate — the rule existed, in a footnote.
130
+ //
131
+ // The measure is not the size of the diff. It is whether a sentence in a
132
+ // spec becomes false, or one is missing: a one-line change of a timeout the
133
+ // spec states is a change, and a two-hundred-line reformat is not.
134
+ export const TRACKS = [
135
+ { id: "direct",
136
+ changes: "nothing a spec says — a typo, a comment, formatting, a fix that makes the code do what the spec already says",
137
+ path: "edit · doctrina verify · commit" },
138
+ { id: "chore",
139
+ changes: "the build, the tooling or the docs, worth a record — no spec changes",
140
+ path: 'doctrina work --chore "<request>"' },
141
+ { id: "change",
142
+ changes: "behaviour — a sentence in a spec becomes false, or one is missing",
143
+ path: 'doctrina work "<request>"' },
144
+ ];
145
+
146
+ // What the words alone can settle. Each phrase names an edit that cannot
147
+ // change behaviour; a bare "comment" or "formatting" is also the name of a
148
+ // feature ("comment moderation", "date formatting"), so those need the word
149
+ // that says which one is meant. A fix that restores what the spec already
150
+ // says is direct too, and no word list can know it: that one is the
151
+ // agent's call, by the rule the standing instructions state.
152
+ export const DIRECT_SIGNALS = [
153
+ /\b(typos?|misspell\w*|spelling|grammar|whitespace|indentation|trailing (spaces?|whitespace|commas?|newlines?))\b/,
154
+ /\b(formatting only|reformat\w*|fix(ing)? (the )?formatting|code formatting)\b/,
155
+ /\b((code|stale|outdated|old|wrong|misleading) comments?|comments? only|dead code|unused (imports?|variables?))\b/,
156
+ // pt
157
+ /\b(erros? de digitacao|ortografia|gramatica|espacos? em branco|indentacao)\b/,
158
+ /\b(so (a )?formatacao|reformatar|corrigir (a )?formatacao|formatacao do codigo)\b/,
159
+ /\b(comentarios? (de codigo|antig\w+|obsolet\w+|desatualizad\w+|errad\w+)|so (o |os )?comentarios?|codigo morto|imports? nao usad\w+|variave(l|is) nao usad\w+)\b/,
160
+ ];
161
+
162
+ /**
163
+ * The signals that make a prompt a direct edit, or none. A prompt that also
164
+ * carries a product or a runtime signal is not direct: "add" or "should"
165
+ * says behaviour is in play, and the ceremony is the safe side to err on.
166
+ * @param {string} prompt
167
+ * @param {ReturnType<typeof classify>} [verdict]
168
+ * @returns {string[]}
169
+ */
170
+ export function directSignals(prompt, verdict = classify(prompt)) {
171
+ if (verdict.scores.product.total > 0 || verdict.scores.runtime.total > 0) return [];
172
+ const text = fold(prompt);
173
+ const hits = [];
174
+ for (const re of DIRECT_SIGNALS) {
175
+ const m = text.match(re);
176
+ if (m) hits.push(m[0]);
177
+ }
178
+ return hits;
179
+ }
180
+
181
+ /** The track a prompt reads as: direct, chore, change — or diagnosis, for a runtime problem. */
182
+ export function trackOf(prompt, verdict = classify(prompt)) {
183
+ if (directSignals(prompt, verdict).length > 0) return "direct";
184
+ if (verdict.lane === "runtime") return "diagnosis";
185
+ return verdict.lane === "chore" ? "chore" : "change";
186
+ }
187
+
188
+ /** The three tracks as plain lines, for the commands that print them. */
189
+ export function trackLines() {
190
+ const out = [];
191
+ for (const t of TRACKS) {
192
+ out.push(` ${t.id.padEnd(7)} ${t.changes}`);
193
+ out.push(` ${t.path}`);
194
+ }
195
+ return out;
196
+ }