doctrina-cli 0.21.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 (75) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/commands/change.js +111 -14
  4. package/src/commands/clarify.js +2 -2
  5. package/src/commands/close.js +1 -1
  6. package/src/commands/coverage.js +69 -6
  7. package/src/commands/doctor.js +62 -4
  8. package/src/commands/hooks.js +56 -11
  9. package/src/commands/index-rebuild.js +47 -1
  10. package/src/commands/init.js +51 -7
  11. package/src/commands/metrics.js +1 -1
  12. package/src/commands/onboard.js +113 -0
  13. package/src/commands/review.js +1 -1
  14. package/src/commands/search.js +2 -2
  15. package/src/commands/templates.js +2 -1
  16. package/src/commands/triage.js +16 -1
  17. package/src/commands/ui.js +512 -0
  18. package/src/commands/upgrade.js +46 -6
  19. package/src/commands/verify.js +3 -3
  20. package/src/commands/why.js +1 -1
  21. package/src/commands/work.js +53 -40
  22. package/src/commands/workspace.js +16 -3
  23. package/src/index.js +9 -0
  24. package/src/lib/actions.js +19 -0
  25. package/src/lib/adapters.js +23 -3
  26. package/src/lib/agent-bridge.js +400 -0
  27. package/src/lib/applied-deltas.js +82 -0
  28. package/src/lib/args.js +1 -1
  29. package/src/lib/change-numbers.js +115 -0
  30. package/src/lib/change-ops.js +2 -2
  31. package/src/lib/clarify-scan.js +3 -3
  32. package/src/lib/code-inventory.js +22 -5
  33. package/src/lib/commands.js +50 -4
  34. package/src/lib/config.js +27 -8
  35. package/src/lib/conflict-markers.js +61 -0
  36. package/src/lib/console-model.js +668 -0
  37. package/src/lib/console-prefs.js +67 -0
  38. package/src/lib/console-room.js +201 -0
  39. package/src/lib/console-state.js +150 -0
  40. package/src/lib/console-view.js +557 -0
  41. package/src/lib/coverage-model.js +29 -6
  42. package/src/lib/delivery.js +2 -2
  43. package/src/lib/docs-impact.js +1 -1
  44. package/src/lib/fs-ops.js +80 -12
  45. package/src/lib/gates.js +7 -1
  46. package/src/lib/git.js +18 -0
  47. package/src/lib/index-json.js +10 -1
  48. package/src/lib/index-merge.js +90 -0
  49. package/src/lib/install.js +80 -0
  50. package/src/lib/intake-model.js +4 -1
  51. package/src/lib/ledger.js +2 -3
  52. package/src/lib/lock.js +58 -0
  53. package/src/lib/merge-setup.js +55 -0
  54. package/src/lib/metrics-model.js +2 -2
  55. package/src/lib/onboard.js +197 -0
  56. package/src/lib/preset.js +177 -0
  57. package/src/lib/prompt.js +1 -1
  58. package/src/lib/properties.js +149 -0
  59. package/src/lib/runtime.js +19 -7
  60. package/src/lib/scan.js +13 -8
  61. package/src/lib/spec-ops.js +2 -2
  62. package/src/lib/task-card.js +1 -1
  63. package/src/lib/templates-model.js +3 -2
  64. package/src/lib/triage-model.js +75 -0
  65. package/src/lib/validation-model.js +23 -5
  66. package/src/lib/views.js +2 -1
  67. package/src/lib/workspace-agents.js +2 -2
  68. package/src/lib/workspace-tools.js +41 -1
  69. package/src/lib/workspace.js +1 -1
  70. package/templates/AGENTS.md.template +8 -4
  71. package/templates/README.md +2 -1
  72. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  73. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  74. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  75. package/templates/adapters/kiro/README.md +22 -0
