doctrina-cli 0.6.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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > CLI for the Doctrina framework. The package is published as
4
4
  > `doctrina-cli`; the executable it installs is `doctrina`.
5
5
 
6
- **Status:** v0.4.0 — current release. Zero runtime dependencies
6
+ **Status:** v0.6.0 — current release. Zero runtime dependencies
7
7
  (Node.js standard library only).
8
8
 
9
9
  ## Install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -0,0 +1,104 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { exists, isFile } from "../lib/fs-ops.js";
4
+ import { flagBool } from "../lib/args.js";
5
+ import { c } from "../lib/colors.js";
6
+ import * as analyze from "./analyze.js";
7
+ import * as change from "./change.js";
8
+ import * as verify from "./verify.js";
9
+ import * as coverage from "./coverage.js";
10
+ import * as trace from "./trace.js";
11
+ import * as validate from "./validate.js";
12
+
13
+ // One-command close (review 2026-06-27 passive-user feature #2). The work
14
+ // playbook lists the closing sequence — analyze → apply → verify → coverage →
15
+ // trace → archive → validate — and the agent runs it step by step, which is
16
+ // exactly where a gate gets skipped. `close` runs the whole sequence in one
17
+ // pass, in-process, stopping at the first failure with the exact command to
18
+ // rerun, so the agent makes one call and the human approves once. It is a
19
+ // driver over the existing commands; it adds no new checks of its own.
20
+
21
+ export async function run(positional, flags) {
22
+ const id = positional[0];
23
+ if (!id) {
24
+ console.error(c.red("error:") + " close requires a change <id> (e.g. doctrina close 0001-add-login)");
25
+ return 2;
26
+ }
27
+ const projectRoot = process.cwd();
28
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
29
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
30
+ }
31
+
32
+ const force = flagBool(flags, "force", false);
33
+ const strict = new Map([["strict", true]]);
34
+ const archiveFlags = force ? new Map([["force", true]]) : new Map();
35
+
36
+ // Each step: a label, the command that runs it, and the literal command to
37
+ // rerun on failure. verify is conditional (skipped, with a loud note, when no
38
+ // verify.json is declared — a project may not have wired the real gate yet).
39
+ const verifyConfigured = isFile(path.join(projectRoot, ".doctrina", "verify.json"));
40
+ const steps = [
41
+ { label: "analyze", rerun: `doctrina analyze ${id}`, run: () => analyze.run([id], new Map()) },
42
+ { label: "apply", rerun: `doctrina change apply ${id}`, run: () => change.run(["apply", id], new Map()) },
43
+ verifyConfigured
44
+ ? { label: "verify", rerun: "doctrina verify", run: () => verify.run([], new Map()) }
45
+ : { label: "verify", skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
46
+ { label: "coverage", rerun: "doctrina coverage --strict", run: () => coverage.run([], strict) },
47
+ // trace is advisory (provenance is a warning, not a hard gate): report it,
48
+ // never let it block the close.
49
+ { label: "trace", rerun: "doctrina trace", run: async () => { await trace.run([], new Map()); return 0; } },
50
+ { label: "archive", rerun: `doctrina change archive ${id}${force ? " --force" : ""}`, run: () => change.run(["archive", id], archiveFlags) },
51
+ { label: "validate", rerun: "doctrina validate", run: () => validate.run([], new Map()) },
52
+ ];
53
+
54
+ console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → apply → verify → coverage → trace → archive → validate"));
55
+
56
+ for (let i = 0; i < steps.length; i++) {
57
+ const step = steps[i];
58
+ console.log("");
59
+ console.log(c.gray(`──── ${i + 1}/${steps.length} ${step.label}`));
60
+ if (step.skip) {
61
+ console.log(c.yellow("skip ") + step.skip);
62
+ continue;
63
+ }
64
+ let code;
65
+ try {
66
+ code = await step.run();
67
+ } catch (err) {
68
+ code = 1;
69
+ console.error(c.red("error:") + ` ${err.message}`);
70
+ }
71
+ if (code !== 0) {
72
+ console.log("");
73
+ console.log(c.red(`✗ close stopped at "${step.label}"`) + c.gray(` (step ${i + 1}/${steps.length})`));
74
+ console.log(c.gray("Fix it, then rerun the step or the whole close:"));
75
+ console.log(` ${c.cyan(step.rerun)}`);
76
+ console.log(` ${c.cyan(`doctrina close ${id}`)}`);
77
+ return 1;
78
+ }
79
+ }
80
+
81
+ console.log("");
82
+ console.log(c.green(`✓ change ${id} closed`) + c.gray(" — verified, archived, and validated."));
83
+ console.log(c.gray("Next: ") + c.cyan("doctrina next"));
84
+ return 0;
85
+ }
86
+
87
+ export const help = `
88
+ Usage: doctrina close <id> [--force]
89
+
90
+ Run the whole closing sequence for a change in one pass, stopping at the
91
+ first failure with the exact command to rerun:
92
+
93
+ analyze → change apply → verify → coverage --strict → trace → change
94
+ archive → validate
95
+
96
+ verify is skipped (with a note) when no .doctrina/verify.json is declared;
97
+ trace is advisory (a report, never a blocker). This is a driver over the
98
+ existing commands — it adds no checks of its own — so the agent makes one
99
+ call instead of seven and the human approves once.
100
+
101
+ Options:
102
+ --force Pass through to \`change archive\` (archive even if verification
103
+ is incomplete; records the gap).
104
+ `;
@@ -97,6 +97,33 @@ export async function run(_positional, flags) {
97
97
  return strict ? 1 : 0;
98
98
  }
99
99
 
100
+ // Pure summary of coverage across the spec tree, for other commands
101
+ // (`status`, `review`) that need the numbers without the report output.
102
+ export function summarize(projectRoot) {
103
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
104
+ let totalCriteria = 0, totalCovered = 0, totalDangling = 0, totalConditional = 0;
105
+ const perCap = [];
106
+ if (isDir(specsDir)) {
107
+ for (const cap of readdirSync(specsDir).sort()) {
108
+ const specPath = path.join(specsDir, cap, "spec.md");
109
+ if (!isFile(specPath)) continue;
110
+ const criteria = extractAcceptanceCriteria(read(specPath));
111
+ if (criteria.length === 0) continue;
112
+ const rows = criteria.map((crit, i) => classify(crit, i + 1, projectRoot, path.dirname(specPath)));
113
+ const covered = rows.filter((r) => r.kind === "covered").length;
114
+ const dangling = rows.filter((r) => r.kind === "dangling").length;
115
+ const conditional = rows.filter((r) => r.kind === "conditional").length;
116
+ totalCriteria += rows.length;
117
+ totalCovered += covered;
118
+ totalDangling += dangling;
119
+ totalConditional += conditional;
120
+ perCap.push({ cap, total: rows.length, covered, dangling, conditional });
121
+ }
122
+ }
123
+ const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
124
+ return { perCap, totalCriteria, totalCovered, totalDangling, totalConditional, pct };
125
+ }
126
+
100
127
  // Pull the numbered items out of the "## Acceptance criteria" section.
101
128
  // Each item may span multiple lines (continuation prose); accumulate until
102
129
  // the next number or the next "## " heading. Returns an array of strings.
@@ -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 /