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.
@@ -1,14 +1,16 @@
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 { 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
+ `;
@@ -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) {
@@ -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
@@ -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
- if (!ch || typeof ch.run !== "string" || ch.run.trim() === "") {
84
- console.error(c.red("error:") + ` every check in ${CONFIG_REL} needs a non-empty "run" string`);
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
- console.log(` ${i + 1}. ${c.cyan((ch.name ?? "check").padEnd(16))} ${c.gray(ch.run)}`);
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
- if (passed === results.length) {
136
- console.log(c.green("ok") + ` ${passed}/${results.length} checks passed`);
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 failed = results.filter((r) => !r.ok).map((r) => r.name).join(", ");
140
- console.log(c.red("fail") + ` ${passed}/${results.length} checks passed — failed: ${failed}`);
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
  `;