doctrina-cli 0.4.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 +32 -13
- package/package.json +1 -1
- package/src/commands/change.js +128 -38
- package/src/commands/close.js +104 -0
- package/src/commands/coverage.js +100 -20
- 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/spec.js +92 -6
- package/src/commands/status.js +178 -0
- package/src/commands/trace.js +31 -0
- package/src/commands/validate.js +97 -11
- package/src/commands/verify.js +234 -13
- package/src/commands/watch.js +97 -0
- package/src/commands/why.js +182 -0
- package/src/commands/work.js +241 -20
- package/src/index.js +13 -1
- package/src/lib/spec-ops.js +236 -0
- package/templates/AGENTS.md.template +49 -22
- package/templates/change/spec-delta.md.template +13 -2
- package/templates/hooks/pre-commit.sample +21 -4
- package/templates/spec.md.template +7 -3
package/src/commands/verify.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
3
4
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { exists, isFile, read, write, relPath } from "../lib/fs-ops.js";
|
|
5
|
-
import { flagBool } from "../lib/args.js";
|
|
5
|
+
import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
|
|
6
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
7
|
+
import { today } from "../lib/dates.js";
|
|
6
8
|
import { c } from "../lib/colors.js";
|
|
7
9
|
|
|
8
10
|
// The build/verify gate `validate` is not. `validate` checks the shape of
|
|
@@ -14,6 +16,7 @@ import { c } from "../lib/colors.js";
|
|
|
14
16
|
// `.doctrina/verify.json`, and `verify` runs exactly that, in order.
|
|
15
17
|
|
|
16
18
|
const CONFIG_REL = ".doctrina/verify.json";
|
|
19
|
+
const SIGNOFF_REL = ".doctrina/verify.signoffs.json";
|
|
17
20
|
|
|
18
21
|
const STARTER = {
|
|
19
22
|
$comment:
|
|
@@ -32,6 +35,17 @@ export async function run(_positional, flags) {
|
|
|
32
35
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
// --clean is the reproducibility lint (review G4): `verify` runs the
|
|
39
|
+
// declared checks against the *current* disk, so a repo that only works
|
|
40
|
+
// because a previous run generated a Prisma client or built a workspace's
|
|
41
|
+
// dist still passes — "verify green" ≠ "passes from a clean checkout".
|
|
42
|
+
// This is the static half of that gap: it flags the footguns that make a
|
|
43
|
+
// fresh `npm install` differ from the dirty machine, without doing a real
|
|
44
|
+
// (slow, package-manager-specific) clean install.
|
|
45
|
+
if (flagBool(flags, "clean", false)) {
|
|
46
|
+
return reproducibilityLint(projectRoot);
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
const configPath = path.join(projectRoot, CONFIG_REL);
|
|
36
50
|
|
|
37
51
|
// --init scaffolds a starter config so the gate is one edit away.
|
|
@@ -68,8 +82,11 @@ export async function run(_positional, flags) {
|
|
|
68
82
|
return 1;
|
|
69
83
|
}
|
|
70
84
|
for (const ch of checks) {
|
|
71
|
-
|
|
72
|
-
|
|
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")`);
|
|
73
90
|
return 1;
|
|
74
91
|
}
|
|
75
92
|
}
|
|
@@ -78,21 +95,69 @@ export async function run(_positional, flags) {
|
|
|
78
95
|
return 1;
|
|
79
96
|
}
|
|
80
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
|
+
|
|
81
127
|
// --list prints the configured checks without running them.
|
|
82
128
|
if (flagBool(flags, "list", false)) {
|
|
83
129
|
console.log(c.bold("Verify checks") + c.gray(` (from ${CONFIG_REL}):`));
|
|
84
130
|
console.log("");
|
|
85
131
|
checks.forEach((ch, i) => {
|
|
86
|
-
|
|
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}`);
|
|
87
134
|
});
|
|
88
135
|
return 0;
|
|
89
136
|
}
|
|
90
137
|
|
|
138
|
+
const strict = flagBool(flags, "strict", false);
|
|
91
139
|
console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
|
|
92
140
|
const results = [];
|
|
93
141
|
for (const ch of checks) {
|
|
94
142
|
const name = ch.name ?? "check";
|
|
95
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
|
+
|
|
96
161
|
const where = ch.cwd ? c.gray(` [${ch.cwd}]`) : "";
|
|
97
162
|
console.log(c.gray(`──── ${name}: `) + ch.run + where);
|
|
98
163
|
// Run through the shell so declared commands can use pipes, &&, env,
|
|
@@ -113,41 +178,197 @@ export async function run(_positional, flags) {
|
|
|
113
178
|
} else {
|
|
114
179
|
console.log(ok ? c.green(`✓ ${name}`) : c.red(`✗ ${name} (exit ${res.status})`));
|
|
115
180
|
}
|
|
116
|
-
results.push({ name, ok });
|
|
181
|
+
results.push({ name, ok, kind: "command" });
|
|
117
182
|
// Fail fast is tempting, but running every check surfaces all breakage
|
|
118
183
|
// in one pass — more useful for an agent fixing the gap.
|
|
119
184
|
}
|
|
120
185
|
|
|
186
|
+
const cmdFailed = results.filter((r) => r.kind === "command" && !r.ok);
|
|
187
|
+
const pending = results.filter((r) => r.pending);
|
|
121
188
|
const passed = results.filter((r) => r.ok).length;
|
|
122
189
|
console.log("");
|
|
123
|
-
|
|
124
|
-
|
|
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}`);
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
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("; ")}`);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Static reproducibility lint: walk the project's package.json files and
|
|
207
|
+
// report the two "works on my machine" footguns the review hit — a package
|
|
208
|
+
// whose entry points live in a build-output dir with nothing that builds it
|
|
209
|
+
// on install, and a codegen dependency (Prisma) with no install hook that
|
|
210
|
+
// generates. Returns 0 when clean, 1 when any risk is found. Pure read.
|
|
211
|
+
function reproducibilityLint(projectRoot) {
|
|
212
|
+
const pkgPaths = findPackageJsons(projectRoot);
|
|
213
|
+
console.log(c.bold("doctrina verify --clean") + c.gray(" — reproducibility lint (static, not a real fresh install)"));
|
|
214
|
+
console.log("");
|
|
215
|
+
|
|
216
|
+
if (pkgPaths.length === 0) {
|
|
217
|
+
console.log(c.gray("no package.json found — nothing this lint can check (it knows Node/npm footguns)"));
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const findings = [];
|
|
222
|
+
for (const pkgPath of pkgPaths) {
|
|
223
|
+
const rel = relPath(projectRoot, pkgPath);
|
|
224
|
+
let pkg;
|
|
225
|
+
try {
|
|
226
|
+
pkg = JSON.parse(read(pkgPath));
|
|
227
|
+
} catch (err) {
|
|
228
|
+
findings.push(`${rel}: not valid JSON (${err.message})`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const scripts = pkg.scripts ?? {};
|
|
232
|
+
const hasInstallBuild = typeof scripts.prepare === "string" || typeof scripts.prepack === "string";
|
|
233
|
+
|
|
234
|
+
// 1. Entry point into a build output with nothing to build it on install.
|
|
235
|
+
const built = entryIntoBuildDir(pkg);
|
|
236
|
+
if (built && !hasInstallBuild) {
|
|
237
|
+
findings.push(
|
|
238
|
+
`${rel}: ${built.field} → \`${built.value}\` is a build output, but no "prepare"/"prepack" ` +
|
|
239
|
+
`script builds it on install — a fresh install/clone won't have it (add a prepare script, or commit the output)`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 2. Codegen dependency with no install hook that generates.
|
|
244
|
+
const deps = {
|
|
245
|
+
...pkg.dependencies, ...pkg.devDependencies,
|
|
246
|
+
...pkg.peerDependencies, ...pkg.optionalDependencies,
|
|
247
|
+
};
|
|
248
|
+
for (const [dep, gen] of Object.entries(CODEGEN_DEPS)) {
|
|
249
|
+
if (!(dep in deps)) continue;
|
|
250
|
+
const installHook = `${scripts.postinstall ?? ""} ${scripts.prepare ?? ""}`;
|
|
251
|
+
if (!gen.test(installHook)) {
|
|
252
|
+
findings.push(
|
|
253
|
+
`${rel}: depends on "${dep}" but no "postinstall"/"prepare" runs its codegen ` +
|
|
254
|
+
`(\`${gen.source.replace(/\\s\+/g, " ")}\`) — a fresh install has no generated output`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (findings.length === 0) {
|
|
261
|
+
console.log(c.green("ok") + ` ${pkgPaths.length} package.json file${pkgPaths.length === 1 ? "" : "s"} — no reproducibility risks found`);
|
|
125
262
|
return 0;
|
|
126
263
|
}
|
|
127
|
-
const
|
|
128
|
-
console.log(
|
|
264
|
+
for (const f of findings) console.log(` ${c.yellow("!")} ${f}`);
|
|
265
|
+
console.log("");
|
|
266
|
+
console.log(c.red("fail") + ` ${findings.length} reproducibility risk${findings.length === 1 ? "" : "s"} — a clean checkout may not build`);
|
|
129
267
|
return 1;
|
|
130
268
|
}
|
|
131
269
|
|
|
270
|
+
// Known codegen dependencies → the command an install hook must run so a
|
|
271
|
+
// fresh install produces their generated output.
|
|
272
|
+
const CODEGEN_DEPS = {
|
|
273
|
+
"@prisma/client": /prisma\s+generate/,
|
|
274
|
+
"prisma": /prisma\s+generate/,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const BUILD_DIR_RE = /(?:^|\/)(?:dist|build|out)\//;
|
|
278
|
+
|
|
279
|
+
// The first entry-point field that points into a build-output directory, or
|
|
280
|
+
// null. Scans main/module/types/typings, bin (string or map), and exports
|
|
281
|
+
// (recursively, string leaves only).
|
|
282
|
+
function entryIntoBuildDir(pkg) {
|
|
283
|
+
for (const field of ["main", "module", "types", "typings"]) {
|
|
284
|
+
if (typeof pkg[field] === "string" && BUILD_DIR_RE.test(pkg[field])) {
|
|
285
|
+
return { field, value: pkg[field] };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (typeof pkg.bin === "string" && BUILD_DIR_RE.test(pkg.bin)) return { field: "bin", value: pkg.bin };
|
|
289
|
+
if (pkg.bin && typeof pkg.bin === "object") {
|
|
290
|
+
for (const v of Object.values(pkg.bin)) {
|
|
291
|
+
if (typeof v === "string" && BUILD_DIR_RE.test(v)) return { field: "bin", value: v };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const fromExports = scanExports(pkg.exports);
|
|
295
|
+
if (fromExports) return { field: "exports", value: fromExports };
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function scanExports(node) {
|
|
300
|
+
if (typeof node === "string") return BUILD_DIR_RE.test(node) ? node : null;
|
|
301
|
+
if (node && typeof node === "object") {
|
|
302
|
+
for (const v of Object.values(node)) {
|
|
303
|
+
const hit = scanExports(v);
|
|
304
|
+
if (hit) return hit;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Bounded walk for package.json files: skip dependency, build, and VCS
|
|
311
|
+
// directories so the lint stays fast and ignores vendored manifests.
|
|
312
|
+
const LINT_SKIP_DIRS = new Set([
|
|
313
|
+
".git", "node_modules", "dist", "build", "out", "vendor", ".next",
|
|
314
|
+
"coverage", ".venv", "venv", "__pycache__", "target", ".doctrina",
|
|
315
|
+
]);
|
|
316
|
+
|
|
317
|
+
function findPackageJsons(projectRoot) {
|
|
318
|
+
const found = [];
|
|
319
|
+
const stack = [projectRoot];
|
|
320
|
+
while (stack.length) {
|
|
321
|
+
const dir = stack.pop();
|
|
322
|
+
let entries;
|
|
323
|
+
try {
|
|
324
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
325
|
+
} catch {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
for (const entry of entries) {
|
|
329
|
+
const full = path.join(dir, entry.name);
|
|
330
|
+
if (entry.isDirectory()) {
|
|
331
|
+
if (!LINT_SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) stack.push(full);
|
|
332
|
+
} else if (entry.name === "package.json") {
|
|
333
|
+
found.push(full);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return found.sort();
|
|
338
|
+
}
|
|
339
|
+
|
|
132
340
|
export const help = `
|
|
133
|
-
Usage: doctrina verify [--list] [--init] [--force]
|
|
341
|
+
Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--signoff "<name>=<note>"] [--force]
|
|
134
342
|
|
|
135
343
|
Run the project-declared build/verify checks from ${CONFIG_REL} in order,
|
|
136
344
|
streaming their output, and exit non-zero if any check fails. This is the
|
|
137
345
|
real "does the code work" gate, separate from the structural \`validate\`.
|
|
138
346
|
|
|
139
347
|
${CONFIG_REL} shape (an optional per-check "cwd", relative to the project
|
|
140
|
-
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):
|
|
141
350
|
{
|
|
142
351
|
"checks": [
|
|
143
352
|
{ "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api" },
|
|
144
353
|
{ "name": "test", "run": "npm test" },
|
|
145
|
-
{ "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?" }
|
|
146
356
|
]
|
|
147
357
|
}
|
|
148
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
|
+
|
|
149
363
|
Flags:
|
|
150
364
|
--init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
|
|
151
365
|
--list Print the configured checks without running them
|
|
366
|
+
--clean Don't run checks; lint the project's package.json files for
|
|
367
|
+
reproducibility footguns (entry points in a build dir with no
|
|
368
|
+
prepare/prepack; a Prisma dep with no generate on install) so
|
|
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
|
|
152
373
|
--force With --init, overwrite an existing config
|
|
153
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
|
+
`;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
|
|
5
|
+
import { specHeader, listHeader } from "../lib/scan.js";
|
|
6
|
+
import { c } from "../lib/colors.js";
|
|
7
|
+
import { suggest } from "../lib/suggest.js";
|
|
8
|
+
|
|
9
|
+
// Provenance explainer (review 2026-06-27 passive-user feature #5): answer
|
|
10
|
+
// "why does this capability exist / why was it built this way?" by assembling
|
|
11
|
+
// the chain the framework already records — product intent (the [SC1] anchors
|
|
12
|
+
// it Realizes) → the capability's purpose → the acceptance criteria that prove
|
|
13
|
+
// it → the accepted ADRs that decided it — into one read. The human (or agent)
|
|
14
|
+
// asks; the tool answers from the artifacts, instead of grepping by hand.
|
|
15
|
+
// Read-only. Builds on the Realizes:/trace provenance (ADR 0006/0011).
|
|
16
|
+
|
|
17
|
+
export async function run(positional, _flags) {
|
|
18
|
+
const cap = positional[0];
|
|
19
|
+
const projectRoot = process.cwd();
|
|
20
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
21
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
22
|
+
}
|
|
23
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
24
|
+
const known = isDir(specsDir) ? readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md"))) : [];
|
|
25
|
+
|
|
26
|
+
if (!cap) {
|
|
27
|
+
console.error(c.red("error:") + " why requires a <capability>");
|
|
28
|
+
if (known.length) console.error(c.gray("known: ") + known.sort().join(", "));
|
|
29
|
+
return 2;
|
|
30
|
+
}
|
|
31
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
32
|
+
if (!isFile(specPath)) {
|
|
33
|
+
console.error(c.red("error:") + ` no spec for capability "${cap}"`);
|
|
34
|
+
const guess = suggest(cap, known);
|
|
35
|
+
if (guess) console.error(c.gray("hint: ") + `did you mean "${guess}"?`);
|
|
36
|
+
else if (known.length) console.error(c.gray("known: ") + known.sort().join(", "));
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const text = read(specPath);
|
|
41
|
+
console.log(c.bold(`Why "${cap}"`) + c.gray(" — provenance chain (intent → capability → proof → decisions)"));
|
|
42
|
+
console.log("");
|
|
43
|
+
|
|
44
|
+
// 1. Product intent it realizes.
|
|
45
|
+
const realizesRaw = specHeader(text, "Realizes");
|
|
46
|
+
const anchorIds = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? []) : [];
|
|
47
|
+
console.log(c.bold(" Product intent"));
|
|
48
|
+
if (realizesRaw && /^n\/a\b/i.test(realizesRaw.trim())) {
|
|
49
|
+
console.log(` ${c.gray("n/a — " + realizesRaw.trim().replace(/^n\/a\s*[—-]?\s*/i, ""))}`);
|
|
50
|
+
} else if (anchorIds.length === 0) {
|
|
51
|
+
console.log(` ${c.yellow("none declared")} ${c.gray("— add a **Realizes:** header (doctrina trace)")}`);
|
|
52
|
+
} else {
|
|
53
|
+
const anchors = productAnchors(projectRoot);
|
|
54
|
+
for (const id of anchorIds) {
|
|
55
|
+
const line = anchors.get(id);
|
|
56
|
+
console.log(` ${c.cyan(id)} ${line ? line : c.yellow("(no matching anchor in product.md)")}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. The capability itself.
|
|
61
|
+
console.log("");
|
|
62
|
+
console.log(c.bold(" Capability"));
|
|
63
|
+
const status = specHeader(text, "Status") ?? "—";
|
|
64
|
+
const impl = specHeader(text, "Implementation") ?? "—";
|
|
65
|
+
console.log(` ${c.gray("status:")} ${status} ${c.gray("implementation:")} ${impl}`);
|
|
66
|
+
const purpose = sectionParagraph(text, "Purpose");
|
|
67
|
+
if (purpose) console.log(" " + purpose.replace(/\s+/g, " ").trim());
|
|
68
|
+
|
|
69
|
+
// 3. The proof (acceptance criteria with cited evidence).
|
|
70
|
+
console.log("");
|
|
71
|
+
console.log(c.bold(" Proof"));
|
|
72
|
+
const criteria = acceptanceWithEvidence(text);
|
|
73
|
+
if (criteria.length === 0) {
|
|
74
|
+
console.log(` ${c.gray("no acceptance criteria declared")}`);
|
|
75
|
+
} else {
|
|
76
|
+
for (const cr of criteria) {
|
|
77
|
+
const proof = cr.proof.length ? c.gray(cr.proof.map((p) => `\`${p}\``).join(", ")) : c.yellow("no evidence cited");
|
|
78
|
+
console.log(` ${cr.mark} #${cr.n} ${proof}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 4. The decisions that shaped it (accepted ADRs naming the capability).
|
|
83
|
+
console.log("");
|
|
84
|
+
console.log(c.bold(" Decisions"));
|
|
85
|
+
const adrs = decisionsMentioning(projectRoot, cap);
|
|
86
|
+
if (adrs.length === 0) {
|
|
87
|
+
console.log(` ${c.gray("no accepted ADR names this capability")}`);
|
|
88
|
+
} else {
|
|
89
|
+
for (const a of adrs) console.log(` ${c.cyan("ADR " + a.id)} ${a.title}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Map of anchor id -> the product.md bullet text that declares it.
|
|
96
|
+
function productAnchors(projectRoot) {
|
|
97
|
+
const out = new Map();
|
|
98
|
+
const p = path.join(projectRoot, ".doctrina", "product.md");
|
|
99
|
+
if (!isFile(p)) return out;
|
|
100
|
+
for (const line of read(p).split(/\r?\n/)) {
|
|
101
|
+
const m = line.match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+(.+)$/);
|
|
102
|
+
if (m && !out.has(m[1])) out.set(m[1], m[2].trim());
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// First non-comment paragraph under a "## <name>" heading.
|
|
108
|
+
function sectionParagraph(text, name) {
|
|
109
|
+
const lines = text.split(/\r?\n/);
|
|
110
|
+
let inSection = false;
|
|
111
|
+
const buf = [];
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (/^##\s+/.test(line)) {
|
|
114
|
+
if (inSection) break;
|
|
115
|
+
if (new RegExp(`^##\\s+${name}\\b`, "i").test(line)) inSection = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!inSection) continue;
|
|
119
|
+
if (/^\s*<!--/.test(line) || /-->/.test(line)) continue;
|
|
120
|
+
if (line.trim() === "") {
|
|
121
|
+
if (buf.length) break;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
buf.push(line.trim());
|
|
125
|
+
}
|
|
126
|
+
return buf.join(" ");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Numbered acceptance criteria with their [mark] and any backtick proof paths.
|
|
130
|
+
function acceptanceWithEvidence(text) {
|
|
131
|
+
const lines = text.split(/\r?\n/);
|
|
132
|
+
let inSection = false;
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
if (/^##\s+/.test(line)) {
|
|
136
|
+
inSection = /^##\s+Acceptance criteria\b/i.test(line);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!inSection) continue;
|
|
140
|
+
const m = line.match(/^\s*(\d+)\.\s+(.*)$/);
|
|
141
|
+
if (!m) continue;
|
|
142
|
+
const body = m[2];
|
|
143
|
+
const markMatch = body.match(/^\[([^\]]+)\]/);
|
|
144
|
+
const mark = markMatch
|
|
145
|
+
? (/^verified/i.test(markMatch[1]) ? c.green("✓") : c.yellow("○"))
|
|
146
|
+
: c.gray("·");
|
|
147
|
+
const proof = [...body.matchAll(/`([^`]+)`/g)].map((x) => x[1]).filter((s) => s.includes("/") || /\.[a-z0-9]{1,8}$/i.test(s));
|
|
148
|
+
out.push({ n: m[1], mark, proof });
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Accepted ADRs whose body names the capability (word-ish boundary).
|
|
154
|
+
function decisionsMentioning(projectRoot, cap) {
|
|
155
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
156
|
+
const out = [];
|
|
157
|
+
if (!isDir(adrDir)) return out;
|
|
158
|
+
const re = new RegExp(`\\b${cap.replace(/[-]/g, "[- ]?")}\\b`, "i");
|
|
159
|
+
for (const f of readdirSync(adrDir).sort()) {
|
|
160
|
+
const m = f.match(/^(\d{4})-.*\.md$/);
|
|
161
|
+
if (!m) continue;
|
|
162
|
+
const text = read(path.join(adrDir, f));
|
|
163
|
+
const status = (listHeader(text, "Status") ?? "").toLowerCase();
|
|
164
|
+
if (status !== "accepted") continue;
|
|
165
|
+
if (!re.test(text)) continue;
|
|
166
|
+
const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
|
|
167
|
+
out.push({ id: m[1], title: titleMatch ? titleMatch[1].trim() : f });
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const help = `
|
|
173
|
+
Usage: doctrina why <capability>
|
|
174
|
+
|
|
175
|
+
Explain a capability's provenance by assembling the chain Doctrina already
|
|
176
|
+
records into one read: the product intent it **Realizes:** ([SC1] anchors),
|
|
177
|
+
the capability's purpose and status, the acceptance criteria that prove it
|
|
178
|
+
(with cited evidence), and the accepted ADRs that name it. Read-only.
|
|
179
|
+
|
|
180
|
+
Builds on the intent provenance (ADR 0006/0011) — answer "why was X built,
|
|
181
|
+
and built this way?" without grepping the tree by hand.
|
|
182
|
+
`;
|