@davidbalzan/groundwork 0.4.1 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidbalzan/groundwork",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Groundwork — an installable AI development workflow (skills + doc methodology) you bolt onto any repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,7 +29,7 @@
29
29
  "author": "David Balzan",
30
30
  "license": "UNLICENSED",
31
31
  "dependencies": {
32
- "@davidbalzan/groundwork-seam": "0.1.5"
32
+ "@davidbalzan/groundwork-seam": "0.1.6"
33
33
  },
34
34
  "scripts": {
35
35
  "groundwork": "node src/cli.mjs",
@@ -25,7 +25,13 @@ Update with `/update-workstreams`.
25
25
  | ------ | ------------- | ----------------- | ------ | ------- | --------- |
26
26
  | — | — | — | ⏳ Queued | — | Run `/kickstart`, then `/plan-phase` to populate the backlog |
27
27
 
28
- Status: 🚧 In Progress · 🔍 In Review · ⏳ Queued · ⛔ Blocked · ✅ Done
28
+ Status: 🚧 In Progress · 🔍 In Review · ⏳ Queued · ⛔ Blocked · 🚫 Unstaffable · ✅ Done
29
+
30
+ ⛔ **Blocked** promises a future in which the work resumes, so the `Blocker` column must name
31
+ what would unblock it. 🚫 **Unstaffable** denies that future: the work will not be staffed at
32
+ all (out of scope, superseded, a policy removal). A row read as *resume when the base settles*
33
+ when it was never going to be staffed has already produced a GO against a standing ruling — the
34
+ two states are not a nuance.
29
35
 
30
36
  ---
31
37
 
@@ -5,12 +5,12 @@ referencing the full task breakdown. Ad-hoc tasks (bug fixes, improvements) are
5
5
  the human. This file is intake only: live in-flight state lives in [[WORKSTREAMS]], completions
6
6
  are recorded in `docs/DONE.md`.
7
7
 
8
- **Write rule (single writer per file):** the human (or their planning proxy) writes here — add /
9
- reorder / remove / edit items, set priority (`P1`/`P2`/`P3`; top-to-bottom breaks ties), add
10
- constraints, acceptance criteria, or refs inline. Whoever executes work never edits this file — it
11
- reads the top unblocked item fresh and appends the completion to `docs/DONE.md` instead. Pruning
12
- satisfied items is the writer's job. No compare-and-swap ceremony needed: the filesystem enforces
13
- the boundary.
8
+ **Write rule ownership splits by ACTION, not by file.** The human (or their planning proxy)
9
+ CURATES: add / reorder / remove / edit items, set priority (`P1`/`P2`/`P3`; top-to-bottom breaks
10
+ ties), add constraints, acceptance criteria, refs. Whoever COORDINATES closes: it sets item status
11
+ and moves what a merge satisfied to `docs/DONE.md`, and pruning it here is its obligation, not its
12
+ permission. Solo, or with no coordinator, you do both. Whoever executes work reads the top unblocked
13
+ item fresh and edits neither. Assigning the FILE to one role is what once left `prune` unowned.
14
14
 
15
15
  ## Queue
16
16
 
@@ -53,8 +53,11 @@ parse-only.
53
53
  - ✅ Phase 1 · Foundation — agent-api · `feat/phase1` · merged YYYY-MM-DD
54
54
  ```
55
55
 
56
- Guidelines: status = 🚧 In Progress · 🔍 In Review · ⏳ Queued · ⛔ Blocked · ✅ Done. Record
57
- branch + worktree so any agent can resume the exact context. `Blocker` names the dependency
58
- (another stream, a decision, a person), not just "yes". Dates `YYYY-MM-DD`.
56
+ Guidelines: status = 🚧 In Progress · 🔍 In Review · ⏳ Queued · ⛔ Blocked · 🚫 Unstaffable · ✅ Done.
57
+ Record branch + worktree so any agent can resume the exact context. `Blocker` names the
58
+ dependency (another stream, a decision, a person), not just "yes" ⛔ Blocked promises the work
59
+ resumes, so it must say what would end the block. Use 🚫 Unstaffable when it will not be
60
+ staffed at all; expressing that as ⛔ Blocked has been read as *resume later* and scoped into a
61
+ GO. Dates `YYYY-MM-DD`.
59
62
 
60
63
  Progress to record: $ARGUMENTS
@@ -1,12 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
3
4
  import { TARGET, PKG_ROOT } from "../lib/paths.mjs";
4
5
  import { exists, readText, walk, listDirs } from "../lib/fs.mjs";
5
6
  import { countCheckboxes, progressBar } from "../lib/progress.mjs";
6
- import { ARTIFACTS } from "../lib/artifacts.mjs";
7
+ import { ARTIFACTS, workDocPaths } from "../lib/artifacts.mjs";
7
8
  import { log, bold, green, yellow, dim, cyan } from "../lib/log.mjs";
8
9
  import { adrTripwire } from "../lib/adr-tripwire.mjs";
9
- import { parseFactsDoc, parseWorkDoc, workDocIssues, workDocLegacyWriteIssues } from "@davidbalzan/groundwork-seam";
10
+ import { parseFactsDoc, parseWorkDoc, workDocIssues, workDocLegacyWriteIssues, workDocIssuesDetailed, queueItemsOf, doneEntriesOf, workstreamsV1RowsOf } from "@davidbalzan/groundwork-seam";
10
11
 
11
12
  /**
12
13
  * `groundwork doctor` — flag drift between the docs and reality. Offline + deterministic.
@@ -45,6 +46,36 @@ export function collectDoctor(targetDir) {
45
46
  const mdFiles = [...walk(docs)].filter((f) => f.endsWith(".md"));
46
47
  const phaseDir = path.join(docs, "phases");
47
48
 
49
+ // THE POPULATION COMES FROM THE MANIFEST, never from a filename in a checker.
50
+ //
51
+ // Three checks shipped on 2026-08-28 reading `docs/QUEUE.md` and nothing else.
52
+ // Four hours later 31 of 52 items were moved, deliberately and correctly, into
53
+ // `docs/phases/phase4/ITEMS.md` — and every one of those checks kept reporting
54
+ // green about a file that no longer held most of the work. Nothing broke; the
55
+ // population narrowed underneath a check trusted for the whole of it. Same
56
+ // shape as a hand-typed path list, which is the defect `doc-registration`
57
+ // already exists to catch, arriving from the other direction.
58
+ //
59
+ // `phaseN` in a manifest path is a placeholder, expanded against the phase
60
+ // directories that actually exist.
61
+ const declaredDocs = (grammar) => {
62
+ const out = [];
63
+ for (const decl of workDocPaths(grammar)) {
64
+ const rel = decl.replace(/^docs\//, "");
65
+ if (!rel.includes("phaseN")) {
66
+ if (exists(path.join(docs, rel))) out.push(rel);
67
+ continue;
68
+ }
69
+ if (!exists(phaseDir)) continue;
70
+ for (const dir of listDirs(phaseDir)) {
71
+ const candidate = rel.replace("phaseN", dir);
72
+ if (exists(path.join(docs, candidate))) out.push(candidate);
73
+ }
74
+ }
75
+ return out;
76
+ };
77
+ const queueDocs = declaredDocs("queue.v1");
78
+
48
79
  guard("orphan-wikilinks", () => {
49
80
  const valid = validLinkTargets(docs, mdFiles);
50
81
  const orphanItems = [];
@@ -155,23 +186,691 @@ guard("workstreams", () => {
155
186
  const wsFile = path.join(docs, "WORKSTREAMS.md");
156
187
  if (exists(wsFile)) {
157
188
  const wsDoc = parseWorkDoc(readText(wsFile));
158
- const wsIssues = [...workDocIssues(wsDoc), ...workDocLegacyWriteIssues(wsDoc)];
159
- if (wsIssues.length) push("workstreams", "warn", wsIssues);
189
+ const found = [
190
+ ...workDocIssuesDetailed(wsDoc),
191
+ ...workDocLegacyWriteIssues(wsDoc).map((message) => ({ level: "warn", message })),
192
+ ];
193
+ const hard = found.filter((i) => i.level === "warn");
194
+ // Hard failures first, always — a board that does not parse must not sit
195
+ // below advisories about style.
196
+ const wsIssues = [...hard, ...found.filter((i) => i.level !== "warn")].map((i) => i.message);
197
+ if (hard.length) push("workstreams", "warn", wsIssues);
198
+ else if (wsIssues.length) push("workstreams", "info", wsIssues);
160
199
  else push("workstreams", "ok", ["write grammar is workstreams.v1"]);
161
200
  } else push("workstreams", "info", ["no WORKSTREAMS.md yet (run `groundwork update --docs`)"]);
162
201
  });
163
202
 
164
203
 
204
+ // Severity here means ONE thing: is anyone expected to act?
205
+ //
206
+ // This check pushed every finding at `warn`, and a fleet's deliberate
207
+ // headline+body items produced 17 benign warnings in a single run — "the heading
208
+ // is already good", David. A GENUINE `workstreams.v1` parse failure appeared in
209
+ // that same run and was harder to see for it, and was found by David looking at
210
+ // his console rather than by anyone reading the report. Seventeen unactionable
211
+ // warnings trained the reader to skim, which is what let the real one through.
212
+ //
213
+ // So the one-line rule is DEMOTED, not deleted: when the item's first line parses
214
+ // the contract is satisfied and the body below it is a style choice. The token
215
+ // cost of continuation lines is real for every agent reading the file, which is
216
+ // why the advisory stays. A count is always reported — a checker that says only
217
+ // "ok" tells you nothing about whether it looked at anything.
165
218
  guard("queue", () => {
166
- const qFile = path.join(docs, "QUEUE.md");
167
- if (exists(qFile)) {
168
- const qIssues = workDocIssues(parseWorkDoc(readText(qFile)));
169
- if (qIssues.length) push("queue", "warn", qIssues);
170
- else push("queue", "ok", ["queue.v1 one line per item"]);
219
+ if (!queueDocs.length) return;
220
+ const lines = [];
221
+ let hardCount = 0;
222
+ let softCount = 0;
223
+ for (const rel of queueDocs) {
224
+ const qDoc = parseWorkDoc(readText(path.join(docs, rel)));
225
+ const items = queueItemsOf(qDoc);
226
+ const open = items.filter((i) => !i.done).length;
227
+ // A declared queue doc parsing to ZERO items is not a clean queue, it is a
228
+ // file the grammar does not recognise. `ITEMS.md` held 31 byte-identical
229
+ // `- [ ] (P2) …` lines and yielded nothing, because queue.v1 reads items only
230
+ // under a `## Queue` h2 — the manifest pointed at something the seam will not
231
+ // read, which is worse than not declaring it at all.
232
+ if (items.length === 0) {
233
+ hardCount++;
234
+ lines.push(
235
+ `docs/${rel} is declared queue.v1 and parsed to ZERO items — the grammar does not recognise it (items live under a \`## Queue\` h2), so every queue check is silently blind to this file`,
236
+ );
237
+ continue;
238
+ }
239
+ const found = workDocIssuesDetailed(qDoc);
240
+ const hard = found.filter((i) => i.level === "warn");
241
+ hardCount += hard.length;
242
+ softCount += found.length - hard.length;
243
+ lines.push(`docs/${rel}: ${items.length} item(s), ${open} open — queue.v1`);
244
+ for (const i of hard) lines.push(` ${i.message}`);
245
+ for (const i of found) if (i.level !== "warn") lines.push(` ${i.message}`);
171
246
  }
