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.
@@ -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
  `;
@@ -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
+ `;