doctrina-cli 0.4.0 → 0.8.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.
@@ -43,7 +43,9 @@ export async function run(positional, flags) {
43
43
  write(target, body, { force: true });
44
44
  chmodSync(target, 0o755);
45
45
  console.log(c.green("installed") + ` ${relPath(projectRoot, target)}`);
46
- console.log(`The hook runs ${c.cyan("doctrina validate")} before each commit.`);
46
+ console.log(`The hook runs ${c.cyan("doctrina validate --fix")} before each commit:`);
47
+ console.log(c.gray(" it rebuilds index.json from the tree (healing drift, re-staging it)"));
48
+ console.log(c.gray(" and still blocks the commit on errors a rebuild cannot heal."));
47
49
  return 0;
48
50
  }
49
51
 
@@ -51,8 +53,12 @@ export const help = `
51
53
  Usage: doctrina hooks install [--force]
52
54
 
53
55
  Install the Doctrina pre-commit hook into .git/hooks/pre-commit.
54
- The hook runs \`doctrina validate\` and blocks the commit when
55
- validation reports errors.
56
+ The hook runs \`doctrina validate --fix\`: it regenerates index.json
57
+ from the tree (healing the most common gate failure — a hand-edited
58
+ header that drifted the index — and re-staging the repaired index)
59
+ and still blocks the commit on errors a rebuild cannot heal. Edit the
60
+ installed hook freely; to gate without auto-repair, swap the line for a
61
+ bare \`doctrina validate\`.
56
62
 
57
63
  Options:
58
64
  --force Overwrite an existing pre-commit hook
@@ -58,17 +58,43 @@ export async function run(_positional, _flags) {
58
58
  }
59
59
  }
60
60
 
61
- // ADRs stuck in proposed need a human decision.
61
+ // ADRs stuck in proposed need a human decision; accepted-but-bare ADRs are
62
+ // the rot the review flagged — a decision with nothing behind it (no Evidence,
63
+ // no Landed) is drift waiting to be superseded. Surface the proposed ones
64
+ // first (a pending decision blocks more than a missing stamp).
62
65
  const adrDir = path.join(projectRoot, ".doctrina", "decisions");
66
+ const landNudges = [];
67
+ const isBareValue = (v) => {
68
+ const t = (v ?? "").trim();
69
+ return t === "" || t === "—" || t === "-";
70
+ };
63
71
  for (const f of walk(adrDir)) {
64
72
  if (!f.endsWith(".md")) continue;
65
73
  const m = path.basename(f).match(/^(\d{4})-/);
66
74
  if (!m) continue;
67
- const status = listHeader(read(f), "Status");
75
+ const text = read(f);
76
+ const status = listHeader(text, "Status");
68
77
  if (status && status.toLowerCase() === "proposed") {
69
78
  actions.push(`review ADR ${m[1]} (${relPath(projectRoot, f)}): doctrina decision accept ${m[1]}, or supersede it`);
79
+ } else if (status && status.toLowerCase() === "accepted") {
80
+ // Bare only when BOTH anchors are empty (Evidence header present-but-bare
81
+ // and no Landed stamp). An ADR that opts out of Evidence entirely
82
+ // (header absent) is not nagged.
83
+ const evidence = listHeader(text, "Evidence");
84
+ const landed = listHeader(text, "Landed");
85
+ if (evidence !== null && isBareValue(evidence) && isBareValue(landed)) {
86
+ landNudges.push(`record what proves ADR ${m[1]}: cite its **Evidence:**, or once it ships, doctrina decision land ${m[1]}`);
87
+ }
70
88
  }
71
89
  }
90
+ for (const n of landNudges) actions.push(n);
91
+
92
+ // No procedural memory captured yet, but the history shows a fix-shaped
93
+ // change — exactly the lesson a skill exists to keep from being relearned
94
+ // (review §5: the change-0003 "tolerate LLM code fences" case). One gentle
95
+ // nudge, only when skills are empty, so it never nags a project that opted in.
96
+ const skillNudge = suggestSkillCapture(projectRoot);
97
+ if (skillNudge) actions.push(skillNudge);
72
98
 
73
99
  // Index drift is silent rot; surface it last.
74
100
  try {
@@ -95,13 +121,40 @@ export async function run(_positional, _flags) {
95
121
  return 0;
96
122
  }
97
123
 
124
+ // Return a single skill-capture nudge, or null. Fires only when no skill has
125
+ // been written yet (skills/ holds nothing but .gitkeep) AND the archive shows a
126
+ // fix-shaped change whose lesson is the textbook case for a skill. Deterministic
127
+ // pattern match on the archived folder name — a hint, never a decision (ADR 0005).
128
+ const FIX_SHAPED = /(?:^|-)(fix|bug|hotfix|patch|parse|parsing|tolerate|workaround|race|deadlock|flaky|retry|escape|sanitize|sanitise)(?:-|$)/;
129
+
130
+ function suggestSkillCapture(projectRoot) {
131
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
132
+ if (isDir(skillsDir)) {
133
+ const hasSkill = walk(skillsDir).some((f) => f.endsWith(".md"));
134
+ if (hasSkill) return null; // opted in already — never nag
135
+ }
136
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
137
+ if (!isDir(archiveDir)) return null;
138
+ for (const name of readdirSync(archiveDir).sort()) {
139
+ if (!isDir(path.join(archiveDir, name))) continue;
140
+ const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
141
+ if (FIX_SHAPED.test(id)) {
142
+ return `capture a skill from past fixes (e.g. "${id}"): doctrina skill new <slug> — ` +
143
+ `on-demand procedural memory so the next agent does not relearn it (none exist yet)`;
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+
98
149
  export const help = `