247
+ if (hardCount) push("queue", "warn", lines);
248
+ else if (softCount) push("queue", "info", lines);
249
+ else push("queue", "ok", lines.map((l) => `${l} — one line per item`));
172
250
  });
173
251
 
174
252
 
253
+ // A queue item ticked `[x]` with no matching entry in DONE.md.
254
+ //
255
+ // This replaces a discipline that failed THREE TIMES on 2026-08-27 with the rule
256
+ // visible: twelve unmoved items in the morning, two in the afternoon after David
257
+ // ruled on it in writing, and three more whose fixes had shipped and were never
258
+ // closed. A reminder was already tried and did not hold — which is why this is a
259
+ // check and not another note.
260
+ //
261
+ // The tie is the PR REFERENCE, not text similarity: a closed item cites the PR that
262
+ // closed it and a DONE entry records the same ref, so matching them is mechanical
263
+ // rather than a guess about wording. A closed item citing NO ref at all is its own
264
+ // finding — it cannot be tied to anything, by anyone, ever.
265
+ guard("queue-done-loop", () => {
266
+ const dFile = path.join(docs, "DONE.md");
267
+ if (!queueDocs.length || !exists(dFile)) return;
268
+ const closed = queueDocs.flatMap((rel) =>
269
+ queueItemsOf(parseWorkDoc(readText(path.join(docs, rel))))
270
+ .filter((i) => i.done)
271
+ .map((i) => ({ ...i, where: `docs/${rel}` })),
272
+ );
273
+ if (closed.length === 0) {
274
+ push("queue-done-loop", "ok", ["no closed items sitting in the queue"]);
275
+ return;
276
+ }
277
+ const REF = /(?:[\w.-]+\/[\w.-]+)?#(\d+)/g;
278
+ const doneDoc = parseWorkDoc(readText(dFile));
279
+ const doneEntries = doneEntriesOf(doneDoc);
280
+ // A DONE.md that does not PARSE yields zero entries, and every closed item would
281
+ // then read as unclosed — the check would blame the queue for a defect in the log.
282
+ // "No matching entry" and "nothing parsed" are different answers, and conflating
283
+ // them is the failure this whole check exists to catch, one file over.
284
+ if (doneEntries.length === 0 && readText(dFile).trim().length > 0) {
285
+ push("queue-done-loop", "warn", [
286
+ "DONE.md has content but parsed to ZERO entries — the loop cannot be checked, and this is not the same as clean",
287
+ "entries must read `- [x] <task> — owner/repo#N · YYYY-MM-DD` (done.v1); a `- ` bullet without `[x]` is prose to the parser",
288
+ ]);
289
+ return;
290
+ }
291
+ const doneRefs = new Set(
292
+ doneEntries.map((e) => String(e.ref ?? "").match(/#(\d+)/)?.[1]).filter(Boolean),
293
+ );
294
+ const problems = [];
295
+ for (const item of closed) {
296
+ const cited = [...String(item.text).matchAll(REF)].map((m) => m[1]);
297
+ const short = String(item.text).replace(/\s+/g, " ").slice(0, 70);
298
+ if (cited.length === 0) {
299
+ problems.push(`${item.where}: [x] item cites no PR, so nothing can tie it to DONE.md — "${short}…"`);
300
+ } else if (!cited.some((n) => doneRefs.has(n))) {
301
+ problems.push(`${item.where}: [x] item cites #${cited.join(", #")} but DONE.md has no entry for any of them — "${short}…"`);
302
+ }
303
+ }
304
+ if (problems.length) {
305
+ push("queue-done-loop", "warn", [
306
+ ...problems,
307
+ "move the item's closure into DONE.md — the queue is inbound work, DONE.md is the completion log",
308
+ ]);
309
+ } else {
310
+ push("queue-done-loop", "ok", [`${closed.length} closed item(s), each matched to a DONE.md entry`]);
311
+ }
312
+ });
313
+
314
+ // A queue that lost items without anyone intending it.
315
+ //
316
+ // `queue.v1` validates SHAPE — one line per item — and a scripted edit that DELETED
317
+ // an item passed every check green, because a file with one fewer correct line is
318
+ // still correctly shaped. Measured 2026-08-27: a Python edit collapsed two items into
319
+ // one, the replacement text was never written, and an item's entire content was lost
320
+ // under a green `queue.v1 ✓`.
321
+ //
322
+ // The baseline is the committed version of the same file. Where there is no git
323
+ // history to compare against, this says so OUT LOUD rather than passing: "could not
324
+ // check" and "nothing wrong" are different answers and today cost a day proving it.
325
+ //
326
+ // A DELIBERATE prune must pass, or the check is a wall rather than a guard and the
327
+ // first person to close an item correctly learns to ignore it. Two acknowledgements
328
+ // are accepted, both mechanical and both already part of the work:
329
+ //
330
+ // 1. The item was CLOSED — one of the PR refs it cited has an entry in DONE.md.
331
+ // That is the loop `queue-done-loop` enforces, read from the other end.
332
+ // 2. The item was RETIRED — a `<!-- queue-prune: <text> -->` comment in QUEUE.md
333
+ // quotes enough of it to name it. A policy removal is not a completion and has
334
+ // no PR; the aide had to carry exactly that in PROSE once because no field
335
+ // could hold it.
336
+ //
337
+ // Anything left is an unacknowledged disappearance, which is precisely what an
338
+ // accidental drop looks like.
339
+ // THE BASELINE IS THE MERGE TARGET, NOT THE BRANCH TIP.
340
+ //
341
+ // This check compared against `HEAD` — the branch's own last commit — and that is
342
+ // blind to the loss that actually happens. Measured live on kit#102: a record PR
343
+ // cut before three items were added to `main` would have deleted all three on
344
+ // merge, and this check printed `16 item(s), none lost against the committed copy`.
345
+ // Green. From the BRANCH's point of view nothing was lost, because those items had
346
+ // never existed there.
347
+ //
348
+ // It is the population rule with the axis rotated: the earlier instance narrowed
349
+ // the set of FILES and was fixed by deriving them from the manifest; this narrows
350
+ // the baseline IN TIME. The first fix did not generalise, and a partial
351
+ // generalisation is worse than none — it made the check look principled.
352
+ //
353
+ // AND IT NAMES THE REF IT USED. The old output said "the committed copy", which is
354
+ // a phrase, not a baseline: it does not distinguish branch tip from merge target,
355
+ // so a reader could not tell which question had been answered. Print the ref.
356
+ function resolveBaseline() {
357
+ const git = (args) =>
358
+ execFileSync("git", args, { cwd: docs, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
359
+ // THE TARGET TIP, NOT THE MERGE BASE — and this correction came from the test
360
+ // rather than from reasoning. The merge base is the point the branch DIVERGED
361
+ // from, so an item added to `main` AFTER the cut is not there either: comparing
362
+ // against it is exactly as blind as comparing against HEAD, for exactly the same
363
+ // reason. The question is "what is on the thing I am about to merge INTO that my
364
+ // branch does not have", and only the target's tip can answer it.
365
+ //
366
+ // A branch that legitimately CLOSES items will now show them as gone, which is
367
+ // correct and is what the acknowledgement paths are for: a ticked item, a DONE
368
+ // entry for a PR it cites, or a quoting prune comment.
369
+ for (const target of ["origin/main", "origin/master", "main", "master"]) {
370
+ try {
371
+ const sha = git(["rev-parse", "--verify", `${target}^{commit}`]);
372
+ return { ref: sha, label: `${target} (${sha.slice(0, 8)})` };
373
+ } catch {
374
+ /* not this one */
375
+ }
376
+ }
377
+ try {
378
+ const head = git(["rev-parse", "HEAD"]);
379
+ return {
380
+ ref: head,
381
+ label: `HEAD (${head.slice(0, 8)}) — NO INTEGRATION BRANCH FOUND, so this cannot see items lost to a stale base`,
382
+ degraded: true,
383
+ };
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+
389
+ guard("queue-item-loss", () => {
390
+ if (!queueDocs.length) return;
391
+ const baseline = resolveBaseline();
392
+ if (!baseline) {
393
+ push("queue-item-loss", "info", [
394
+ "not a git repository — item loss NOT checked against any baseline, which is different from checked and clean",
395
+ ]);
396
+ return;
397
+ }
398
+ const okLines = [];
399
+ const warnLines = [];
400
+ const infoLines = [];
401
+ for (const rel of queueDocs) lossOf(rel);
402
+ // A degraded baseline is a WARNING, not a footnote under a clean verdict: with no
403
+ // integration branch the check cannot answer the question it is trusted for, and
404
+ // "could not check" must never render as "checked and clean".
405
+ const degraded = baseline.degraded
406
+ ? [`baseline DEGRADED — comparing against ${baseline.label}`]
407
+ : [];
408
+ if (warnLines.length || degraded.length) {
409
+ push("queue-item-loss", "warn", [...degraded, ...warnLines, ...infoLines, ...okLines]);
410
+ } else if (infoLines.length) {
411
+ push("queue-item-loss", "info", [...infoLines, ...okLines]);
412
+ } else {
413
+ push("queue-item-loss", "ok", okLines);
414
+ }
415
+
416
+ function lossOf(rel) {
417
+ const qFile = path.join(docs, rel);
418
+ let committed;
419
+ try {
420
+ committed = execFileSync("git", ["show", `${baseline.ref}:./${rel}`], {
421
+ cwd: docs, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
422
+ });
423
+ } catch {
424
+ infoLines.push(
425
+ `docs/${rel}: not present at ${baseline.label} — item loss NOT checked for this file, which is different from checked and clean`,
426
+ );
427
+ return;
428
+ }
429
+ const now = queueItemsOf(parseWorkDoc(readText(qFile)));
430
+ const before = queueItemsOf(parseWorkDoc(committed));
431
+ const key = (i) => String(i.text).replace(/\s+/g, " ").slice(0, 60);
432
+ const nowKeys = new Set(now.map(key));
433
+ const lost = before.filter((i) => !nowKeys.has(key(i)));
434
+ if (!lost.length) {
435
+ okLines.push(`docs/${rel}: ${now.length} item(s), none lost against ${baseline.label}`);
436
+ return;
437
+ }
438
+
439
+ const qText = readText(qFile);
440
+ const dFile = path.join(docs, "DONE.md");
441
+ const doneRefs = new Set();
442
+ if (exists(dFile)) {
443
+ for (const e of doneEntriesOf(parseWorkDoc(readText(dFile)))) {
444
+ const n = String(e.ref ?? "").match(/#(\d+)/)?.[1];
445
+ if (n) doneRefs.add(n);
446
+ }
447
+ }
448
+ const prunes = [...qText.matchAll(/<!--\s*queue-prune:([\s\S]*?)-->/g)].map((m) =>
449
+ String(m[1]).replace(/\s+/g, " ").trim(),
450
+ );
451
+ const acknowledged = (item) => {
452
+ // The item was already ticked in the committed copy. Moving a `[x]` item out is
453
+ // the intended prune — the tick IS the acknowledgement, and it is set-based
454
+ // rather than a count. Requiring ceremony for the ordinary closure is how a
455
+ // guard becomes a wall and then gets ignored.
456
+ if (item.done) return "closed";
457
+ const cited = [...String(item.text).matchAll(/(?:[\w.-]+\/[\w.-]+)?#(\d+)/g)].map((m) => m[1]);
458
+ if (cited.some((n) => doneRefs.has(n))) return "closed";
459
+ // The comment must QUOTE the item — its first 20 characters at least — not merely
460
+ // exist. A bare `<!-- queue-prune: tidied up -->` would acknowledge every future
461
+ // deletion in the file, forever. Twenty characters is the item's opening words and
462
+ // stops short of the trailing refs, which a policy retirement has no reason to copy.
463
+ const k = key(item).slice(0, 20);
464
+ if (k.length >= 8 && prunes.some((p) => p.includes(k))) return "retired";
465
+ return null;
466
+ };
467
+ const unexplained = [];
468
+ const explained = [];
469
+ for (const i of lost) {
470
+ const how = acknowledged(i);
471
+ if (how) explained.push(`${how}: "${key(i)}…"`);
472
+ else unexplained.push(` "${key(i)}…"`);
473
+ }
474
+ const tally = `docs/${rel}: ${before.length} → ${now.length} item(s) (${lost.length} gone, ${explained.length} acknowledged)`;
475
+ if (unexplained.length) {
476
+ warnLines.push(
477
+ `${tally} — ${unexplained.length} disappearance(s) nobody accounted for:`,
478
+ ...unexplained.slice(0, 5),
479
+ "a shape check cannot see this: a file with one fewer correct line is still correctly shaped",
480
+ "close it (a DONE.md entry for a PR the item cites) or retire it (`<!-- queue-prune: <quote the item> -->`)",
481
+ );
482
+ } else {
483
+ okLines.push(tally, ...explained.map((e) => ` ${e}`));
484
+ }
485
+ }
486
+ });
487
+
488
+ // A work-doc on disk that the manifest does not declare.
489
+ //
490
+ // THE OTHER HALF OF DERIVING THE POPULATION, and without it the derivation is
491
+ // worth nothing: a manifest-derived set finds exactly what the manifest names, so
492
+ // a file nobody declared is invisible to it by construction. That is not a
493
+ // smaller version of the old bug, it is the same bug — a check whose population
494
+ // is narrower than the thing it is trusted for — reported green with more
495
+ // machinery behind it.
496
+ //
497
+ // The shape is the signal: a file carrying `- [ ] (P1|P2|P3)` lines IS a queue,
498
+ // whatever it is called and wherever it sits. `docs/phases/phase4/ITEMS.md` held
499
+ // 31 of them four hours after three checks shipped watching one filename.
500
+ guard("undeclared-work-docs", () => {
501
+ const declared = new Set(queueDocs);
502
+ const ITEM = /^- \[[ x]\] \((P1|P2|P3)\) /m;
503
+ // A TEMPLATE is not a queue, and this exclusion is not convenience — it is the
504
+ // over-reporting direction, which is the dangerous one. `QUEUE_TEMPLATE.md`
505
+ // holds an EXAMPLE item line. Declaring it to silence the warning would make
506
+ // every queue check read a specimen row as real work, so a false positive here
507
+ // manufactures exactly the defect the check exists to find.
508
+ const isTemplate = (rel) => /(^|\/)templates\//.test(rel) || /_TEMPLATE\.md$/i.test(rel);
509
+ const rogue = [];
510
+ for (const rel of mdFiles) {
511
+ if (declared.has(rel) || isTemplate(rel)) continue;
512
+ const text = readText(path.join(docs, rel));
513
+ const count = (text.match(/^- \[[ x]\] \((?:P1|P2|P3)\) /gm) ?? []).length;
514
+ if (count > 0 && ITEM.test(text)) rogue.push({ rel, count });
515
+ }
516
+ // Population size beside the verdict, always. "none undeclared" and "none
517
+ // undeclared out of 61 docs scanned" are different claims and only one of them
518
+ // is checkable — a scan that found nothing is a broken scan until it says how
519
+ // much it looked at.
520
+ const scanned = `${mdFiles.filter((r) => !isTemplate(r)).length} doc(s) scanned (templates excluded), ${declared.size} declared queue.v1`;
521
+ if (rogue.length) {
522
+ push("undeclared-work-docs", "warn", [
523
+ scanned,
524
+ ...rogue.map(
525
+ (r) => `docs/${r.rel} holds ${r.count} queue item(s) and is NOT declared queue.v1 in the manifest — no queue check reads it`,
526
+ ),
527
+ "declare it in src/lib/artifacts.mjs with `grammar: \"queue.v1\"` and re-run `groundwork artifacts` — a manifest-derived population cannot find what the manifest does not name",
528
+ ]);
529
+ } else {
530
+ push("undeclared-work-docs", "ok", [`${scanned}, no undeclared queue-shaped doc`]);
531
+ }
532
+ });
533
+
534
+ // A doc on disk that the artifact manifest does not know about.
535
+ //
536
+ // Doctor already reads ARTIFACTS to decide which links may resolve; nothing checks the
537
+ // inverse — a doc that EXISTS and is registered nowhere. A hand-typed coverage list is
538
+ // itself an instance of the duplication it is meant to catch (gate-inventory F4, which
539
+ // this repo shipped twice today), and the cost was measured elsewhere: a drift guard
540
+ // whose path list omitted a second queue file hid a WRONG CLOSURE, reporting clean
541
+ // while DONE.md claimed a PR had closed an item it never delivered.
542
+ //
543
+ // Registered-ness is the property being checked, not correctness: an unregistered doc
544
+ // is not wrong, it is UNGOVERNED — no writer, no reader, and no check knows to look at
545
+ // it. That is exactly how a second queue file goes unnoticed.
546
+ guard("doc-registration", () => {
547
+ if (!exists(docs)) return;
548
+ const known = new Set();
549
+ for (const a of ARTIFACTS) {
550
+ const p = String(a.path ?? "").replace(/\s*\(.*\)\s*/g, "").trim();
551
+ if (p) known.add(p.replace(/^docs\//, "").replace(/\/$/, ""));
552
+ }
553
+ let onDisk;
554
+ try {
555
+ onDisk = fs.readdirSync(docs).filter((f) => f.endsWith(".md"));
556
+ } catch {
557
+ return;
558
+ }
559
+ // A scan that found nothing is a broken scan, not a clean one (gate-inventory F1).
560
+ if (onDisk.length === 0) {
561
+ push("doc-registration", "info", ["no .md files directly under docs/ — nothing to check"]);
562
+ return;
563
+ }
564
+ const unregistered = onDisk.filter((f) => !known.has(f));
565
+ if (unregistered.length) {
566
+ push("doc-registration", "warn", [
567
+ ...unregistered.map((f) => `docs/${f} is not in the artifact manifest — no stated writer, reader, or purpose`),
568
+ "add it to src/lib/artifacts.mjs and re-run `groundwork artifacts`, or delete it — an unregistered doc is ungoverned, not merely undocumented",
569
+ ]);
570
+ } else {
571
+ push("doc-registration", "ok", [`${onDisk.length} doc(s) under docs/, all registered in the manifest`]);
572
+ }
573
+ });
574
+
575
+ // A merge and its phase checkbox, which nothing ties together.
576
+ //
577
+ // Measured elsewhere: a phase read 15 done / 13 open before FIVE component tasks were
578
+ // merged and 15 done / 16 open after — real completion ~20 of 31, reported as 15. A
579
+ // fleet-wide "tick your own checkbox" reminder was already tried and five more boxes
580
+ // accumulated after it, which is why this is a guard and not another note.
581
+ //
582
+ // THE CITATION IS THE TIE, and it has to be: there is no other mechanical link between
583
+ // a merge and a checkbox. A DONE.md entry or a commit subject naming `Phase <N> Task
584
+ // <X.Y>` claims that box. Several tasks per line and per PR are normal (one PR closes
585
+ // 1.1 and 1.2), so every id on the line is read, not just the first.
586
+ //
587
+ // BOTH DIRECTIONS, and the second is the worse error — a ticked box nobody merged
588
+ // anything for reports progress that does not exist. It is enforced PER PHASE and only
589
+ // once that phase cites at least one task, because zero of 67 DONE entries cited a task
590
+ // id when this was written: enforcing it retroactively would flood every phase with
591
+ // findings about a convention that did not exist yet, and a check whose first run
592
+ // prints a hundred lines gets muted rather than read. A phase that has not adopted it
593
+ // says so, at info — silence would read as compliance.
594
+ guard("phase-checkbox", () => {
595
+ if (!exists(phaseDir)) return;
596
+
597
+ // `- [x] 1.1 <text>` inside docs/phases/phase<N>/PHASE*_TASKS.md.
598
+ const TASK_LINE = /^\s*-\s*\[( |x)\]\s*\*{0,2}(\d+\.\d+)\b/i;
599
+ const boxes = new Map(); // "4:1.1" -> { ticked, phase, id, file }
600
+ for (const rel of walk(phaseDir)) {
601
+ if (!/PHASE.*TASKS\.md$/i.test(rel)) continue;
602
+ const n = phaseNumberFromDir(rel.split(path.sep)[0]);
603
+ if (n == null) continue;
604
+ for (const line of readText(path.join(phaseDir, rel)).split("\n")) {
605
+ const m = TASK_LINE.exec(line);
606
+ if (!m) continue;
607
+ boxes.set(`${n}:${m[2]}`, { ticked: m[1] === "x", phase: n, id: m[2], file: rel });
608
+ }
609
+ }
610
+ if (boxes.size === 0) {
611
+ push("phase-checkbox", "info", ["no `N.M` task checkboxes in docs/phases — nothing to tie merges to"]);
612
+ return;
613
+ }
614
+
615
+ const claims = new Map(); // "4:1.1" -> where it was claimed
616
+ const CITE = /\bPhase\s+(\d+)\s+Tasks?\s+((?:\d+\.\d+)(?:\s*(?:,|and|&|\+|\/|·)\s*\d+\.\d+)*)/gi;
617
+ const harvest = (text, where) => {
618
+ for (const m of String(text).matchAll(CITE)) {
619
+ for (const id of String(m[2]).match(/\d+\.\d+/g) ?? []) {
620
+ const k = `${Number(m[1])}:${id}`;
621
+ if (!claims.has(k)) claims.set(k, where);
622
+ }
623
+ }
624
+ };
625
+ const dFile = path.join(docs, "DONE.md");
626
+ if (exists(dFile)) harvest(readText(dFile), "DONE.md");
627
+ // Merged PR subjects, as the item specifies. Offline and bounded; a shallow clone or
628
+ // a non-repo simply contributes no subjects, and DONE.md still carries the claims.
629
+ try {
630
+ const subjects = execFileSync("git", ["log", "-n", "500", "--format=%s"], {
631
+ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
632
+ });
633
+ harvest(subjects, "a merged PR subject");
634
+ } catch {
635
+ /* no git history — DONE.md remains the only claim source */
636
+ }
637
+
638
+ const adopted = new Set([...claims.keys()].map((k) => k.split(":")[0]));
639
+ const problems = [];
640
+ for (const [k, box] of boxes) {
641
+ const claimed = claims.get(k);
642
+ if (claimed && !box.ticked) {
643
+ problems.push(
644
+ `Phase ${box.phase} Task ${box.id} is claimed in ${claimed} but its checkbox is unticked (${box.file}) — the record says done, the file says open`,
645
+ );
646
+ } else if (!claimed && box.ticked && adopted.has(String(box.phase))) {
647
+ problems.push(
648
+ `Phase ${box.phase} Task ${box.id} is ticked but nothing cites it (${box.file}) — no DONE.md entry and no merged PR subject names it, so the progress it reports is unevidenced`,
649
+ );
650
+ }
651
+ }
652
+ const unadopted = [...new Set([...boxes.values()].map((b) => b.phase))].filter((n) => !adopted.has(String(n)));
653
+ if (problems.length) {
654
+ push("phase-checkbox", "warn", [
655
+ ...problems,
656
+ "cite the box in the DONE.md entry or the PR subject as `Phase <N> Task <X.Y>` — that citation is the only mechanical tie between a merge and its checkbox",
657
+ ]);
658
+ } else {
659
+ push("phase-checkbox", "ok", [
660
+ `${boxes.size} task checkbox(es), ${claims.size} cited, none contradicting`,
661
+ ...unadopted.map(
662
+ (n) =>
663
+ `phase${n} cites no task ids yet — a ticked box there is NOT checked against a merge (cite "Phase ${n} Task <X.Y>" in the DONE entry or PR subject)`,
664
+ ),
665
+ ]);
666
+ }
667
+ });
668
+
669
+ // A board that describes work older than the last thing that finished.
670
+ //
671
+ // The queue and DONE.md are written BY the work — a merge touches both as part of
672
+ // itself. The board is written ABOUT it, only if someone remembers, which is why it is
673
+ // the one live doc that goes stale. Confirmed here: the kit's own board sat at zero rows
674
+ // with two lanes live while the roadmap asserted "Steady state (queue empty)" against 28
675
+ // open items.
676
+ //
677
+ // AN EMPTY BOARD IS NOT A STALE BOARD, and that distinction is the whole difficulty —
678
+ // the same one that produced the UNKNOWN badge (ADR-013). No rows means no work in
679
+ // flight, which is a legitimate state and reports as such. Staleness is only meaningful
680
+ // about rows that CLAIM to be live.
681
+ guard("board-freshness", () => {
682
+ const wsFile = path.join(docs, "WORKSTREAMS.md");
683
+ const dFile = path.join(docs, "DONE.md");
684
+ if (!exists(wsFile) || !exists(dFile)) return;
685
+ const rows = workstreamsV1RowsOf(parseWorkDoc(readText(wsFile)));
686
+ if (rows.length === 0) {
687
+ push("board-freshness", "ok", ["board has no in-flight rows — an empty board is not a stale board"]);
688
+ return;
689
+ }
690
+ const dates = doneEntriesOf(parseWorkDoc(readText(dFile)))
691
+ .map((e) => e.date)
692
+ .filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(String(d)))
693
+ .sort();
694
+ const newestDone = dates[dates.length - 1];
695
+ if (!newestDone) {
696
+ push("board-freshness", "info", ["no dated DONE.md entry to compare the board against"]);
697
+ return;
698
+ }
699
+ let boardWritten;
700
+ try {
701
+ boardWritten = execFileSync("git", ["log", "-1", "--format=%cs", "--", "WORKSTREAMS.md"], {
702
+ cwd: docs, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
703
+ }).trim();
704
+ } catch {
705
+ boardWritten = "";
706
+ }
707
+ if (!boardWritten) {
708
+ push("board-freshness", "info", [
709
+ "no commit date for WORKSTREAMS.md (not a git repo, or the board is untracked) — freshness NOT checked, which is different from fresh",
710
+ ]);
711
+ return;
712
+ }
713
+ if (boardWritten < newestDone) {
714
+ push("board-freshness", "warn", [
715
+ `board last written ${boardWritten}, newest DONE.md entry ${newestDone} — ${rows.length} row(s) claim to be live and predate the last completion`,
716
+ "a merge writes DONE.md as part of the work and the board only if someone remembers; re-derive the rows or close them",
717
+ ]);
718
+ } else {
719
+ push("board-freshness", "ok", [`board written ${boardWritten}, newest completion ${newestDone} — not behind`]);
720
+ }
721
+ });
722
+
723
+ // A citation whose ADDRESS is dead while its CLAIM is still live.
724
+ //
725
+ // A caveat is an assertion with a shelf life and nothing expires it, so it outlives
726
+ // its subject and becomes evidence for something no longer true. Only one half of
727
+ // that is mechanizable, and the line is where a consumer fleet drew it and we agree:
728
+ // **a citation can be checked for whether it still RESOLVES. Whether the text it
729
+ // points at still MEANS what the citer claimed cannot be checked, only stated.**
730
+ // This check does the first and says so about the second.
731
+ //
732
+ // IT UNDER-REPORTS ON PURPOSE, and that is the whole design. A naive resolver keyed
733
+ // on the repo root was run over this corpus first: 52 citations → 34 "target not
734
+ // found" + 5 "line out of range". Nearly all of it was the resolver being wrong —
735
+ // citations here are written against an IMPLIED PACKAGE ROOT (`roles.ts:60` means
736
+ // `packages/coord-mcp/src/roles.ts`), and a monorepo has 12 files called README.md.
737
+ // A confident 39 findings would have sent someone to "fix" citations that were
738
+ // correct, which is the same defect the check exists to catch, reproduced inside it.
739
+ // So: a citation is reported ONLY when its file resolves to exactly ONE candidate and
740
+ // the line is past that file's end. Ambiguous and unresolved ones are COUNTED and
741
+ // named as a limit, never as findings.
742
+ guard("citation-rot", () => {
743
+ const CITE = /`([A-Za-z0-9_./-]+\.(?:md|mjs|ts|tsx|json|sh)):(\d+)`/g;
744
+ const roots = [root, docs];
745
+ const pkgDir = path.join(root, "packages");
746
+ if (exists(pkgDir)) for (const d of listDirs(pkgDir)) roots.push(path.join(pkgDir, d), path.join(pkgDir, d, "src"));
747
+ const lineCount = (f) => {
748
+ // A trailing newline is a terminator, not a line — counting it lets a citation
749
+ // one past the end resolve as in-range, which is the only off-by-one that
750
+ // matters here because it under-reports.
751
+ try { const t = readText(f).replace(/\n$/, ""); return t === "" ? 0 : t.split("\n").length; } catch { return null; }
752
+ };
753
+ // WORK-DOCS ARE EXCLUDED, and this was measured rather than assumed: the first run
754
+ // flagged `DONE.md:343` twice, cited in a queue item — a citation belonging to
755
+ // ANOTHER FLEET's repo, quoted here as evidence in a report about dead citations.
756
+ // It resolved against our `docs/DONE.md` and was confidently wrong about a file it
757
+ // was never pointing at. A queue item is a REPORT containing quoted evidence; its
758
+ // citations belong to whoever reported them. Ours are the ones in our own docs.
759
+ const workDocs = new Set([...queueDocs, ...declaredDocs("done.v1")]);
760
+ const dead = [];
761
+ let checked = 0, ambiguous = 0, unresolved = 0, quoted = 0;
762
+ for (const rel of mdFiles) {
763
+ if (workDocs.has(rel)) {
764
+ quoted += [...readText(path.join(docs, rel)).matchAll(CITE)].length;
765
+ continue;
766
+ }
767
+ const text = readText(path.join(docs, rel));
768
+ for (const m of text.matchAll(CITE)) {
769
+ const [, cited, lineStr] = m;
770
+ const hits = roots.map((r) => path.join(r, cited)).filter((f) => exists(f));
771
+ const uniq = [...new Set(hits)];
772
+ if (uniq.length === 0) { unresolved++; continue; }
773
+ if (uniq.length > 1) { ambiguous++; continue; }
774
+ checked++;
775
+ const n = lineCount(uniq[0]);
776
+ if (n !== null && Number(lineStr) > n) {
777
+ dead.push(`docs/${rel} cites \`${cited}:${lineStr}\` but ${path.relative(root, uniq[0])} has ${n} line(s) — the address is dead, the claim may not be`);
778
+ }
779
+ }
780
+ }
781
+ const limit =
782
+ `${checked} citation(s) resolved and checked · ${ambiguous} ambiguous (a monorepo has many files of the same name) · ${unresolved} unresolved (written against an implied package root) · ` +
783
+ `${quoted} skipped in work-docs (quoted evidence, often another repo's) — ` +
784
+ "and this check verifies only that a citation RESOLVES, never that the cited text still says what the citer claimed";
785
+ if (dead.length) push("citation-rot", "warn", [...dead, limit]);
786
+ else push("citation-rot", "ok", [limit]);
787
+ });
788
+
789
+ // A document asserting a version, checked for TRUTH rather than freshness.
790
+ //
791
+ // `facts-freshness` already chases entries older than 14 days. Nothing compared a
792
+ // claim against the mechanically-available source, so **a fact can be recently
793
+ // verified and false** — five version facts were wrong at once, four of them in the
794
+ // direction that reads healthier than reality.
795
+ //
796
+ // THE TWO CHECKS STAY SEPARATE, deliberately: a truth failure must not be reportable
797
+ // as a freshness pass or vice versa, or one's green gets read as the other's. Same
798
+ // reason the publish-path guards stay split (staleness vs inertness).
799
+ //
800
+ // SCOPE IS ANY DOCUMENT, not just FACTS.md — the wrong versions were in a version
801
+ // table, which is precisely the document a reader trusts most.
802
+ //
803
+ // IT CHECKS ROWS, NOT PROSE, and that is a deliberate under-reach. Prose legitimately
804
+ // discusses old versions ("0.5.10 shipped that defect") and flagging those would bury
805
+ // the real finding under history. A markdown table row naming a package is making a
806
+ // present-tense claim about it; if the authoritative version appears nowhere in that
807
+ // row, the row is asserting something else.
808
+ guard("version-truth", () => {
809
+ const kitFile = path.join(root, "kit.json");
810
+ if (!exists(kitFile)) return;
811
+ let pkgs;
812
+ try {
813
+ const kit = JSON.parse(readText(kitFile));
814
+ pkgs = Object.values(kit.packages ?? {}).filter((p) => p?.name && p?.version);
815
+ } catch (e) {
816
+ push("version-truth", "warn", [`kit.json is unreadable (${e.message}) — version claims NOT checked, which is not the same as true`]);
817
+ return;
818
+ }
819
+ if (!pkgs.length) {
820
+ push("version-truth", "info", ["kit.json declares no packages — nothing to check against"]);
821
+ return;
822
+ }
823
+ const stale = [];
824
+ let rows = 0;
825
+ let scanned = 0;
826
+ for (const rel of mdFiles) {
827
+ const text = readText(path.join(docs, rel));
828
+ // ONLY DOCUMENTS THAT CLAIM CURRENCY, detected by their own freshness marker
829
+ // rather than by a filename list. A dated design document stating the versions
830
+ // current when it was written is CORRECT, and the first run flagged nine such
831
+ // rows in `DESIGN.md`, a 2026-08-16 snapshot. Reporting those buries the real
832
+ // finding under history and teaches the reader to skim it.
833
+ // The marker must be in the HEADER, not anywhere in the prose: `DESIGN.md`
834
+ // contains the string `verified:` inside a CODE BLOCK 762 lines down, which is
835
+ // a type definition and not a currency claim about the document. A freshness
836
+ // stamp lives at the top or it is not a stamp.
837
+ if (!/Last audited|verified:/i.test(text.split("\n").slice(0, 15).join("\n"))) continue;
838
+ scanned++;
839
+ const lines = text.split("\n");
840
+ lines.forEach((line, i) => {
841
+ if (!line.startsWith("|")) return;
842
+ for (const p of pkgs) {
843
+ // Whole-name match. `@davidbalzan/groundwork-seam` CONTAINS
844
+ // `@davidbalzan/groundwork`, so `includes` reported the seam row as a
845
+ // groundwork row and named the wrong expected version — a substring match
846
+ // presented as a structural fact, in the check, one commit after that rule
847
+ // went into the canon.
848
+ if (!new RegExp(`${p.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w./-])`).test(line)) continue;
849
+ const seen = [...line.matchAll(/\b\d+\.\d+\.\d+\b/g)].map((m) => m[0]);
850
+ // A row naming a package but asserting NO version is not making a claim
851
+ // this check can contradict — a file map, a path table, an ownership row.
852
+ // The first run flagged eight of those, which is the over-report direction:
853
+ // it would send someone to add version numbers to a directory listing.
854
+ if (!seen.length) continue;
855
+ rows++;
856
+ if (seen.includes(p.version)) continue;
857
+ stale.push(
858
+ `docs/${rel}:${i + 1} states ${p.name} at ${seen.join(", ")} — kit.json says ${p.version}`,
859
+ );
860
+ }
861
+ });
862
+ }
863
+ const tally = `${rows} version-asserting row(s) in ${scanned} doc(s) that claim currency (a dated snapshot is not checked — it was true when written), against kit.json`;
864
+ if (stale.length) {
865
+ push("version-truth", "warn", [
866
+ ...stale,
867
+ `${tally} — a recently-verified fact can still be false, which is why this is not the freshness check`,
868
+ ]);
869
+ } else {
870
+ push("version-truth", "ok", [`${tally}, all current — and this checks TRUTH only; freshness is a separate check for a separate failure`]);
871
+ }
872
+ });
873
+
175
874
  const factsFile = path.join(docs, "FACTS.md");
176
875
  guard("facts-freshness", () => {
177
876
  if (exists(factsFile)) {
@@ -238,7 +937,13 @@ export function doctor(targetDir, opts = {}) {
238
937
  "facts-freshness": "Facts",
239
938
  "adr-tripwire": "ADR tripwire",
240
939
  };
241
- for (const c of report.checks) {
940
+ // WARNINGS FIRST in the human report. The same reason the queue advisory was
941
+ // demoted: a hard failure printed below a pile of advisories is a hard failure
942
+ // nobody reads. `--json` keeps registration order — a programmatic consumer
943
+ // sorts for itself and should not have its shape changed underneath it.
944
+ const rank = (c) => (c.level === "warn" ? 0 : c.level === "info" ? 2 : 1);
945
+ const ordered = [...report.checks].sort((a, b) => rank(a) - rank(b));
946
+ for (const c of ordered) {
242
947
  log.heading(titles[c.check] ?? c.check);
243
948
  for (const item of c.items) {
244
949
  if (c.level === "warn") console.log(` ${yellow("⚠")} ${item}`);
@@ -6,14 +6,40 @@
6
6
  * ("reference" = generic, same across projects → refreshable; "project" = yours → never touched)
7
7
  *
8
8
  * Add a doc in ONE place: append an entry here, then `groundwork artifacts` regenerates the map.
9
+ *
10
+ * `writtenBy` HOLDS TWO KINDS OF VALUE and the column says which by its shape:
11
+ * - COMMAND-shaped (`/kickstart`, `/plan-phase`) — a skill generates the doc.
12
+ * - ROLE-shaped (`aide`, `coordinator`, `David`) — a person or agent owns it,
13
+ * and ownership means keeping it HONEST, not licence to rewrite what it records.
14
+ * Stated here rather than inferred, because a column quietly meaning two things is
15
+ * exactly how `(+ CONTEXT-MAP.md)` came to read as a registration to a human and as
16
+ * prose to every parser.
9
17
  */
10
18
 
11
19
  /** @typedef {"project"|"reference"|"external"} Scope */
20
+ /**
21
+ * The write grammar a doc is parsed under, when it has one. This is what makes
22
+ * the manifest the SOURCE OF THE POPULATION for every work-doc check instead of
23
+ * a hardcoded filename, and it exists because the population moved: 31 items were
24
+ * moved out of `docs/QUEUE.md` into a phase file four hours after three checks
25
+ * shipped watching `QUEUE.md` and nothing else. Every check reported green about
26
+ * a file holding two thirds of the work.
27
+ *
28
+ * A `phaseN` segment is a placeholder — a consumer expands it against the phase
29
+ * directories that exist.
30
+ * @typedef {"queue.v1"|"done.v1"|"workstreams.v1"} Grammar
31
+ */
12
32
 
13
33
  export const ARTIFACTS = [
14
34
  // --- project artifacts (generated per project; vary; never auto-refreshed) ---
15
35
  { path: "docs/PRD.md", scope: "project", purpose: "Product definition (problem, users, goals, scope, requirements)", writtenBy: "/create-prd", readBy: "/kickstart, /plan-phase", rules: "First artifact; the source for scaffolding" },
16
- { path: "docs/CONTEXT.md (+ CONTEXT-MAP.md)", scope: "project", purpose: "Ubiquitous-language glossary / domain model", writtenBy: "/domain-model", readBy: "/start-session, /plan-phase", rules: "Glossary only — no implementation detail" },
36
+ { path: "docs/CONTEXT.md", scope: "project", purpose: "Ubiquitous-language glossary / domain model", writtenBy: "/domain-model", readBy: "/start-session, /plan-phase", rules: "Glossary only — no implementation detail" },
37
+ // Declared on its OWN LINE, not as "(+ CONTEXT-MAP.md)" inside another path.
38
+ // A parenthetical reads as registration to a human and as prose to every
39
+ // checker: `doc-registration` strips it, so the file was ungoverned while a
40
+ // grep for its name in this file returned a hit. A carrier that satisfies a
41
+ // reader and not a parser is the same trap as the PR-title citation.
42
+ { path: "docs/CONTEXT-MAP.md", scope: "project", purpose: "Generated map of the domain model (companion to CONTEXT.md)", writtenBy: "aide (generated by /domain-model)", readBy: "/start-session, /plan-phase", rules: "David 2026-08-28: the aide owns it. Regenerated with CONTEXT.md; ownership is keeping it honest, not hand-editing the generator's output" },
17
43
  { path: "docs/TECH_STACK.md", scope: "project", purpose: "Technology choices narrative", writtenBy: "/kickstart", readBy: "/plan-phase, /start-session", rules: "No version numbers here — link to STACK_MAP" },
18
44
  { path: "docs/STACK_MAP.md", scope: "project", purpose: "Single source of truth for versions (pinned + latest)", writtenBy: "/kickstart, /check-versions", readBy: "anyone bumping deps", rules: "The only place a version appears" },
19
45
  { path: "docs/ARCHITECTURE_GUIDE.md", scope: "project", purpose: "System design, patterns, the \"why\"", writtenBy: "/kickstart", readBy: "/plan-phase", rules: "—" },
@@ -21,11 +47,14 @@ export const ARTIFACTS = [
21
47
  { path: "docs/PRODUCTION_ROADMAP.md", scope: "project", purpose: "Phase roadmap + \"Current Status\" pointer", writtenBy: "/kickstart, /plan-phase", readBy: "/start-session, groundwork status", rules: "Current Status points at WORKSTREAMS" },
22
48
  { path: "docs/phases/phaseN/README.md", scope: "project", purpose: "Phase overview", writtenBy: "/plan-phase", readBy: "/start-session", rules: "—" },
23
49
  { path: "docs/phases/phaseN/PHASEN_TASKS.md", scope: "project", purpose: "Detailed checkbox tasks", writtenBy: "/plan-phase", readBy: "/check-task, /start-session, groundwork status", rules: "Progress recomputed by the helper script" },
24
- { path: "docs/WORKSTREAMS.md", scope: "project", purpose: "Live state of parallel streams", writtenBy: "/update-workstreams + coordinator", readBy: "everyone, /start-session", rules: "One row per active stream" },
25
- { path: "docs/QUEUE.md", scope: "project", purpose: "Inbound queue (phases + ad-hoc)", writtenBy: "/plan-phase + human", readBy: "/start-session, coordinator", rules: "Single writer per file: human/proxy only; executors never edit it" },
26
- { path: "docs/DONE.md", scope: "project", purpose: "Completion log", writtenBy: "executor (solo you or coordinator)", readBy: "/start-session, humans", rules: "Append-only; sole executor write in the queue seam; pinned em-dash+middot line format" },
50
+ { path: "docs/phases/phaseN/ITEMS.md", grammar: "queue.v1", scope: "project", purpose: "Queue items pulled into a phase, verbatim (the phase's working set)", writtenBy: "aide curates; coordinator sets status", readBy: "groundwork doctor, whoever executes the phase", rules: "queue.v1 items live under a `## Queue` h2; `### Task N` groups them. Watched by the same checks as QUEUE.md" },
51
+ { path: "docs/WORKSTREAMS.md", grammar: "workstreams.v1", scope: "project", purpose: "Live state of parallel streams", writtenBy: "/update-workstreams + coordinator", readBy: "everyone, /start-session", rules: "One row per active stream" },
52
+ { path: "docs/QUEUE.md", grammar: "queue.v1", scope: "project", purpose: "Inbound queue (phases + ad-hoc)", writtenBy: "/plan-phase + human", readBy: "/start-session, coordinator", rules: "Single writer per file: human/proxy only; executors never edit it" },
53
+ { path: "docs/DONE.md", grammar: "done.v1", scope: "project", purpose: "Completion log", writtenBy: "executor (solo you or coordinator)", readBy: "/start-session, humans", rules: "Append-only; sole executor write in the queue seam; pinned em-dash+middot line format" },
27
54
  { path: "docs/DESIGN_SYSTEM.md", scope: "project", purpose: "Visual language (optional)", writtenBy: "/kickstart", readBy: "frontend work", rules: "Only when there's a UI" },
28
55
  { path: "docs/FACTS.md", scope: "project", purpose: "Verified project facts (settled world-model)", writtenBy: "whoever verified (or set-fact.mjs)", readBy: "everyone, groundwork doctor", rules: "One writer per fact; entries carry verified/by/method; doctor flags stale (>14d)" },
56
+ { path: "docs/DESIGN.md", scope: "project", purpose: "Monorepo structure + seam-extraction design (organization, package seams, file map, PR sequence)", writtenBy: "aide", readBy: "anyone touching package boundaries", rules: "David 2026-08-28: the aide owns it. ADR-governed (ADR-010) — ownership means keeping it HONEST, not licence to rewrite the spec" },
57
+ { path: "docs/NAMES.md", scope: "project", purpose: "Locked names — repo, packages, publish names, directories", writtenBy: "aide", readBy: "everyone", rules: "David 2026-08-28: the aide owns it. Names LOCKED by David 2026-08-16 — do not reopen in implementation PRs; a rename is David's call" },
29
58
  { path: "docs/_INDEX.md", scope: "project", purpose: "Obsidian Map of Content (human navigation)", writtenBy: "/kickstart (scaffold), then you", readBy: "humans, agents navigating docs", rules: "Curated per project — installed once, never auto-refreshed" },
30
59
 
31
60
  // --- reference (shipped, generic, same across projects → refreshable by `update --docs`) ---
@@ -40,6 +69,16 @@ export const ARTIFACTS = [
40
69
  { path: "$GROUNDWORK_KNOWLEDGE/adr/NNNN-*.md", scope: "external", purpose: "Formal cross-project ADRs", writtenBy: "/remember --adr" },
41
70
  ];
42
71
 
72
+ /**
73
+ * Every path the manifest DECLARES under a given write grammar, `phaseN`
74
+ * placeholders included. The point is that no checker names a file: adding a
75
+ * fifth work-doc is a manifest entry, and forgetting to add one is what the
76
+ * undeclared-work-doc control catches from the other side.
77
+ */
78
+ export function workDocPaths(grammar) {
79
+ return ARTIFACTS.filter((a) => a.grammar === grammar).map((a) => a.path);
80
+ }
81
+
43
82
  /** Doc paths that `update --docs` may safely refresh (generic, not project-authored). */
44
83
  export function refreshableDocs() {
45
84
  return ARTIFACTS.filter((a) => a.scope === "reference" && a.path.endsWith(".md")).map(