@davidbalzan/groundwork 0.4.0 → 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/LICENSE +8 -10
- package/package.json +5 -4
- package/payload/doc-templates/FACTS.md +2 -2
- package/payload/doc-templates/WORKSTREAMS.md +7 -1
- package/payload/doc-templates/templates/QUEUE_TEMPLATE.md +6 -6
- package/payload/skills/update-workstreams/SKILL.md +6 -3
- package/src/commands/doctor.mjs +715 -10
- package/src/lib/adr-tripwire.mjs +2 -2
- package/src/lib/artifacts.mjs +43 -4
package/LICENSE
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
1
|
Copyright (c) 2026 David Balzan
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense,
|
|
9
|
-
copies of
|
|
10
|
-
|
|
3
|
+
All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software and its accompanying files are proprietary. No licence is
|
|
6
|
+
granted to use, copy, modify, merge, publish, distribute, sublicense, or sell
|
|
7
|
+
copies of this software, in whole or in part, except by a separate written
|
|
8
|
+
agreement signed by the copyright holder.
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
Possession of these files — including by download from a public package
|
|
11
|
+
registry — does not constitute such an agreement and grants no rights.
|
|
14
12
|
|
|
15
13
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
14
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidbalzan/groundwork",
|
|
3
|
-
"version": "0.4.
|
|
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": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"src",
|
|
14
14
|
"payload",
|
|
15
15
|
"docs",
|
|
16
|
-
"README.md"
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
17
18
|
],
|
|
18
19
|
"keywords": [
|
|
19
20
|
"ai",
|
|
@@ -26,9 +27,9 @@
|
|
|
26
27
|
"methodology"
|
|
27
28
|
],
|
|
28
29
|
"author": "David Balzan",
|
|
29
|
-
"license": "
|
|
30
|
+
"license": "UNLICENSED",
|
|
30
31
|
"dependencies": {
|
|
31
|
-
"@davidbalzan/groundwork-seam": "0.1.
|
|
32
|
+
"@davidbalzan/groundwork-seam": "0.1.6"
|
|
32
33
|
},
|
|
33
34
|
"scripts": {
|
|
34
35
|
"groundwork": "node src/cli.mjs",
|
|
@@ -38,6 +38,6 @@ glyphs, not ASCII. Parsers (doctor, `set-fact.mjs`, UIs) split on those glyphs.
|
|
|
38
38
|
## Facts
|
|
39
39
|
|
|
40
40
|
<!-- add entries in the pinned format above, e.g.:
|
|
41
|
-
- `
|
|
42
|
-
verified: 2026-07-02T14:30Z · by:
|
|
41
|
+
- `nightly-export-job` — does NOT exist anywhere in the codebase
|
|
42
|
+
verified: 2026-07-02T14:30Z · by: <project>-worker-1 · method: git grep + gh api origin/main
|
|
43
43
|
-->
|
|
@@ -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
|
|
9
|
-
reorder / remove / edit items, set priority (`P1`/`P2`/`P3`; top-to-bottom breaks
|
|
10
|
-
constraints, acceptance criteria,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
the
|
|
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.
|
|
57
|
-
branch + worktree so any agent can resume the exact context. `Blocker` names the
|
|
58
|
-
(another stream, a decision, a person), not just "yes"
|
|
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
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -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
|
|
159
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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}`);
|
package/src/lib/adr-tripwire.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
*
|
|
16
16
|
* Shared-token suppression: tokens that also appear in the ADR title / Decision / Context
|
|
17
17
|
* (alias-expanded) are the accepted path, not evidence of the reject. Generic project-wide
|
|
18
|
-
* paths (`docs`, `templates`, `readme`, `wiki`) are not hits.
|
|
18
|
+
* paths (`docs`, `templates`, `readme`, `wiki`) are not hits. A consumer fleet burned an audit cycle on
|
|
19
19
|
* `next` (accepted Next.js), top-level `docs/`, and `docs/templates`.
|
|
20
20
|
*/
|
|
21
21
|
|
|
@@ -85,7 +85,7 @@ const STOP = new Set(["the", "and", "with", "only", "over", "for", "app", "api",
|
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
87
|
* Top-level / workspace path tokens that every repo tends to have. A hit on these is
|
|
88
|
-
* project-wide, not evidence of a rejected alternative (
|
|
88
|
+
* project-wide, not evidence of a rejected alternative (field report: `docs/`, `docs/templates`).
|
|
89
89
|
* Dependency names are not filtered here — `next` still fires unless it is in `own`.
|
|
90
90
|
*/
|
|
91
91
|
export const GENERIC_PATHS = new Set(["docs", "templates", "readme", "wiki"]);
|
package/src/lib/artifacts.mjs
CHANGED
|
@@ -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
|
|
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/
|
|
25
|
-
{ path: "docs/
|
|
26
|
-
{ path: "docs/
|
|
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(
|