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 +1 -1
- package/package.json +1 -1
- package/src/commands/close.js +104 -0
- package/src/commands/coverage.js +27 -0
- package/src/commands/hooks.js +9 -3
- package/src/commands/next.js +56 -3
- package/src/commands/review.js +177 -0
- package/src/commands/skill.js +112 -2
- package/src/commands/status.js +178 -0
- package/src/commands/trace.js +31 -0
- package/src/commands/validate.js +21 -0
- package/src/commands/verify.js +83 -12
- package/src/commands/watch.js +97 -0
- package/src/commands/why.js +182 -0
- package/src/commands/work.js +9 -0
- package/src/index.js +13 -1
- package/templates/AGENTS.md.template +49 -22
- package/templates/hooks/pre-commit.sample +21 -4
- package/templates/spec.md.template +7 -3
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.
|
|
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
|
@@ -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
|
+
`;
|
package/src/commands/coverage.js
CHANGED
|
@@ -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.
|
package/src/commands/hooks.js
CHANGED
|
@@ -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
|
|
55
|
-
|
|
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
|
package/src/commands/next.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
+
`;
|
package/src/commands/skill.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import {
|
|
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 /
|