@@ -0,0 +1,197 @@
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 { checklistProgress, parseChangeTitle } from "./doc-model.js";
6
+ import { intakeStatus } from "./intake-model.js";
7
+ import { parseAcceptanceCriteria } from "./criteria.js";
8
+ import { collect } from "./coverage-model.js";
9
+ import { gitLines } from "./git.js";
10
+
11
+ // THE CYCLE ONCE, ON THE REAL REPOSITORY (change 0256).
12
+ //
13
+ // A newcomer had the reference (2,900 lines), a getting-started page, and
14
+ // `next`, which names one action and assumes the reader knows why. Nothing
15
+ // walked the whole cycle once. OpenSpec's `onboard` does that with a model
16
+ // narrating; here the CLI reads where the project stands and prints the
17
+ // walk — what each step is for, what to run, what to tell the human, and
18
+ // where to stop and ask — and the agent is the guide.
19
+ //
20
+ // Every "done" below is read from the tree, never stored: a tour that
21
+ // remembers its own progress is a second place for a fact to go stale.
22
+
23
+ const CODE_FILE = /\.(py|js|mjs|cjs|ts|tsx|jsx|go|rs|java|kt|rb|php|cs|swift|scala|c|cc|cpp|h|hpp|sh)$/i;
24
+ const TODO_LINE = /\b(TODO|FIXME)\b[:\s-]*(.{0,90})/;
25
+ const FILES_READ = 400;
26
+ const FILE_BYTES = 200_000;
27
+ export const CANDIDATES_SHOWN = 3;
28
+
29
+ /**
30
+ * Where the project stands, read from the tree.
31
+ * @param {string} projectRoot
32
+ */
33
+ export function onboardState(projectRoot) {
34
+ const dir = path.join(projectRoot, ".doctrina");
35
+ const installed = isDir(dir);
36
+ const specsDir = path.join(dir, "specs");
37
+ const specs = isDir(specsDir)
38
+ ? readdirSync(specsDir).filter((cap) => isFile(path.join(specsDir, cap, "spec.md"))).sort()
39
+ : [];
40
+ const intake = path.join(dir, "intake.md");
41
+ const intakePending = isFile(intake) && intakeStatus(read(intake)) === "pending";
42
+ const changesDir = path.join(dir, "changes");
43
+ const open = [];
44
+ if (isDir(changesDir)) {
45
+ for (const id of readdirSync(changesDir).filter((d) => /^\d{4}-/.test(d)).sort()) {
46
+ const proposal = path.join(changesDir, id, "proposal.md");
47
+ if (!isFile(proposal)) continue;
48
+ const tasksPath = path.join(changesDir, id, "tasks.md");
49
+ const p = isFile(tasksPath) ? checklistProgress(read(tasksPath)) : { total: 0, done: 0, placeholders: 0 };
50
+ open.push({
51
+ id,
52
+ title: parseChangeTitle(read(proposal)) ?? "",
53
+ planned: p.total > 0 && p.placeholders === 0,
54
+ ticked: p.total > 0 && p.placeholders === 0 && p.done === p.total,
55
+ tasks: { total: p.total, done: p.done },
56
+ });
57
+ }
58
+ }
59
+ const archiveDir = path.join(changesDir, "archive");
60
+ const archived = isDir(archiveDir)
61
+ ? readdirSync(archiveDir).filter((d) => /^\d{4}-\d{2}-\d{2}-\d{4}-/.test(d)).sort()
62
+ : [];
63
+ const hasCode = codeFiles(projectRoot, 1).length > 0;
64
+ return {
65
+ installed, specs, intakePending, hasCode, open,
66
+ closed: archived.length,
67
+ lastClosed: archived.length > 0 ? `.doctrina/changes/archive/${archived[archived.length - 1]}/` : null,
68
+ };
69
+ }
70
+
71
+ /** Tracked code files, as git lists them; none outside a repository. */
72
+ function codeFiles(projectRoot, limit) {
73
+ const out = [];
74
+ for (const f of gitLines(projectRoot, ["ls-files", "--cached", "--others", "--exclude-standard"])) {
75
+ if (f.startsWith(".doctrina/") || /(^|\/)(node_modules|vendor|dist|build)\//.test(f) || !CODE_FILE.test(f)) continue;
76
+ out.push(f);
77
+ if (out.length >= limit) break;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * Small, real first tasks read from this repository: a criterion with no
84
+ * proof yet, then a TODO somebody left in the code.
85
+ * @param {string} projectRoot
86
+ * @returns {{ kind: "criterion" | "todo", where: string, text: string, prompt: string }[]}
87
+ */
88
+ export function firstTaskCandidates(projectRoot) {
89
+ const out = [];
90
+ for (const report of collect(projectRoot)) {
91
+ if (out.length >= CANDIDATES_SHOWN) break;
92
+ const criteria = parseAcceptanceCriteria(read(report.specPath));
93
+ for (const row of report.rows) {
94
+ if (out.length >= CANDIDATES_SHOWN) break;
95
+ const kind = "was" in row ? row.was : row.kind;
96
+ if (kind !== "bare" && kind !== "dangling") continue;
97
+ const body = String(criteria[row.n - 1]?.body ?? "").replace(/^\[[a-z]+\]\s*/i, "").replace(/\s+—\s+(verified|proven|evidenced|demonstrated) by .*$/i, "").trim();
98
+ if (body === "") continue;
99
+ out.push({
100
+ kind: /** @type {"criterion"} */ ("criterion"),
101
+ where: `${report.cap} #${row.n}`,
102
+ text: `${kind === "bare" ? "no proof cited" : "the proof it cites is missing"}: ${clip(body, 90)}`,
103
+ prompt: `prove ${report.cap} criterion ${row.n}: ${clip(body, 70)}`,
104
+ });
105
+ }
106
+ }
107
+ let read_ = 0;
108
+ for (const f of codeFiles(projectRoot, FILES_READ)) {
109
+ if (out.length >= CANDIDATES_SHOWN * 2) break;
110
+ const abs = path.join(projectRoot, f);
111
+ if (!isFile(abs)) continue;
112
+ let text;
113
+ try {
114
+ text = read(abs);
115
+ } catch {
116
+ continue;
117
+ }
118
+ if (text.length > FILE_BYTES || ++read_ > FILES_READ) continue;
119
+ const lines = text.split("\n");
120
+ for (let i = 0; i < lines.length; i++) {
121
+ const m = lines[i].match(TODO_LINE);
122
+ if (!m || m[2].trim().length < 8) continue;
123
+ const note = m[2].trim().replace(/\s*(\*\/|-->)\s*$/, "");
124
+ out.push({ kind: /** @type {"todo"} */ ("todo"), where: `${f}:${i + 1}`, text: `${m[1]} ${clip(note, 90)}`, prompt: clip(note, 70) });
125
+ break;
126
+ }
127
+ }
128
+ return out.slice(0, CANDIDATES_SHOWN * 2);
129
+ }
130
+
131
+ const clip = (/** @type {string} */ s, /** @type {number} */ n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
132
+
133
+ /**
134
+ * The walk: every step with whether the tree says it is done, what it is
135
+ * for, what to run and where to ask. `current` is the first one not done.
136
+ * @param {ReturnType<typeof onboardState>} state
137
+ */
138
+ export function onboardSteps(state) {
139
+ const first = state.open[0] ?? null;
140
+ const id = first?.id ?? "<id>";
141
+ const ran = state.closed > 0;
142
+ const steps = [
143
+ { id: "install", title: "Install",
144
+ done: state.installed,
145
+ evidence: state.installed ? ".doctrina/ and AGENTS.md are here" : "",
146
+ say: "Doctrina keeps what the system should do in `.doctrina/` and tells every agent how to work in `AGENTS.md`; nothing else in the repository is touched.",
147
+ run: ["doctrina init"],
148
+ ask: "" },
149
+ { id: "describe", title: "Describe the project",
150
+ done: state.installed && state.specs.length > 0 && !state.intakePending,
151
+ evidence: state.specs.length > 0 ? `${state.specs.length} capability spec${state.specs.length === 1 ? "" : "s"} (${state.specs.slice(0, 4).join(", ")}${state.specs.length > 4 ? ", …" : ""})` : "",
152
+ say: "The specs are the current truth: one per capability, each requirement a testable sentence, each criterion citing its proof. Everything after this step changes them through a change.",
153
+ run: state.intakePending
154
+ ? ["doctrina intake # the stored description is pending: execute the playbook it prints"]
155
+ : [state.hasCode ? "doctrina intake --from-code # describe what the code does today" : 'doctrina intake --text "<what the project is, in a paragraph>"'],
156
+ ask: state.hasCode ? "what the code cannot tell: who uses it, and what must never break." : "for the project in a paragraph: who it is for and what it must do." },
157
+ { id: "first-task", title: "Pick a first task and open it",
158
+ done: state.open.length > 0 || ran,
159
+ evidence: first ? `${first.id} is open` : (ran ? "a change was opened and closed" : ""),
160
+ say: "A request that changes behaviour becomes a change before code is edited; the CLI scaffolds it and prints the playbook. Small and real beats a toy: the cycle is learned on work that matters.",
161
+ run: ['doctrina work "<the task>"'],
162
+ ask: "to choose one of the candidates, or to name a small task of their own — something done in under an hour." },
163
+ { id: "plan", title: "Write the delta and the tasks",
164
+ done: ran || state.open.some((ch) => ch.planned),
165
+ evidence: first?.planned ? `${first.id} has ${first.tasks.total} task${first.tasks.total === 1 ? "" : "s"} written` : "",
166
+ say: "The delta is the sentence the spec gains or loses; the tasks are how the code gets there. Nothing is implemented yet — this is the moment the human reads and corrects.",
167
+ run: [`doctrina work --resume ${id} # the playbook`, `doctrina change check ${id} # would the close pass?`],
168
+ ask: "whether the requirement you wrote is what they meant — show it to them before any code.",
169
+ askAfter: true },
170
+ { id: "implement", title: "Implement and prove",
171
+ done: ran || state.open.some((ch) => ch.ticked),
172
+ evidence: first?.ticked ? `every task of ${first.id} is ticked` : "",
173
+ say: "Each task is implemented and ticked, and each new criterion cites the test that proves it: `coverage` reads that citation, not the claim.",
174
+ run: [`doctrina change tick ${id} <n>`, "doctrina verify"],
175
+ ask: "" },
176
+ { id: "close", title: "Close",
177
+ done: ran,
178
+ evidence: ran ? `${state.closed} change${state.closed === 1 ? "" : "s"} closed — the last at ${state.lastClosed}` : "",
179
+ say: "One command merges the delta into the spec, runs every gate and archives the change. If it refuses, the change is not done — it names what is missing.",
180
+ run: [`doctrina close ${id}`],
181
+ ask: "" },
182
+ ];
183
+ const at = steps.findIndex((s) => !s.done);
184
+ return { steps, current: at < 0 ? null : steps[at].id };
185
+ }
186
+
187
+ /** What a project that already ran the cycle shows a newcomer. */
188
+ export function tourLines(state) {
189
+ const cap = state.specs[0] ?? "<capability>";
190
+ return [
191
+ "doctrina prime # the state of the project in one read — every session starts here",
192
+ `doctrina why ${cap}${" ".repeat(Math.max(1, 17 - cap.length))}# why a capability exists: intent, proof, decisions, history`,
193
+ ...(state.lastClosed ? [`${state.lastClosed}proposal.md # a finished change, start to end`] : []),
194
+ 'doctrina work "<request>" # the next request — it says by itself when no change is needed',
195
+ "doctrina hooks install # once per clone: the commit hook and the merge drivers",
196
+ ];
197
+ }
@@ -0,0 +1,177 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, read, walk, write } from "./fs-ops.js";
5
+ import { configPath } from "./config.js";
6
+
7
+ // A PRESET: THE ORGANISATION'S HOUSE, IN A FOLDER (change 0253).
8
+ //
9
+ // Every project of one company starts from the same things — its language,
10
+ // the rules no repository may break, the decisions that bind all of them,
11
+ // its templates, the checks its pipeline runs — and each `doctrina init`
12
+ // started from the CLI's defaults, so the house style was re-typed, or
13
+ // forgotten, per repository. A preset is a folder (a repository the team
14
+ // clones, a submodule, a path on a shared drive — the CLI makes no network
15
+ // call) that `init --preset` and `upgrade --preset` lay over a project:
16
+ //
17
+ // preset.json { "name", "description", "config": { … } }
18
+ // templates/** → .doctrina/templates/** (per-file overrides, ADR 0019)
19
+ // decisions/*.md → .doctrina/decisions/
20
+ // skills/*.md → .doctrina/skills/
21
+ // verify.json → .doctrina/verify.json (only when the project has none)
22
+ //
23
+ // ADDITIVE, like every scaffold update: a file the project already has is
24
+ // kept, a config key it already declares is kept, and the plan says so.
25
+
26
+ export const PRESET_FILE = "preset.json";
27
+ const NAME = /^[a-z0-9][a-z0-9-]*$/;
28
+ const CONFIG_KEYS = ["language", "context_budget", "rules", "surface"];
29
+ const FOLDERS = [
30
+ { from: "templates", to: ".doctrina/templates", only: null },
31
+ { from: "decisions", to: ".doctrina/decisions", only: /\.md$/ },
32
+ { from: "skills", to: ".doctrina/skills", only: /\.md$/ },
33
+ ];
34
+
35
+ /**
36
+ * Read a preset folder. Throws an Error whose message says what is wrong
37
+ * with it — the caller prints it and exits 2.
38
+ * @param {string} dir
39
+ * @returns {{ dir: string, name: string, description: string, config: Record<string, any> }}
40
+ */
41
+ export function readPreset(dir) {
42
+ const root = path.resolve(dir);
43
+ if (!isDir(root)) throw new Error(`preset ${dir} is not a folder`);
44
+ const file = path.join(root, PRESET_FILE);
45
+ if (!isFile(file)) throw new Error(`${dir} has no ${PRESET_FILE} — a preset is a folder with one: { "name": "acme", "config": { … } }`);
46
+ let data;
47
+ try {
48
+ data = JSON.parse(read(file));
49
+ } catch (err) {
50
+ throw new Error(`${path.join(dir, PRESET_FILE)} is not valid JSON: ${/** @type {Error} */ (err).message}`);
51
+ }
52
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error(`${PRESET_FILE} must be an object`);
53
+ if (typeof data.name !== "string" || !NAME.test(data.name)) throw new Error(`${PRESET_FILE} needs a "name" of lowercase letters, digits and hyphens`);
54
+ const config = data.config ?? {};
55
+ if (typeof config !== "object" || Array.isArray(config)) throw new Error(`${PRESET_FILE}: "config" must be an object`);
56
+ const unknown = Object.keys(config).filter((k) => !CONFIG_KEYS.includes(k));
57
+ if (unknown.length > 0) throw new Error(`${PRESET_FILE}: config key${unknown.length === 1 ? "" : "s"} ${unknown.map((k) => `"${k}"`).join(", ")} unknown — a preset sets ${CONFIG_KEYS.join(", ")}`);
58
+ return { dir: root, name: data.name, description: typeof data.description === "string" ? data.description : "", config };
59
+ }
60
+
61
+ const posix = (/** @type {string} */ p) => p.split(path.sep).join("/");
62
+ const adrNumber = (/** @type {string} */ name) => name.match(/^(\d{4})-/)?.[1] ?? null;
63
+
64
+ /**
65
+ * What laying the preset over the project would do. Reads only.
66
+ * @returns {{
67
+ * files: { rel: string, from: string, action: "copy" | "same" | "kept", note: string }[],
68
+ * config: { key: string, value: any, action: "set" | "kept" | "same" }[],
69
+ * verify: string[],
70
+ * }}
71
+ */
72
+ export function planPreset(projectRoot, preset) {
73
+ /** @type {{ rel: string, from: string, action: "copy" | "same" | "kept", note: string }[]} */
74
+ const files = [];
75
+ const taken = new Map();
76
+ const decisions = path.join(projectRoot, ".doctrina", "decisions");
77
+ if (isDir(decisions)) {
78
+ for (const f of readdirSync(decisions)) {
79
+ const n = adrNumber(f);
80
+ if (n) taken.set(n, f);
81
+ }
82
+ }
83
+ const add = (/** @type {string} */ from, /** @type {string} */ rel) => {
84
+ const dest = path.join(projectRoot, rel);
85
+ if (isFile(dest)) {
86
+ files.push({ rel, from, action: read(dest) === read(from) ? "same" : "kept", note: "the project has its own" });
87
+ return;
88
+ }
89
+ // An ADR's number is its identity: a preset decision whose number the
90
+ // project already gave to another one is not renumbered behind its back.
91
+ const n = rel.startsWith(".doctrina/decisions/") ? adrNumber(path.basename(rel)) : null;
92
+ if (n && taken.has(n)) {
93
+ files.push({ rel, from, action: "kept", note: `ADR number ${n} is ${taken.get(n)} here — copy it under a free number by hand` });
94
+ return;
95
+ }
96
+ files.push({ rel, from, action: "copy", note: "" });
97
+ };
98
+ for (const folder of FOLDERS) {
99
+ const base = path.join(preset.dir, folder.from);
100
+ for (const from of walk(base)) {
101
+ if (folder.only && !folder.only.test(from)) continue;
102
+ add(from, `${folder.to}/${posix(path.relative(base, from))}`);
103
+ }
104
+ }
105
+ /** @type {string[]} */
106
+ let verify = [];
107
+ const presetVerify = path.join(preset.dir, "verify.json");
108
+ if (isFile(presetVerify)) {
109
+ add(presetVerify, ".doctrina/verify.json");
110
+ try {
111
+ verify = (JSON.parse(read(presetVerify)).checks ?? []).map((/** @type {{ run?: string }} */ ch) => String(ch.run ?? "")).filter(Boolean);
112
+ } catch { /* an unreadable verify.json is reported by `doctrina verify` once copied */ }
113
+ }
114
+
115
+ /** @type {Record<string, any>} */
116
+ let current = {};
117
+ try {
118
+ current = JSON.parse(read(configPath(projectRoot)));
119
+ } catch { /* no config.json yet, or an unreadable one: nothing is declared */ }
120
+ const config = Object.entries(preset.config).map(([key, value]) => ({
121
+ key, value,
122
+ action: /** @type {"set" | "kept" | "same"} */ (!(key in current) ? "set"
123
+ : JSON.stringify(current[key]) === JSON.stringify(value) ? "same" : "kept"),
124
+ }));
125
+ return { files, config, verify };
126
+ }
127
+
128
+ /** How many things the plan would write. */
129
+ export function presetPending(plan) {
130
+ return plan.files.filter((f) => f.action === "copy").length + plan.config.filter((c) => c.action === "set").length;
131
+ }
132
+
133
+ /** Write what the plan calls for. The caller re-derives the index. */
134
+ export function applyPreset(projectRoot, preset, plan) {
135
+ for (const f of plan.files) {
136
+ if (f.action === "copy") write(path.join(projectRoot, f.rel), read(f.from));
137
+ }
138
+ const file = configPath(projectRoot);
139
+ /** @type {Record<string, any>} */
140
+ let current = {};
141
+ try {
142
+ current = JSON.parse(read(file));
143
+ } catch { /* written whole below */ }
144
+ /** @type {Record<string, any>} */
145
+ const next = { ...current };
146
+ for (const c of plan.config) if (c.action === "set") next[c.key] = c.value;
147
+ next.preset = preset.name;
148
+ write(file, JSON.stringify(next, null, 2) + "\n", { force: true });
149
+ }
150
+
151
+ /**
152
+ * The plan as lines a person reads: what is copied, what is kept and why,
153
+ * and — said every time — the commands a preset's verify.json would run.
154
+ * @param {boolean} written
155
+ * @returns {string[]}
156
+ */
157
+ export function describePreset(preset, plan, written) {
158
+ const verb = written ? "copied " : "would copy";
159
+ const lines = [`preset ${preset.name}${preset.description ? ` — ${preset.description}` : ""}`];
160
+ for (const f of plan.files) {
161
+ if (f.action === "copy") lines.push(` ${verb} ${f.rel}`);
162
+ else if (f.action === "kept") lines.push(` kept ${f.rel} — ${f.note}`);
163
+ }
164
+ for (const c of plan.config) {
165
+ const shown = Array.isArray(c.value) ? `${c.value.length} rule${c.value.length === 1 ? "" : "s"}` : JSON.stringify(c.value);
166
+ if (c.action === "set") lines.push(` ${written ? "set " : "would set "} config ${c.key} = ${shown}`);
167
+ else if (c.action === "kept") lines.push(` kept config ${c.key} — the project declares another value`);
168
+ }
169
+ const copiesVerify = plan.files.some((f) => f.rel === ".doctrina/verify.json" && f.action === "copy");
170
+ if (copiesVerify && plan.verify.length > 0) {
171
+ lines.push(` note the preset's verify.json declares what \`doctrina verify\` RUNS on this machine: ${plan.verify.map((r) => `\`${r}\``).join(", ")} — read it before running the gate`);
172
+ }
173
+ const same = plan.files.filter((f) => f.action === "same").length;
174
+ if (same > 0) lines.push(` same ${same} file${same === 1 ? "" : "s"} already as the preset has ${same === 1 ? "it" : "them"}`);
175
+ if (presetPending(plan) === 0) lines.push(" nothing to add — the project already carries the preset");
176
+ return lines;
177
+ }
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.
@@ -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
  }