99
150
  Usage: doctrina next
100
151
 
101
152
  Inspect the .doctrina/ tree and print the recommended next workflow
102
153
  actions in priority order: open changes (missing proposal, unchecked
103
154
  tasks, deltas ready to apply, applied-but-unarchived), ADRs still in
104
- proposed status, and index drift. Read-only; always exits 0.
155
+ proposed status, accepted ADRs with nothing proving them (cite Evidence
156
+ or run "decision land"), a skill-capture nudge when a past fix went
157
+ uncaptured, and index drift last. Read-only; always exits 0.
105
158
 
106
159
  Intended use: agents and humans run it to resume work without
107
160
  re-reading the whole tree.
@@ -0,0 +1,177 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { spawnSync } from "node:child_process";
4
+ import { readdirSync } from "node:fs";
5
+ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
6
+ import { flagBool, flagString } from "../lib/args.js";
7
+ import { c } from "../lib/colors.js";
8
+ import { rankCapabilitiesByDiff } from "./work.js";
9
+ import { summarize as coverageSummary } from "./coverage.js";
10
+ import { summarize as traceSummary } from "./trace.js";
11
+
12
+ // Deterministic conformance review (review 2026-06-27 passive-user feature #3).
13
+ // Given the working tree (or a diff against a ref), report STRUCTURAL breaks
14
+ // between the change and the spec/ADR/contract tree, so the agent self-reviews
15
+ // before bringing work to the human and the human's approval is one command.
16
+ //
17
+ // It checks links and conformance shape, NOT semantic fidelity — whether the
18
+ // code actually does what the spec says stays a human/LLM call, the same honest
19
+ // ceiling as `trace`/`clarify` (ADR 0005). Read-only; exits 0 as a report, 1
20
+ // under --strict when any break exists (CI gate).
21
+
22
+ export async function run(_positional, flags) {
23
+ const projectRoot = process.cwd();
24
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
25
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
26
+ }
27
+ const strict = flagBool(flags, "strict", false);
28
+ const against = flagString(flags, "diff"); // optional git ref to diff against
29
+
30
+ const { all, sourceFiles } = changedFiles(projectRoot, against);
31
+ console.log(c.bold("Review") + c.gray(against ? ` — vs ${against}` : " — working-tree changes"));
32
+ console.log("");
33
+
34
+ if (all.length === 0) {
35
+ console.log(c.gray(against
36
+ ? `no changes between ${against} and the working tree`
37
+ : "no working-tree changes to review (stage or edit code first, or pass --diff <ref>)"));
38
+ return 0;
39
+ }
40
+
41
+ const breaks = []; // hard conformance breaks (fail --strict)
42
+ const notes = []; // advisory observations
43
+
44
+ // 1. Code changed under a capability whose spec was NOT touched — the spec
45
+ // may have drifted from the code it describes.
46
+ const specChanged = new Set(
47
+ all.filter((f) => /(^|\/)\.doctrina\/specs\/[^/]+\/spec\.md$/.test(f.replace(/\\/g, "/")))
48
+ .map((f) => f.replace(/\\/g, "/").match(/\.doctrina\/specs\/([^/]+)\/spec\.md$/)[1]),
49
+ );
50
+ const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles);
51
+ for (const m of ranked) {
52
+ if (!specChanged.has(m.id)) {
53
+ breaks.push(`code under capability "${m.id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${m.id}/spec.md\` still describes the code (update it, or note why not)`);
54
+ }
55
+ }
56
+
57
+ // 2. New source files that map to no capability at all — code with no home
58
+ // in any spec (a likely new, unspecced capability).
59
+ if (ranked.length === 0 && sourceFiles.length > 0) {
60
+ notes.push(`changed code maps to no existing capability spec — if this is a new capability, scaffold it: \`doctrina spec new <capability>\` (or \`doctrina work --from-diff\`)`);
61
+ }
62
+
63
+ // 3. Coverage — acceptance criteria whose cited proof is missing/skipped.
64
+ const cov = coverageSummary(projectRoot);
65
+ if (cov.totalDangling > 0) {
66
+ breaks.push(`${cov.totalDangling} acceptance criterion(s) cite evidence missing on disk (\`doctrina coverage\`)`);
67
+ }
68
+ if (cov.totalConditional > 0) {
69
+ notes.push(`${cov.totalConditional} acceptance criterion(s) proven only by a skipped test (\`doctrina coverage\`)`);
70
+ }
71
+ if (cov.totalCriteria > 0 && cov.totalCovered < cov.totalCriteria - cov.totalDangling - cov.totalConditional) {
72
+ notes.push(`coverage is ${cov.pct}% (${cov.totalCovered}/${cov.totalCriteria}) — uncovered criteria carry no proof`);
73
+ }
74
+
75
+ // 4. Trace — product intent that no spec realizes, or specs realizing nothing.
76
+ const tr = traceSummary(projectRoot);
77
+ if (tr.dropped > 0) breaks.push(`${tr.dropped} product intent anchor(s) are realized by no spec (\`doctrina trace\`)`);
78
+ if (tr.untraceable > 0) notes.push(`${tr.untraceable} active spec(s) trace to no product intent (\`doctrina trace\`)`);
79
+
80
+ // 5. Contract surface — port collisions / env drift / dangling spec refs.
81
+ const contractIssues = runContractCheck(projectRoot);
82
+ for (const issue of contractIssues) breaks.push(issue);
83
+
84
+ // --- Output ---
85
+ console.log(c.gray(`${all.length} changed path(s); ${sourceFiles.length} source file(s) outside .doctrina/.`));
86
+ if (ranked.length > 0) {
87
+ console.log(c.gray("Capabilities touched: ") + ranked.map((m) => c.cyan(m.id)).join(", "));
88
+ }
89
+ console.log("");
90
+
91
+ if (breaks.length === 0 && notes.length === 0) {
92
+ console.log(c.green("ok") + " no structural conformance breaks found");
93
+ } else {
94
+ for (const b of breaks) console.log(` ${c.red("✗")} ${b}`);
95
+ for (const n of notes) console.log(` ${c.yellow("!")} ${n}`);
96
+ console.log("");
97
+ const summary = `${breaks.length} break${breaks.length === 1 ? "" : "s"}, ${notes.length} note${notes.length === 1 ? "" : "s"}`;
98
+ const status = breaks.length === 0 ? c.green("ok") : (strict ? c.red("fail") : c.yellow("gap"));
99
+ console.log(status + " " + summary);
100
+ }
101
+
102
+ console.log("");
103
+ console.log(c.gray("Structural review only — whether the code is FAITHFUL to the spec is a human/LLM call."));
104
+ return strict && breaks.length > 0 ? 1 : 0;
105
+ }
106
+
107
+ // Changed paths between a base (a git ref, or HEAD + untracked for the working
108
+ // tree) and now. Returns the full list and the subset outside .doctrina/.
109
+ function changedFiles(projectRoot, against) {
110
+ const run = (args) => {
111
+ const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
112
+ if (r.error || r.status !== 0) return [];
113
+ return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
114
+ };
115
+ const set = new Set();
116
+ if (against) {
117
+ for (const f of run(["diff", "--name-only", against])) set.add(f);
118
+ } else {
119
+ for (const f of run(["diff", "--name-only", "HEAD"])) set.add(f);
120
+ for (const f of run(["ls-files", "--others", "--exclude-standard"])) set.add(f);
121
+ }
122
+ const all = [...set];
123
+ const sourceFiles = all.filter((f) => !f.replace(/\\/g, "/").startsWith(".doctrina/"));
124
+ return { all, sourceFiles };
125
+ }
126
+
127
+ // Light re-detection of the two contract breaks that matter to a review — a
128
+ // port claimed by two contracts, and a referenced spec that does not exist. The
129
+ // full contract gate stays `doctrina contract check`; this keeps review
130
+ // self-contained without spawning it.
131
+ function runContractCheck(projectRoot) {
132
+ const issues = [];
133
+ const contractsDir = path.join(projectRoot, ".doctrina", "contracts");
134
+ if (!isDir(contractsDir)) return issues;
135
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
136
+ const ports = new Map(); // port -> contract id
137
+ for (const f of readdirSync(contractsDir)) {
138
+ if (!f.endsWith(".md")) continue;
139
+ const id = path.basename(f, ".md");
140
+ const text = read(path.join(contractsDir, f));
141
+ for (const m of text.matchAll(/`?(\d{2,5})`?\s*\|/g)) {
142
+ const port = m[1];
143
+ if (Number(port) >= 1 && Number(port) <= 65535) {
144
+ if (ports.has(port) && ports.get(port) !== id) {
145
+ issues.push(`contract port ${port} is claimed by both "${ports.get(port)}" and "${id}" (\`doctrina contract check\`)`);
146
+ } else {
147
+ ports.set(port, id);
148
+ }
149
+ }
150
+ }
151
+ for (const m of text.matchAll(/\.doctrina\/specs\/([a-z0-9-]+)\//g)) {
152
+ const cap = m[1];
153
+ if (!isFile(path.join(specsDir, cap, "spec.md"))) {
154
+ issues.push(`contract "${id}" references spec "${cap}" which does not exist (\`doctrina contract check\`)`);
155
+ }
156
+ }
157
+ }
158
+ return issues;
159
+ }
160
+
161
+ export const help = `
162
+ Usage: doctrina review [--diff <ref>] [--strict]
163
+
164
+ Deterministic conformance review of your changes against the spec / ADR /
165
+ contract tree. By default it reviews the working-tree changes (tracked +
166
+ untracked); with --diff <ref> it reviews everything that differs from a git
167
+ ref (e.g. \`doctrina review --diff main\`).
168
+
169
+ Reports structural breaks: code changed under a capability whose spec was
170
+ not updated, changed code that maps to no capability, acceptance criteria
171
+ citing missing proof, product intent realized by no spec, and contract
172
+ port/reference collisions. It checks conformance shape, NOT semantic
173
+ fidelity (whether the code does what the spec says stays a human/LLM call).
174
+
175
+ Read-only. Exits 0 as a report; with --strict, exits 1 when any hard break
176
+ exists (CI gate). Notes (advisory) never fail the gate.
177
+ `;
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
- import { exists, isDir, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
4
5
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
5
6
  import * as idx from "../lib/index-json.js";
6
7
  import { today } from "../lib/dates.js";
@@ -8,7 +9,7 @@ import { flagBool } from "../lib/args.js";
8
9
  import { c } from "../lib/colors.js";
9
10
  import { suggest } from "../lib/suggest.js";
10
11
 
11
- const SUBCOMMANDS = ["new", "list", "sync"];
12
+ const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
12
13
 
13
14
  export async function run(positional, flags) {
14
15
  const sub = positional[0];
@@ -22,9 +23,114 @@ export async function run(positional, flags) {
22
23
  }
23
24
  if (sub === "new") return skillNew(positional.slice(1), flags);
24
25
  if (sub === "sync") return skillSync();
26
+ if (sub === "suggest") return skillSuggest(positional.slice(1), flags);
25
27
  return skillList();
26
28
  }
27
29
 
30
+ // Surface (and optionally scaffold) skills worth capturing (review 2026-06-27
31
+ // passive-user feature #6). A skill exists so a hard-won lesson is not relearned
32
+ // by the next agent/session; the framework otherwise never pulls you toward
33
+ // writing one. `skill suggest` scans the archived changes for fix-shaped work —
34
+ // the textbook case for a skill — and lists candidates whose lesson is not yet
35
+ // captured. With --write it scaffolds a stub per candidate, pre-seeded from the
36
+ // change so the human/agent only fills the body. Deterministic pattern match on
37
+ // the archived folder name — a hint, never a decision (ADR 0005).
38
+ const FIX_SHAPED = /(?:^|-)(fix|bug|hotfix|patch|parse|parsing|tolerate|workaround|race|deadlock|flaky|retry|escape|sanitize|sanitise)(?:-|$)/;
39
+
40
+ function skillSuggest(args, flags) {
41
+ const projectRoot = process.cwd();
42
+ ensureDoctrinaProject(projectRoot);
43
+ const doWrite = flagBool(flags, "write", false);
44
+
45
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
46
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
47
+ const existing = new Set(
48
+ isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")).map((f) => path.basename(f, ".md")) : [],
49
+ );
50
+
51
+ const candidates = [];
52
+ if (isDir(archiveDir)) {
53
+ for (const name of readdirSync(archiveDir).sort()) {
54
+ if (!isDir(path.join(archiveDir, name))) continue;
55
+ const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
56
+ if (!FIX_SHAPED.test(id)) continue;
57
+ const slug = skillSlug(id);
58
+ if (existing.has(slug)) continue; // lesson already captured
59
+ const proposal = path.join(archiveDir, name, "proposal.md");
60
+ const why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
61
+ candidates.push({ id, slug, why, archive: name });
62
+ }
63
+ }
64
+
65
+ if (candidates.length === 0) {
66
+ console.log(c.gray("no uncaptured fix-shaped lessons found in .doctrina/changes/archive/"));
67
+ console.log(c.gray("(skills are written by humans; this only surfaces candidates — `doctrina skill new <slug>` to author one)"));
68
+ return 0;
69
+ }
70
+
71
+ if (!doWrite) {
72
+ console.log(c.bold("Skill candidates") + c.gray(" — fix-shaped lessons not yet captured:"));
73
+ console.log("");
74
+ for (const cand of candidates) {
75
+ console.log(` ${c.cyan(cand.slug.padEnd(28))} ${c.gray("from " + cand.archive)}`);
76
+ if (cand.why) console.log(` ${c.gray(cand.why.slice(0, 90))}`);
77
+ }
78
+ console.log("");
79
+ console.log(c.gray(`${candidates.length} candidate${candidates.length === 1 ? "" : "s"}. Scaffold them: `) + c.cyan("doctrina skill suggest --write"));
80
+ return 0;
81
+ }
82
+
83
+ // --write: scaffold a stub per candidate, pre-seeded from the change.
84
+ const templatesDir = locateTemplatesDir();
85
+ const tplPath = path.join(templatesDir, "skill.md.template");
86
+ const tpl = read(tplPath);
87
+ mkdirp(skillsDir);
88
+ const index = idx.load(projectRoot);
89
+ const date = today();
90
+ let created = 0;
91
+ for (const cand of candidates) {
92
+ const target = path.join(skillsDir, `${cand.slug}.md`);
93
+ if (exists(target)) {
94
+ console.log(c.yellow("skip ") + ` ${cand.slug} (already exists)`);
95
+ continue;
96
+ }
97
+ let body = substitute(tpl, { SKILL_NAME: cand.slug });
98
+ if (cand.why) {
99
+ // Drop the originating lesson into the body so authoring is "fill", not "start".
100
+ body += `\n<!-- Candidate from change "${cand.id}". The lesson to capture:\n${cand.why}\n-->\n`;
101
+ }
102
+ write(target, body, { force: false });
103
+ idx.addSkill(index, {
104
+ id: cand.slug,
105
+ path: `.doctrina/skills/${cand.slug}.md`,
106
+ description: parseFrontmatter(body, "description") ?? "<edit me — one-sentence summary>",
107
+ last_updated: date,
108
+ });
109
+ console.log(c.green("created") + ` ${relPath(projectRoot, target)}`);
110
+ created += 1;
111
+ }
112
+ if (created > 0) {
113
+ idx.touch(index, date);
114
+ idx.save(projectRoot, index);
115
+ }
116
+ console.log("");
117
+ console.log(c.green("ok") + ` scaffolded ${created} skill stub${created === 1 ? "" : "s"} — fill the description/when/body, then \`doctrina skill sync\``);
118
+ return 0;
119
+ }
120
+
121
+ function skillSlug(id) {
122
+ // Reuse the change id as the skill slug, trimmed to a sane length.
123
+ const slug = id.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
124
+ return slug.length > 40 ? slug.slice(0, 40).replace(/-+$/g, "") : (slug || "lesson");
125
+ }
126
+
127
+ function firstWhyLine(proposalText) {
128
+ const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
129
+ if (!m) return "";
130
+ const line = m[1].trim();
131
+ return /^<!--/.test(line) ? "" : line;
132
+ }
133
+
28
134
  function skillNew(args, flags) {
29
135
  const name = args[0];
30
136
  if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) {
@@ -176,6 +282,10 @@ Subcommands:
176
282
  sync Copy each skill's frontmatter description into
177
283
  .doctrina/index.json so the index never drifts
178
284
  from the frontmatter (single source of truth).
285
+ suggest [--write]
286
+ Surface fix-shaped lessons in the change archive whose
287
+ skill is not yet captured. --write scaffolds a stub per
288
+ candidate (pre-seeded from the change) to fill in.
179
289
 
180
290
  Skills are written by humans, not generated. See docs/en/skills.md
181
291
  for the design rationale and the distinction from specs / AGENTS.md /
@@ -3,14 +3,15 @@ import process from "node:process";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { exists, isDir, isFile, lineCount, read, relPath, write } from "../lib/fs-ops.js";
5
5
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
6
- import { specHeader } from "../lib/scan.js";
6
+ import { specHeader, deriveIndex } from "../lib/scan.js";
7
+ import { setHeader, bumpVersion, setCriterionMark } from "../lib/spec-ops.js";
7
8
  import * as idx from "../lib/index-json.js";
8
9
  import { today } from "../lib/dates.js";
9
- import { flagBool } from "../lib/args.js";
10
+ import { flagBool, flagString } from "../lib/args.js";
10
11
  import { c } from "../lib/colors.js";
11
12
  import { suggest } from "../lib/suggest.js";
12
13
 
13
- const SUBCOMMANDS = ["new", "list"];
14
+ const SUBCOMMANDS = ["new", "list", "set"];
14
15
 
15
16
  export async function run(positional, flags) {
16
17
  const sub = positional[0];
@@ -23,6 +24,7 @@ export async function run(positional, flags) {
23
24
  return 2;
24
25
  }
25
26
  if (sub === "list") return specList();
27
+ if (sub === "set") return specSet(positional.slice(1), flags);
26
28
 
27
29
  const capability = positional[1];
28
30
  if (!capability || !/^[a-z][a-z0-9-]*$/.test(capability)) {
@@ -109,6 +111,82 @@ function specList() {
109
111
  return 0;
110
112
  }
111
113
 
114
+ // Edit a spec's bookkeeping headers (and a criterion mark) and resync the
115
+ // index in one step — the inverse drift footgun the review hit (G8): a manual
116
+ // "bump the version in the spec, then bump it again in index.json" lockstep.
117
+ // Reuses the same structured ops as `change apply` (ADR 0007), so the header
118
+ // and the index can never diverge after a `spec set`.
119
+ function specSet(args, flags) {
120
+ const capability = args[0];
121
+ if (!capability || !/^[a-z][a-z0-9-]*$/.test(capability)) {
122
+ console.error(c.red("error:") + " spec set requires a <capability> (lowercase letters, digits, hyphens)");
123
+ return 2;
124
+ }
125
+ const projectRoot = process.cwd();
126
+ ensureDoctrinaProject(projectRoot);
127
+
128
+ const specPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
129
+ if (!isFile(specPath)) {
130
+ console.error(c.red("error:") + ` no spec at ${relPath(projectRoot, specPath)} (create it with \`doctrina spec new ${capability}\`)`);
131
+ return 1;
132
+ }
133
+
134
+ const impl = flagString(flags, "implementation");
135
+ const status = flagString(flags, "status");
136
+ const bump = flagString(flags, "bump");
137
+ const criterion = flagString(flags, "criterion");
138
+ if (impl === undefined && status === undefined && bump === undefined && criterion === undefined) {
139
+ console.error(c.red("error:") + " spec set needs at least one of --implementation, --status, --bump, --criterion");
140
+ console.error(c.gray("hint: ") + `example: doctrina spec set ${capability} --implementation "verified — \`src/x.js\`" --bump minor`);
141
+ return 2;
142
+ }
143
+
144
+ let text = read(specPath);
145
+ const applied = [];
146
+ const errors = [];
147
+ const apply = (res) => {
148
+ if (res.error) errors.push(res.error);
149
+ else { text = res.text; applied.push(res.summary); }
150
+ };
151
+ // Order: headers, then version, then criterion — independent edits, all or
152
+ // none (a failed op leaves the spec untouched, like `change apply`).
153
+ if (status !== undefined) apply(setHeader(text, "Status", status));
154
+ if (impl !== undefined) apply(setHeader(text, "Implementation", impl));
155
+ if (bump !== undefined) {
156
+ if (!["major", "minor", "patch"].includes(bump)) errors.push(`--bump needs major|minor|patch (got "${bump}")`);
157
+ else apply(bumpVersion(text, bump));
158
+ }
159
+ if (criterion !== undefined) {
160
+ const m = String(criterion).match(/^(\d+)\s*:\s*(.+)$/);
161
+ if (!m) errors.push(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
162
+ else apply(setCriterionMark(text, Number(m[1]), m[2].trim()));
163
+ }
164
+
165
+ if (errors.length > 0) {
166
+ console.error(c.red("error:") + ` ${errors.length} operation error${errors.length === 1 ? "" : "s"} — spec left untouched:`);
167
+ for (const e of errors) console.error(` - ${e}`);
168
+ return 1;
169
+ }
170
+
171
+ // Stamp Last updated when the header exists (best-effort, never an error).
172
+ const date = today();
173
+ const lu = setHeader(text, "Last updated", date);
174
+ if (!lu.error) text = lu.text;
175
+
176
+ write(specPath, text, { force: true });
177
+ console.log(c.green("updated") + ` ${relPath(projectRoot, specPath)}`);
178
+ for (const s of applied) console.log(c.gray(` · ${s}`));
179
+
180
+ // Single source of truth: regenerate the index from the now-updated tree so
181
+ // the spec headers and the index can never drift (no manual lockstep — G8).
182
+ const index = idx.load(projectRoot);
183
+ const derived = deriveIndex(projectRoot, index);
184
+ derived.last_updated = date;
185
+ idx.save(projectRoot, derived);
186
+ console.log(c.green("indexed") + ` spec "${capability}" synced`);
187
+ return 0;
188
+ }
189
+
112
190
  function ensureDoctrinaProject(projectRoot) {
113
191
  if (!exists(path.join(projectRoot, ".doctrina"))) {
114
192
  throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
@@ -125,9 +203,17 @@ Subcommands:
125
203
  list One line per spec: id, version,
126
204
  status, line count, last updated.
127
205
  Read-only.
206
+ set <capability> [opts] Edit a spec's headers / a criterion
207
+ mark and resync the index in one step
208
+ (no manual lockstep bump — G8).
128
209
 
129
210
  Options:
130
- --bug Scaffold the bug-shape template (current / expected /
131
- unchanged behaviour) instead of the EARS capability spec.
132
- --force Overwrite an existing spec.
211
+ --bug Scaffold the bug-shape template (current / expected /
212
+ unchanged behaviour) instead of the EARS capability spec.
213
+ --force Overwrite an existing spec (with \`new\`).
214
+ --implementation "..." With \`set\`: set the Implementation header.
215
+ --status "..." With \`set\`: set the Status header.
216
+ --bump major|minor|patch With \`set\`: bump the spec Version.
217
+ --criterion "<n>:<mark>" With \`set\`: set criterion n's [mark]
218
+ (e.g. --criterion "2:verified").
133
219
  `;