doctrina-cli 0.6.0 → 0.9.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 +208 -3
- 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/src/commands/skill.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import {
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
|
|
4
6
|
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
5
7
|
import * as idx from "../lib/index-json.js";
|
|
6
8
|
import { today } from "../lib/dates.js";
|
|
7
|
-
import { flagBool } from "../lib/args.js";
|
|
9
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
8
10
|
import { c } from "../lib/colors.js";
|
|
9
11
|
import { suggest } from "../lib/suggest.js";
|
|
10
12
|
|
|
11
|
-
const SUBCOMMANDS = ["new", "list", "sync"];
|
|
13
|
+
const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
|
|
12
14
|
|
|
13
15
|
export async function run(positional, flags) {
|
|
14
16
|
const sub = positional[0];
|
|
@@ -22,9 +24,205 @@ export async function run(positional, flags) {
|
|
|
22
24
|
}
|
|
23
25
|
if (sub === "new") return skillNew(positional.slice(1), flags);
|
|
24
26
|
if (sub === "sync") return skillSync();
|
|
27
|
+
if (sub === "suggest") return skillSuggest(positional.slice(1), flags);
|
|
25
28
|
return skillList();
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
// Surface (and optionally scaffold) skills worth capturing (review 2026-06-27
|
|
32
|
+
// passive-user feature #6). A skill exists so a hard-won lesson is not relearned
|
|
33
|
+
// by the next agent/session; the framework otherwise never pulls you toward
|
|
34
|
+
// writing one. `skill suggest` scans for fix-shaped work — the textbook case for
|
|
35
|
+
// a skill — from two deterministic sources and lists candidates whose lesson is
|
|
36
|
+
// not yet captured: archived change proposals, and the git commit history
|
|
37
|
+
// (ADR 0013). The archive only fills if a team runs the `doctrina change`
|
|
38
|
+
// lifecycle, so without the git source `suggest` is inert in most repos. With
|
|
39
|
+
// --write it scaffolds a stub per candidate, pre-seeded from the source so the
|
|
40
|
+
// human/agent only fills the body. Both sources are a pattern match — a hint,
|
|
41
|
+
// never a decision (ADR 0005); skills are still authored, never generated.
|
|
42
|
+
const FIX_SHAPED = /(?:^|-)(fix|bug|hotfix|patch|parse|parsing|tolerate|workaround|race|deadlock|flaky|retry|escape|sanitize|sanitise)(?:-|$)/;
|
|
43
|
+
|
|
44
|
+
// The same idea applied to a commit subject. Narrower than FIX_SHAPED on
|
|
45
|
+
// purpose: a deliberate folder slug tolerates more noise than a free-form
|
|
46
|
+
// subject line. Matches conventional fix-type prefixes (fix:, fix(scope):,
|
|
47
|
+
// bug:, hotfix:, patch:, and the free-form "Fix the…") plus the strongly
|
|
48
|
+
// fix-flavoured debugging keywords — never feat:/refactor:/docs:/chore:.
|
|
49
|
+
const FIX_SHAPED_SUBJECT =
|
|
50
|
+
/^(?:fix|bug|hotfix|patch)\b|\b(?:tolerate|workaround|deadlock|flaky|race condition|retry|sanitiz|sanitis)/i;
|
|
51
|
+
|
|
52
|
+
// How far back the git source looks when --since is not given, and how many
|
|
53
|
+
// candidates we print before collapsing the rest into a "+N more" line.
|
|
54
|
+
const GIT_LOG_LIMIT = 200;
|
|
55
|
+
const MAX_SHOWN = 20;
|
|
56
|
+
|
|
57
|
+
function skillSuggest(args, flags) {
|
|
58
|
+
const projectRoot = process.cwd();
|
|
59
|
+
ensureDoctrinaProject(projectRoot);
|
|
60
|
+
const doWrite = flagBool(flags, "write", false);
|
|
61
|
+
const since = flagString(flags, "since");
|
|
62
|
+
|
|
63
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
64
|
+
const existing = new Set(
|
|
65
|
+
isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")).map((f) => path.basename(f, ".md")) : [],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Two deterministic sources, deduped by slug. `seen` guards against a lesson
|
|
69
|
+
// appearing twice (e.g. a fix committed and later archived as a change).
|
|
70
|
+
const candidates = [];
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
|
|
73
|
+
// Source 1 — archived change proposals with a fix-shaped folder name.
|
|
74
|
+
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
75
|
+
if (isDir(archiveDir)) {
|
|
76
|
+
for (const name of readdirSync(archiveDir).sort()) {
|
|
77
|
+
if (!isDir(path.join(archiveDir, name))) continue;
|
|
78
|
+
const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
79
|
+
if (!FIX_SHAPED.test(id)) continue;
|
|
80
|
+
const slug = skillSlug(id);
|
|
81
|
+
if (existing.has(slug) || seen.has(slug)) continue; // captured or already queued
|
|
82
|
+
const proposal = path.join(archiveDir, name, "proposal.md");
|
|
83
|
+
const why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
|
|
84
|
+
seen.add(slug);
|
|
85
|
+
candidates.push({ id, slug, why, source: "change", from: name });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
|
|
90
|
+
// slug collision: a `## Why` paragraph is a richer seed than a subject line.
|
|
91
|
+
for (const cand of gitFixCommits(projectRoot, { since, limit: GIT_LOG_LIMIT })) {
|
|
92
|
+
if (existing.has(cand.slug) || seen.has(cand.slug)) continue;
|
|
93
|
+
seen.add(cand.slug);
|
|
94
|
+
candidates.push(cand);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (candidates.length === 0) {
|
|
98
|
+
console.log(c.gray("no uncaptured fix-shaped lessons found in .doctrina/changes/archive/ or the git history"));
|
|
99
|
+
console.log(c.gray("(scans archived changes and fix-shaped commits — pass --since <ref> to widen the git window;"));
|
|
100
|
+
console.log(c.gray(" skills are written by humans, so this only surfaces candidates — `doctrina skill new <slug>` to author one)"));
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!doWrite) {
|
|
105
|
+
console.log(c.bold("Skill candidates") + c.gray(" — fix-shaped lessons not yet captured:"));
|
|
106
|
+
console.log("");
|
|
107
|
+
for (const cand of candidates.slice(0, MAX_SHOWN)) {
|
|
108
|
+
console.log(` ${c.cyan(cand.slug.padEnd(28))} ${c.gray(provenance(cand))}`);
|
|
109
|
+
if (cand.why) console.log(` ${c.gray(cand.why.slice(0, 90))}`);
|
|
110
|
+
}
|
|
111
|
+
const hidden = candidates.length - MAX_SHOWN;
|
|
112
|
+
if (hidden > 0) console.log(c.gray(` … +${hidden} more`));
|
|
113
|
+
console.log("");
|
|
114
|
+
console.log(c.gray(`${candidates.length} candidate${candidates.length === 1 ? "" : "s"}. Scaffold them: `) + c.cyan("doctrina skill suggest --write"));
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --write: scaffold a stub per candidate, pre-seeded from its source.
|
|
119
|
+
const templatesDir = locateTemplatesDir();
|
|
120
|
+
const tplPath = path.join(templatesDir, "skill.md.template");
|
|
121
|
+
const tpl = read(tplPath);
|
|
122
|
+
mkdirp(skillsDir);
|
|
123
|
+
const index = idx.load(projectRoot);
|
|
124
|
+
const date = today();
|
|
125
|
+
let created = 0;
|
|
126
|
+
for (const cand of candidates) {
|
|
127
|
+
const target = path.join(skillsDir, `${cand.slug}.md`);
|
|
128
|
+
if (exists(target)) {
|
|
129
|
+
console.log(c.yellow("skip ") + ` ${cand.slug} (already exists)`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
let body = substitute(tpl, { SKILL_NAME: cand.slug });
|
|
133
|
+
if (cand.why) {
|
|
134
|
+
// Drop the originating lesson into the body so authoring is "fill", not "start".
|
|
135
|
+
body += `\n<!-- ${seedLabel(cand)}. The lesson to capture:\n${cand.why}\n-->\n`;
|
|
136
|
+
}
|
|
137
|
+
write(target, body, { force: false });
|
|
138
|
+
idx.addSkill(index, {
|
|
139
|
+
id: cand.slug,
|
|
140
|
+
path: `.doctrina/skills/${cand.slug}.md`,
|
|
141
|
+
description: parseFrontmatter(body, "description") ?? "<edit me — one-sentence summary>",
|
|
142
|
+
last_updated: date,
|
|
143
|
+
});
|
|
144
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, target)}`);
|
|
145
|
+
created += 1;
|
|
146
|
+
}
|
|
147
|
+
if (created > 0) {
|
|
148
|
+
idx.touch(index, date);
|
|
149
|
+
idx.save(projectRoot, index);
|
|
150
|
+
}
|
|
151
|
+
console.log("");
|
|
152
|
+
console.log(c.green("ok") + ` scaffolded ${created} skill stub${created === 1 ? "" : "s"} — fill the description/when/body, then \`doctrina skill sync\``);
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// One-line origin shown next to a candidate slug.
|
|
157
|
+
function provenance(cand) {
|
|
158
|
+
return cand.source === "commit" ? `from commit ${cand.ref}` : `from ${cand.from}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The label seeded into a scaffolded skill body so the author knows where the
|
|
162
|
+
// lesson came from.
|
|
163
|
+
function seedLabel(cand) {
|
|
164
|
+
return cand.source === "commit"
|
|
165
|
+
? `Candidate from commit ${cand.ref}`
|
|
166
|
+
: `Candidate from change "${cand.id}"`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Source 2 for `skill suggest`: fix-shaped commits in the git history (ADR
|
|
170
|
+
// 0013). Deterministic — a regex on the subject, never a read of the diff
|
|
171
|
+
// (ADR 0005 semantic-fidelity ceiling). Returns [] silently when this is not a
|
|
172
|
+
// git repo, git is absent, or the --since ref is unknown, so the command
|
|
173
|
+
// degrades to archive-only rather than erroring.
|
|
174
|
+
function gitFixCommits(projectRoot, { since, limit }) {
|
|
175
|
+
// US (0x1f) between fields, RS (0x1e) between records — robust against any
|
|
176
|
+
// newline or punctuation inside a subject or body.
|
|
177
|
+
const format = "%H%x1f%s%x1f%b%x1e";
|
|
178
|
+
const args = ["log", "--no-merges", `--format=${format}`];
|
|
179
|
+
if (since) args.push(`${since}..HEAD`);
|
|
180
|
+
else args.push("-n", String(limit));
|
|
181
|
+
|
|
182
|
+
const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
|
|
183
|
+
if (r.error || r.status !== 0 || !r.stdout) return [];
|
|
184
|
+
|
|
185
|
+
const out = [];
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
for (const record of r.stdout.split("\x1e")) {
|
|
188
|
+
const [sha, subject = "", body = ""] = record.replace(/^\r?\n/, "").split("\x1f");
|
|
189
|
+
if (!sha || !sha.trim()) continue;
|
|
190
|
+
if (!FIX_SHAPED_SUBJECT.test(subject)) continue;
|
|
191
|
+
const slug = commitSlug(subject);
|
|
192
|
+
if (!slug || seen.has(slug)) continue; // first commit wins a duplicate lesson
|
|
193
|
+
seen.add(slug);
|
|
194
|
+
const firstBodyLine = body.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
|
|
195
|
+
out.push({
|
|
196
|
+
id: slug,
|
|
197
|
+
slug,
|
|
198
|
+
source: "commit",
|
|
199
|
+
ref: sha.trim().slice(0, 9),
|
|
200
|
+
why: subject.trim() + (firstBodyLine ? ` — ${firstBodyLine}` : ""),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Derive a skill slug from a commit subject: drop a conventional-commit
|
|
207
|
+
// "type(scope): " prefix, then reuse skillSlug for the lowercase/hyphenate/cap.
|
|
208
|
+
function commitSlug(subject) {
|
|
209
|
+
const stripped = subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, "");
|
|
210
|
+
return skillSlug(stripped.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function skillSlug(id) {
|
|
214
|
+
// Reuse the change id as the skill slug, trimmed to a sane length.
|
|
215
|
+
const slug = id.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
216
|
+
return slug.length > 40 ? slug.slice(0, 40).replace(/-+$/g, "") : (slug || "lesson");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function firstWhyLine(proposalText) {
|
|
220
|
+
const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
|
|
221
|
+
if (!m) return "";
|
|
222
|
+
const line = m[1].trim();
|
|
223
|
+
return /^<!--/.test(line) ? "" : line;
|
|
224
|
+
}
|
|
225
|
+
|
|
28
226
|
function skillNew(args, flags) {
|
|
29
227
|
const name = args[0];
|
|
30
228
|
if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
@@ -176,6 +374,13 @@ Subcommands:
|
|
|
176
374
|
sync Copy each skill's frontmatter description into
|
|
177
375
|
.doctrina/index.json so the index never drifts
|
|
178
376
|
from the frontmatter (single source of truth).
|
|
377
|
+
suggest [--write] [--since <ref>]
|
|
378
|
+
Surface fix-shaped lessons not yet captured as skills,
|
|
379
|
+
from two sources: archived change proposals and the git
|
|
380
|
+
commit history (ADR 0013). --write scaffolds a stub per
|
|
381
|
+
candidate (pre-seeded from its source) to fill in.
|
|
382
|
+
--since <ref> scans commits in <ref>..HEAD instead of the
|
|
383
|
+
last 200 (e.g. --since v0.7.0).
|
|
179
384
|
|
|
180
385
|
Skills are written by humans, not generated. See docs/en/skills.md
|
|
181
386
|
for the design rationale and the distinction from specs / AGENTS.md /
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read, walk } from "../lib/fs-ops.js";
|
|
5
|
+
import * as idx from "../lib/index-json.js";
|
|
6
|
+
import { deriveIndex, indexesMatch, specHeader, listHeader } from "../lib/scan.js";
|
|
7
|
+
import { cliVersion } from "../lib/version.js";
|
|
8
|
+
import { summarize as coverageSummary } from "./coverage.js";
|
|
9
|
+
import { summarize as traceSummary } from "./trace.js";
|
|
10
|
+
import { c } from "../lib/colors.js";
|
|
11
|
+
|
|
12
|
+
// One-glance project health (review 2026-06-27 passive-user feature #1): a
|
|
13
|
+
// dashboard that answers "where do things stand?" in a single read, so neither
|
|
14
|
+
// the human nor the agent has to run validate + coverage + trace + next
|
|
15
|
+
// separately. Strictly read-only; never mutates and always exits 0. It reports
|
|
16
|
+
// the gate signals it can compute cheaply (index drift, the framework stamp,
|
|
17
|
+
// coverage/trace ratios, whether verify is configured) and the artifact counts;
|
|
18
|
+
// `doctrina validate` / `verify` remain the full, authoritative gates.
|
|
19
|
+
|
|
20
|
+
const CONFIG_REL = ".doctrina/verify.json";
|
|
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
|
+
|
|
28
|
+
let index = null;
|
|
29
|
+
try {
|
|
30
|
+
index = idx.load(projectRoot);
|
|
31
|
+
} catch {
|
|
32
|
+
index = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const project = index?.project ?? path.basename(projectRoot);
|
|
36
|
+
const stamp = index?.framework_version ?? "—";
|
|
37
|
+
const cli = cliVersion();
|
|
38
|
+
console.log(c.bold("Doctrina status") + c.gray(` — ${project} (framework ${stamp} / CLI ${cli})`));
|
|
39
|
+
console.log("");
|
|
40
|
+
|
|
41
|
+
// --- Gates ---
|
|
42
|
+
console.log(c.bold(" Gates"));
|
|
43
|
+
// Index drift (the silent rot validate now errors on).
|
|
44
|
+
let driftLine;
|
|
45
|
+
if (!index) {
|
|
46
|
+
driftLine = c.red("missing/unreadable") + c.gray(" — run `doctrina index rebuild`");
|
|
47
|
+
} else if (indexesMatch(deriveIndex(projectRoot, index), index)) {
|
|
48
|
+
driftLine = c.green("in sync");
|
|
49
|
+
} else {
|
|
50
|
+
driftLine = c.yellow("drifted") + c.gray(" — run `doctrina validate --fix`");
|
|
51
|
+
}
|
|
52
|
+
console.log(` ${"index".padEnd(11)} ${driftLine}`);
|
|
53
|
+
|
|
54
|
+
// Framework stamp divergence.
|
|
55
|
+
const stampLine = stamp === cli
|
|
56
|
+
? c.green("current")
|
|
57
|
+
: c.yellow(`${stamp} (CLI ${cli})`) + c.gray(" — `doctrina index rebuild`");
|
|
58
|
+
console.log(` ${"stamp".padEnd(11)} ${stampLine}`);
|
|
59
|
+
|
|
60
|
+
// Coverage and trace ratios.
|
|
61
|
+
const cov = coverageSummary(projectRoot);
|
|
62
|
+
const covExtra = cov.totalDangling + cov.totalConditional > 0
|
|
63
|
+
? c.yellow(` ${cov.totalDangling} dangling, ${cov.totalConditional} conditional`)
|
|
64
|
+
: "";
|
|
65
|
+
const covColor = cov.pct === 100 ? c.green : cov.pct >= 50 ? c.yellow : c.red;
|
|
66
|
+
console.log(` ${"coverage".padEnd(11)} ${covColor(`${cov.pct}%`)} ${c.gray(`(${cov.totalCovered}/${cov.totalCriteria} criteria)`)}${covExtra}`);
|
|
67
|
+
|
|
68
|
+
const tr = traceSummary(projectRoot);
|
|
69
|
+
const trExtra = tr.untraceable + tr.dropped + tr.dangling > 0
|
|
70
|
+
? c.yellow(` ${tr.dropped} dropped, ${tr.untraceable} untraceable`)
|
|
71
|
+
: "";
|
|
72
|
+
const trColor = tr.anchors === 0 ? c.gray : (tr.realized === tr.anchors && tr.untraceable === 0 ? c.green : c.yellow);
|
|
73
|
+
const trText = tr.anchors === 0 ? "no anchors" : `${tr.realized}/${tr.anchors} anchors`;
|
|
74
|
+
console.log(` ${"trace".padEnd(11)} ${trColor(trText)}${trExtra}`);
|
|
75
|
+
|
|
76
|
+
// Verify config presence (running it is the slow, authoritative gate).
|
|
77
|
+
const verifyCfg = path.join(projectRoot, CONFIG_REL);
|
|
78
|
+
let verifyLine = c.gray("not configured") + c.gray(" — `doctrina verify --init`");
|
|
79
|
+
if (isFile(verifyCfg)) {
|
|
80
|
+
try {
|
|
81
|
+
const cfg = JSON.parse(read(verifyCfg));
|
|
82
|
+
const n = Array.isArray(cfg?.checks) ? cfg.checks.length : 0;
|
|
83
|
+
verifyLine = c.cyan(`${n} check${n === 1 ? "" : "s"}`) + c.gray(" — run `doctrina verify`");
|
|
84
|
+
} catch {
|
|
85
|
+
verifyLine = c.red("invalid JSON");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
console.log(` ${"verify".padEnd(11)} ${verifyLine}`);
|
|
89
|
+
|
|
90
|
+
// --- Work ---
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log(c.bold(" Work"));
|
|
93
|
+
const specs = countSpecs(projectRoot);
|
|
94
|
+
const implBreak = Object.entries(specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
|
|
95
|
+
console.log(` ${"specs".padEnd(11)} ${specs.total}${implBreak ? c.gray(` (${implBreak})`) : ""}`);
|
|
96
|
+
console.log(` ${"changes".padEnd(11)} ${specs.openChanges} open`);
|
|
97
|
+
const adr = countDecisions(projectRoot);
|
|
98
|
+
const adrNotes = [];
|
|
99
|
+
if (adr.proposed > 0) adrNotes.push(c.yellow(`${adr.proposed} proposed`));
|
|
100
|
+
if (adr.bare > 0) adrNotes.push(c.yellow(`${adr.bare} unproven`));
|
|
101
|
+
console.log(` ${"decisions".padEnd(11)} ${adr.total}${adrNotes.length ? c.gray(" (") + adrNotes.join(c.gray(", ")) + c.gray(")") : ""}`);
|
|
102
|
+
const skills = countSkills(projectRoot);
|
|
103
|
+
console.log(` ${"skills".padEnd(11)} ${skills}`);
|
|
104
|
+
|
|
105
|
+
console.log("");
|
|
106
|
+
console.log(c.gray(" Full gates: `doctrina validate` · `doctrina verify`. Next step: `doctrina next`."));
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function countSpecs(projectRoot) {
|
|
111
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
112
|
+
const impl = {};
|
|
113
|
+
let total = 0;
|
|
114
|
+
if (isDir(specsDir)) {
|
|
115
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
116
|
+
const p = path.join(specsDir, cap, "spec.md");
|
|
117
|
+
if (!isFile(p)) continue;
|
|
118
|
+
total += 1;
|
|
119
|
+
const implRaw = specHeader(read(p), "Implementation");
|
|
120
|
+
if (implRaw) {
|
|
121
|
+
const word = implRaw.trim().split(/[\s—-]+/)[0].toLowerCase();
|
|
122
|
+
impl[word] = (impl[word] ?? 0) + 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
127
|
+
let openChanges = 0;
|
|
128
|
+
if (isDir(changesDir)) {
|
|
129
|
+
for (const e of readdirSync(changesDir)) {
|
|
130
|
+
if (e === "archive" || e.startsWith(".")) continue;
|
|
131
|
+
if (isDir(path.join(changesDir, e))) openChanges += 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { total, impl, openChanges };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function countDecisions(projectRoot) {
|
|
138
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
139
|
+
let total = 0, proposed = 0, bare = 0;
|
|
140
|
+
const isBare = (v) => {
|
|
141
|
+
const t = (v ?? "").trim();
|
|
142
|
+
return t === "" || t === "—" || t === "-";
|
|
143
|
+
};
|
|
144
|
+
if (isDir(adrDir)) {
|
|
145
|
+
for (const f of walk(adrDir)) {
|
|
146
|
+
if (!/^\d{4}-.+\.md$/.test(path.basename(f))) continue;
|
|
147
|
+
total += 1;
|
|
148
|
+
const text = read(f);
|
|
149
|
+
const status = (listHeader(text, "Status") ?? "").toLowerCase();
|
|
150
|
+
if (status === "proposed") proposed += 1;
|
|
151
|
+
if (status === "accepted") {
|
|
152
|
+
const evidence = listHeader(text, "Evidence");
|
|
153
|
+
const landed = listHeader(text, "Landed");
|
|
154
|
+
if (evidence !== null && isBare(evidence) && isBare(landed)) bare += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { total, proposed, bare };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function countSkills(projectRoot) {
|
|
162
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
163
|
+
if (!isDir(skillsDir)) return 0;
|
|
164
|
+
return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const help = `
|
|
168
|
+
Usage: doctrina status
|
|
169
|
+
|
|
170
|
+
Print a one-glance health dashboard for the .doctrina/ project: the gate
|
|
171
|
+
signals (index drift, framework stamp, coverage %, trace anchors, whether
|
|
172
|
+
verify is configured) and the artifact counts (specs by implementation
|
|
173
|
+
state, open changes, decisions, skills). Read-only; always exits 0.
|
|
174
|
+
|
|
175
|
+
It is a fast summary, not the authoritative gate: run \`doctrina validate\`
|
|
176
|
+
and \`doctrina verify\` for the full structural and build checks, and
|
|
177
|
+
\`doctrina next\` for the recommended next action.
|
|
178
|
+
`;
|
package/src/commands/trace.js
CHANGED
|
@@ -106,6 +106,37 @@ export async function run(_positional, flags) {
|
|
|
106
106
|
return strict ? 1 : 0;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// Pure summary of intent provenance, for other commands (`status`, `review`)
|
|
110
|
+
// that need the numbers without the report output.
|
|
111
|
+
export function summarize(projectRoot) {
|
|
112
|
+
const anchors = collectAnchors(projectRoot);
|
|
113
|
+
const specs = collectSpecs(projectRoot);
|
|
114
|
+
const anchorIds = new Set(anchors.map((a) => a.id));
|
|
115
|
+
const realizedBy = new Map();
|
|
116
|
+
let dangling = 0;
|
|
117
|
+
for (const s of specs) {
|
|
118
|
+
if (s.realizes === null) continue;
|
|
119
|
+
for (const id of s.realizes) {
|
|
120
|
+
if (anchorIds.has(id)) {
|
|
121
|
+
if (!realizedBy.has(id)) realizedBy.set(id, []);
|
|
122
|
+
realizedBy.get(id).push(s.cap);
|
|
123
|
+
} else {
|
|
124
|
+
dangling += 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const untraceable = specs.filter((s) => s.realizes === null && s.status === "active").length;
|
|
129
|
+
let realized = 0;
|
|
130
|
+
for (const a of anchors) if ((realizedBy.get(a.id) ?? []).length > 0) realized += 1;
|
|
131
|
+
return {
|
|
132
|
+
anchors: anchors.length,
|
|
133
|
+
realized,
|
|
134
|
+
dropped: anchors.length - realized,
|
|
135
|
+
dangling,
|
|
136
|
+
untraceable,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
109
140
|
// Every "[A-Z]+\d+" tag at the head of a bullet in product.md is an intent
|
|
110
141
|
// anchor. Section-agnostic so Success-criteria and In-scope bullets both work.
|
|
111
142
|
function collectAnchors(projectRoot) {
|
package/src/commands/validate.js
CHANGED
|
@@ -266,6 +266,27 @@ export async function run(_positional, flags) {
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
// 8e. Provenance adoption (review 2026-06-27, §5/§9). `trace` is the
|
|
270
|
+
// intent→capability link, but it stays inert when nothing forces
|
|
271
|
+
// the tag, so an active capability with no Realizes: header is an
|
|
272
|
+
// untraced promise — warn here (validate is always run) rather than
|
|
273
|
+
// only in the opt-in `trace`. Advisory, with the same escape hatch
|
|
274
|
+
// pattern as Implementation: any value silences it, including a
|
|
275
|
+
// deliberate "n/a — <why>" for an internal capability that maps to
|
|
276
|
+
// no product anchor. Bug specs (off the implementation axis) and
|
|
277
|
+
// draft/deprecated documents are exempt — only active capability
|
|
278
|
+
// specs make the promise.
|
|
279
|
+
const docStatusForRealizes = (specHeader(text, "Status") ?? "active").toLowerCase();
|
|
280
|
+
const onImplAxis = specHeader(text, "Implementation") !== null;
|
|
281
|
+
if (docStatusForRealizes === "active" && onImplAxis && specHeader(text, "Realizes") === null) {
|
|
282
|
+
warnings.push(
|
|
283
|
+
`${relPath(projectRoot, specPath)}: active spec has no Realizes: header — ` +
|
|
284
|
+
`it traces to no product intent (\`doctrina trace\`). Tag a product.md ` +
|
|
285
|
+
`success-criteria bullet "- [SC1] ..." and add "**Realizes:** SC1", or ` +
|
|
286
|
+
`record "**Realizes:** n/a — <why>" if it maps to no product anchor`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
269
290
|
// 8d. Metadata-header shape (review G11). In the header block (before
|
|
270
291
|
// the first `## ` section or `<!--` comment), a known key written
|
|
271
292
|
// without the canonical `**Key:** value` form silently fails to
|
package/src/commands/verify.js
CHANGED
|
@@ -3,7 +3,8 @@ import process from "node:process";
|
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
|
|
6
|
-
import { flagBool } from "../lib/args.js";
|
|
6
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
7
|
+
import { today } from "../lib/dates.js";
|
|
7
8
|
import { c } from "../lib/colors.js";
|
|
8
9
|
|
|
9
10
|
// The build/verify gate `validate` is not. `validate` checks the shape of
|
|
@@ -15,6 +16,7 @@ import { c } from "../lib/colors.js";
|
|
|
15
16
|
// `.doctrina/verify.json`, and `verify` runs exactly that, in order.
|
|
16
17
|
|
|
17
18
|
const CONFIG_REL = ".doctrina/verify.json";
|
|
19
|
+
const SIGNOFF_REL = ".doctrina/verify.signoffs.json";
|
|
18
20
|
|
|
19
21
|
const STARTER = {
|
|
20
22
|
$comment:
|
|
@@ -80,8 +82,11 @@ export async function run(_positional, flags) {
|
|
|
80
82
|
return 1;
|
|
81
83
|
}
|
|
82
84
|
for (const ch of checks) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
// A "manual" check is the qualitative gate (review 2026-06-27 feature #7):
|
|
86
|
+
// a DoD item judged by a human/eval, recorded as a sign-off, not run as a
|
|
87
|
+
// command — so it needs no `run`. Every other check still needs one.
|
|
88
|
+
if (!ch || (ch.type !== "manual" && (typeof ch.run !== "string" || ch.run.trim() === ""))) {
|
|
89
|
+
console.error(c.red("error:") + ` every check in ${CONFIG_REL} needs a non-empty "run" string (or "type": "manual")`);
|
|
85
90
|
return 1;
|
|
86
91
|
}
|
|
87
92
|
}
|
|
@@ -90,21 +95,69 @@ export async function run(_positional, flags) {
|
|
|
90
95
|
return 1;
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
// Sign-off store for manual checks: { "<check name>": { date, note } }.
|
|
99
|
+
const signoffPath = path.join(projectRoot, SIGNOFF_REL);
|
|
100
|
+
let signoffs = {};
|
|
101
|
+
if (isFile(signoffPath)) {
|
|
102
|
+
try { signoffs = JSON.parse(read(signoffPath)) ?? {}; } catch { signoffs = {}; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --signoff "<name>=<note>" records a manual check's human/eval sign-off
|
|
106
|
+
// (date stamped today) and exits. The name must be a declared manual check.
|
|
107
|
+
const signoffArg = flagString(flags, "signoff");
|
|
108
|
+
if (signoffArg !== undefined) {
|
|
109
|
+
const eq = String(signoffArg).indexOf("=");
|
|
110
|
+
const name = (eq >= 0 ? signoffArg.slice(0, eq) : signoffArg).trim();
|
|
111
|
+
const note = eq >= 0 ? signoffArg.slice(eq + 1).trim() : "";
|
|
112
|
+
const target = checks.find((ch) => (ch.name ?? "") === name && ch.type === "manual");
|
|
113
|
+
if (!target) {
|
|
114
|
+
console.error(c.red("error:") + ` no manual check named "${name}" in ${CONFIG_REL}`);
|
|
115
|
+
const manualNames = checks.filter((ch) => ch.type === "manual").map((ch) => ch.name);
|
|
116
|
+
console.error(c.gray("hint: ") + (manualNames.length
|
|
117
|
+
? `manual checks: ${manualNames.join(", ")}`
|
|
118
|
+
: `add one: { "name": "${name || "quality"}", "type": "manual", "rubric": "<the question>" }`));
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
signoffs[name] = { date: today(), note };
|
|
122
|
+
write(signoffPath, JSON.stringify(signoffs, null, 2) + "\n", { force: true });
|
|
123
|
+
console.log(c.green("signed off") + ` ${name} on ${today()}${note ? `: ${note}` : ""}`);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
93
127
|
// --list prints the configured checks without running them.
|
|
94
128
|
if (flagBool(flags, "list", false)) {
|
|
95
129
|
console.log(c.bold("Verify checks") + c.gray(` (from ${CONFIG_REL}):`));
|
|
96
130
|
console.log("");
|
|
97
131
|
checks.forEach((ch, i) => {
|
|
98
|
-
|
|
132
|
+
const what = ch.type === "manual" ? c.gray(`manual${ch.rubric ? " — " + ch.rubric : ""}`) : c.gray(ch.run);
|
|
133
|
+
console.log(` ${i + 1}. ${c.cyan((ch.name ?? "check").padEnd(16))} ${what}`);
|
|
99
134
|
});
|
|
100
135
|
return 0;
|
|
101
136
|
}
|
|
102
137
|
|
|
138
|
+
const strict = flagBool(flags, "strict", false);
|
|
103
139
|
console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
|
|
104
140
|
const results = [];
|
|
105
141
|
for (const ch of checks) {
|
|
106
142
|
const name = ch.name ?? "check";
|
|
107
143
|
console.log("");
|
|
144
|
+
|
|
145
|
+
// Manual (qualitative) check: pass if signed off, else pending. Pending is
|
|
146
|
+
// non-blocking by default (a report) and fails only under --strict, so CI
|
|
147
|
+
// can require the human/eval sign-off without blocking local runs.
|
|
148
|
+
if (ch.type === "manual") {
|
|
149
|
+
console.log(c.gray(`──── ${name}: `) + c.gray(`manual${ch.rubric ? " — " + ch.rubric : ""}`));
|
|
150
|
+
const so = signoffs[name];
|
|
151
|
+
if (so && so.date) {
|
|
152
|
+
console.log(c.green(`✓ ${name}`) + c.gray(` — signed off ${so.date}${so.note ? `: ${so.note}` : ""}`));
|
|
153
|
+
results.push({ name, ok: true, kind: "manual" });
|
|
154
|
+
} else {
|
|
155
|
+
console.log(c.yellow(`○ ${name} — pending sign-off`) + c.gray(` (doctrina verify --signoff "${name}=<note>")`));
|
|
156
|
+
results.push({ name, ok: false, kind: "manual", pending: true });
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
108
161
|
const where = ch.cwd ? c.gray(` [${ch.cwd}]`) : "";
|
|
109
162
|
console.log(c.gray(`──── ${name}: `) + ch.run + where);
|
|
110
163
|
// Run through the shell so declared commands can use pipes, &&, env,
|
|
@@ -125,19 +178,28 @@ export async function run(_positional, flags) {
|
|
|
125
178
|
} else {
|
|
126
179
|
console.log(ok ? c.green(`✓ ${name}`) : c.red(`✗ ${name} (exit ${res.status})`));
|
|
127
180
|
}
|
|
128
|
-
results.push({ name, ok });
|
|
181
|
+
results.push({ name, ok, kind: "command" });
|
|
129
182
|
// Fail fast is tempting, but running every check surfaces all breakage
|
|
130
183
|
// in one pass — more useful for an agent fixing the gap.
|
|
131
184
|
}
|
|
132
185
|
|
|
186
|
+
const cmdFailed = results.filter((r) => r.kind === "command" && !r.ok);
|
|
187
|
+
const pending = results.filter((r) => r.pending);
|
|
133
188
|
const passed = results.filter((r) => r.ok).length;
|
|
134
189
|
console.log("");
|
|
135
|
-
|
|
136
|
-
|
|
190
|
+
// The gate fails on any failed command check, or (only under --strict) any
|
|
191
|
+
// manual check still awaiting sign-off. Pending manual checks are otherwise
|
|
192
|
+
// a non-blocking report.
|
|
193
|
+
const failGate = cmdFailed.length > 0 || (strict && pending.length > 0);
|
|
194
|
+
const pendNote = pending.length > 0 ? c.gray(` (${pending.length} manual pending)`) : "";
|
|
195
|
+
if (!failGate) {
|
|
196
|
+
console.log(c.green("ok") + ` ${passed}/${results.length} checks passed${pendNote}`);
|
|
137
197
|
return 0;
|
|
138
198
|
}
|
|
139
|
-
const
|
|
140
|
-
|
|
199
|
+
const reasons = [];
|
|
200
|
+
if (cmdFailed.length > 0) reasons.push(`failed: ${cmdFailed.map((r) => r.name).join(", ")}`);
|
|
201
|
+
if (strict && pending.length > 0) reasons.push(`pending sign-off: ${pending.map((r) => r.name).join(", ")}`);
|
|
202
|
+
console.log(c.red("fail") + ` ${passed}/${results.length} checks passed — ${reasons.join("; ")}`);
|
|
141
203
|
return 1;
|
|
142
204
|
}
|
|
143
205
|
|
|
@@ -276,22 +338,28 @@ function findPackageJsons(projectRoot) {
|
|
|
276
338
|
}
|
|
277
339
|
|
|
278
340
|
export const help = `
|
|
279
|
-
Usage: doctrina verify [--list] [--init] [--clean] [--force]
|
|
341
|
+
Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--signoff "<name>=<note>"] [--force]
|
|
280
342
|
|
|
281
343
|
Run the project-declared build/verify checks from ${CONFIG_REL} in order,
|
|
282
344
|
streaming their output, and exit non-zero if any check fails. This is the
|
|
283
345
|
real "does the code work" gate, separate from the structural \`validate\`.
|
|
284
346
|
|
|
285
347
|
${CONFIG_REL} shape (an optional per-check "cwd", relative to the project
|
|
286
|
-
root, targets a sub-package in a monorepo
|
|
348
|
+
root, targets a sub-package in a monorepo; a "manual" check is the
|
|
349
|
+
qualitative gate — judged by a human/eval, recorded as a sign-off, not run):
|
|
287
350
|
{
|
|
288
351
|
"checks": [
|
|
289
352
|
{ "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api" },
|
|
290
353
|
{ "name": "test", "run": "npm test" },
|
|
291
|
-
{ "name": "build", "run": "npm run build" }
|
|
354
|
+
{ "name": "build", "run": "npm run build" },
|
|
355
|
+
{ "name": "chronicle", "type": "manual", "rubric": "is the chronicle enjoyable to read?" }
|
|
292
356
|
]
|
|
293
357
|
}
|
|
294
358
|
|
|
359
|
+
A manual check passes when it has been signed off and is otherwise reported
|
|
360
|
+
as "pending" — non-blocking by default, failing only under --strict (so CI
|
|
361
|
+
can require the sign-off). Sign-offs are stored in ${SIGNOFF_REL}.
|
|
362
|
+
|
|
295
363
|
Flags:
|
|
296
364
|
--init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
|
|
297
365
|
--list Print the configured checks without running them
|
|
@@ -299,5 +367,8 @@ Flags:
|
|
|
299
367
|
reproducibility footguns (entry points in a build dir with no
|
|
300
368
|
prepare/prepack; a Prisma dep with no generate on install) so
|
|
301
369
|
"verify green" can't hide a clean checkout that won't build.
|
|
370
|
+
--strict Fail the gate when a manual check is still pending sign-off
|
|
371
|
+
--signoff "<name>=<note>"
|
|
372
|
+
Record today's sign-off for a manual check, then exit
|
|
302
373
|
--force With --init, overwrite an existing config
|
|
303
374
|
`;
|