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
|
@@ -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
|
`;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { watch as fsWatch } from "node:fs";
|
|
4
|
+
import { exists } from "../lib/fs-ops.js";
|
|
5
|
+
import { flagBool } from "../lib/args.js";
|
|
6
|
+
import { c } from "../lib/colors.js";
|
|
7
|
+
import * as validate from "./validate.js";
|
|
8
|
+
import * as next from "./next.js";
|
|
9
|
+
|
|
10
|
+
// Continuous keep-in-sync (review 2026-06-27 passive-user feature #4): re-run
|
|
11
|
+
// `validate --fix` (heal drift, migrate the stamp) and reprint `doctrina next`
|
|
12
|
+
// whenever an artifact changes, so the index stays synced and the agent stays
|
|
13
|
+
// oriented without anyone invoking a command. Passive by construction. The
|
|
14
|
+
// pass is debounced and ignores writes to index.json (which `--fix` itself
|
|
15
|
+
// makes) so it never loops on its own output. `--once` runs a single pass and
|
|
16
|
+
// exits — the testable, scriptable form.
|
|
17
|
+
|
|
18
|
+
export async function run(_positional, flags) {
|
|
19
|
+
const projectRoot = process.cwd();
|
|
20
|
+
const dot = path.join(projectRoot, ".doctrina");
|
|
21
|
+
if (!exists(dot)) {
|
|
22
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (flagBool(flags, "once", false)) {
|
|
26
|
+
return pass(projectRoot);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(c.bold("doctrina watch") + c.gray(" — validate --fix + next on every change. Ctrl-C to stop."));
|
|
30
|
+
await pass(projectRoot);
|
|
31
|
+
|
|
32
|
+
let timer = null;
|
|
33
|
+
let running = false;
|
|
34
|
+
const schedule = () => {
|
|
35
|
+
if (timer) clearTimeout(timer);
|
|
36
|
+
timer = setTimeout(async () => {
|
|
37
|
+
if (running) return;
|
|
38
|
+
running = true;
|
|
39
|
+
console.log("");
|
|
40
|
+
console.log(c.gray(`──── ${new Date().toLocaleTimeString()} change detected`));
|
|
41
|
+
await pass(projectRoot);
|
|
42
|
+
running = false;
|
|
43
|
+
}, 300);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
let watcher;
|
|
47
|
+
try {
|
|
48
|
+
watcher = fsWatch(dot, { recursive: true }, (_event, filename) => {
|
|
49
|
+
const name = (filename ?? "").replace(/\\/g, "/");
|
|
50
|
+
// Ignore the index the fix itself rewrites (would self-trigger forever).
|
|
51
|
+
if (name.endsWith("index.json")) return;
|
|
52
|
+
schedule();
|
|
53
|
+
});
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error(c.red("error:") + ` could not start the watcher: ${err.message}`);
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Keep the process alive until interrupted.
|
|
60
|
+
return await new Promise((resolve) => {
|
|
61
|
+
const stop = () => {
|
|
62
|
+
if (watcher) watcher.close();
|
|
63
|
+
console.log("");
|
|
64
|
+
console.log(c.gray("watch stopped."));
|
|
65
|
+
resolve(0);
|
|
66
|
+
};
|
|
67
|
+
process.on("SIGINT", stop);
|
|
68
|
+
process.on("SIGTERM", stop);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// One pass: heal drift, then reprint the next actions. Never throws.
|
|
73
|
+
async function pass(projectRoot) {
|
|
74
|
+
try {
|
|
75
|
+
await validate.run([], new Map([["fix", true]]));
|
|
76
|
+
console.log("");
|
|
77
|
+
await next.run([], new Map());
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(c.red("error:") + ` ${err.message}`);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const help = `
|
|
86
|
+
Usage: doctrina watch [--once]
|
|
87
|
+
|
|
88
|
+
Watch the .doctrina/ tree and, on every change, run \`doctrina validate
|
|
89
|
+
--fix\` (heal index drift, migrate the framework stamp) and reprint
|
|
90
|
+
\`doctrina next\` — so the index stays in sync and the agent stays oriented
|
|
91
|
+
without anyone invoking a command. Debounced; ignores the index.json the
|
|
92
|
+
fix itself rewrites. Runs until interrupted (Ctrl-C).
|
|
93
|
+
|
|
94
|
+
Options:
|
|
95
|
+
--once Run a single pass (validate --fix + next) and exit, instead of
|
|
96
|
+
watching. The scriptable / testable form.
|
|
97
|
+
`;
|