@davidbalzan/groundwork 0.4.3 → 0.4.5

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.3",
3
+ "version": "0.4.5",
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.7"
32
+ "@davidbalzan/groundwork-seam": "0.1.8"
33
33
  },
34
34
  "scripts": {
35
35
  "groundwork": "node src/cli.mjs",
@@ -0,0 +1,43 @@
1
+ # Needs David
2
+
3
+ Work the fleet **cannot do for itself**: things requiring David's hands, his
4
+ accounts, his money, or his ruling.
5
+
6
+ > **This file is not a queue, and the distinction is the reason it exists.**
7
+ > Without it, David-blocked items sit in `QUEUE.md` at P1 looking exactly like
8
+ > work the fleet can pick up — which is how a licence item read as actionable
9
+ > for two days while nobody could have actioned it.
10
+ >
11
+ > So entries here carry **no `- [ ] (Pn)` checkbox lines**. That is deliberate
12
+ > and load-bearing: a David-blocked list that parses as a queue *is* a queue,
13
+ > and every queue check would start reading it as available work. If you find
14
+ > yourself wanting a priority marker here, the item probably belongs in
15
+ > `QUEUE.md` with a note that it is blocked.
16
+
17
+ ## How this differs from its neighbours
18
+
19
+ - **`QUEUE.md`** — work the fleet can do. Prioritised, claimable, closeable.
20
+ - **`WORKSTREAMS.md` § Needs David** — decisions in flight *right now*, with a
21
+ recommendation attached. Cleared within a session.
22
+ - **this file** — the standing list of what is blocked ON DAVID and stays
23
+ blocked until he acts. It outlives any one session.
24
+
25
+ ## Blocked on David
26
+
27
+ *One `###` block per item. Delete this heading's examples when the first real
28
+ one lands.*
29
+
30
+ ### <short title>
31
+
32
+ - **Needs:** what only David can do (publish · pay · approve · decide · access)
33
+ - **Why it cannot be delegated:** the specific thing an agent lacks — a
34
+ credential, an account, an authority. "It is important" is not a reason.
35
+ - **Blocked since:** YYYY-MM-DD
36
+ - **Blocking:** what is waiting on it, or `nothing — it is just owed`
37
+ - **If David does nothing:** the actual consequence, written plainly. An item
38
+ with no consequence for inaction is a preference, not a blocker.
39
+
40
+ ## Cleared
41
+
42
+ *Move items here with the date and what David decided. Kept because "we asked
43
+ and he said no" is a fact worth not re-litigating in three weeks.*
@@ -7,7 +7,7 @@ import { countCheckboxes, progressBar } from "../lib/progress.mjs";
7
7
  import { ARTIFACTS, workDocPaths } from "../lib/artifacts.mjs";
8
8
  import { log, bold, green, yellow, dim, cyan } from "../lib/log.mjs";
9
9
  import { adrTripwire } from "../lib/adr-tripwire.mjs";
10
- import { parseFactsDoc, parseWorkDoc, workDocIssues, workDocLegacyWriteIssues, workDocIssuesDetailed, queueItemsOf, doneEntriesOf, workstreamsV1RowsOf, sweepTagOf, sweepTagCensus } from "@davidbalzan/groundwork-seam";
10
+ import { phaseCitationsIn, parseFactsDoc, parseWorkDoc, workDocIssues, workDocLegacyWriteIssues, workDocIssuesDetailed, queueItemsOf, doneEntriesOf, workstreamsV1RowsOf, sweepTagOf, sweepTagCensus, refsIn, refIn } from "@davidbalzan/groundwork-seam";
11
11
 
12
12
  /**
13
13
  * `groundwork doctor` — flag drift between the docs and reality. Offline + deterministic.
@@ -288,23 +288,40 @@ guard("queue-done-loop", () => {
288
288
  ]);
289
289
  return;
290
290
  }
291
- const doneRefs = new Set(
292
- doneEntries.map((e) => String(e.ref ?? "").match(/#(\d+)/)?.[1]).filter(Boolean),
293
- );
291
+ // REFS ARE COMPARED WITH THE SHARED PRIMITIVE, not by number.
292
+ //
293
+ // This matched on the bare NUMBER, so an item whose body cited `console#39`
294
+ // tied to a `DONE.md` entry for `groundwork-kit#39` — two repositories, one
295
+ // number — and the check reported GREEN over the exact gap it exists to find.
296
+ // Measured live: `q-142d95da` was closed by #119 with no #119 entry in DONE.md,
297
+ // and this printed "1 closed item(s), each matched to a DONE.md entry".
298
+ //
299
+ // Fourth matcher in one day to re-derive ref/tag matching and get it wrong. The
300
+ // primitive lives in the seam now so the fifth one written cannot.
301
+ const doneRefs = doneEntries.flatMap((e) => refsIn(String(e.ref ?? "")));
294
302
  const problems = [];
295
303
  for (const item of closed) {
296
- const cited = [...String(item.text).matchAll(REF)].map((m) => m[1]);
304
+ const cited = refsIn(String(item.text));
297
305
  const short = String(item.text).replace(/\s+/g, " ").slice(0, 70);
298
306
  if (cited.length === 0) {
299
307
  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}…"`);
308
+ } else if (!cited.some((r) => refIn(r, doneRefs))) {
309
+ // NOT "this closure is unlogged" the check cannot know that. The citation
310
+ // is the only tie it has, and an item whose body never names the PR that
311
+ // closed it is UNTIEABLE, even when DONE.md records the closure perfectly.
312
+ // Measured live: an item citing `kit#84` and `console#39` was closed by
313
+ // #119, and #119 IS in DONE.md — the item simply never mentions it.
314
+ // Reporting that as "no entry" would send someone to add a duplicate.
315
+ problems.push(
316
+ `${item.where}: [x] item cites ${cited.map((r) => r.raw).join(", ")}, none of which DONE.md records — so this closure CANNOT BE TIED by citation. ` +
317
+ `It may still be logged under a ref the item does not name; the tie, not the log, is what is missing — "${short}…"`,
318
+ );
302
319
  }
303
320
  }
304
321
  if (problems.length) {
305
322
  push("queue-done-loop", "warn", [
306
323
  ...problems,
307
- "move the item's closure into DONE.mdthe queue is inbound work, DONE.md is the completion log",
324
+ "either DONE.md is missing the entry, or the item does not cite the PR that closed it those are different fixes, and the citation is the only thing that distinguishes them",
308
325
  ]);
309
326
  } else {
310
327
  push("queue-done-loop", "ok", [`${closed.length} closed item(s), each matched to a DONE.md entry`]);
@@ -595,7 +612,9 @@ guard("phase-checkbox", () => {
595
612
  if (!exists(phaseDir)) return;
596
613
 
597
614
  // `- [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;
615
+ // A TASK ID MAY CARRY A LETTER SUFFIX — `3.3b` is a disposition clause, and this
616
+ // convention adds them constantly.
617
+ const TASK_LINE = /^\s*-\s*\[( |x)\]\s*\*{0,2}(\d+\.\d+[a-z]?)\b/i;
599
618
  const boxes = new Map(); // "4:1.1" -> { ticked, phase, id, file }
600
619
  for (const rel of walk(phaseDir)) {
601
620
  if (!/PHASE.*TASKS\.md$/i.test(rel)) continue;
@@ -613,14 +632,33 @@ guard("phase-checkbox", () => {
613
632
  }
614
633
 
615
634
  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;
635
+ // B-CLAUSES BROKE THIS TWO WAYS, AND THE SECOND IS WORSE THAN THE TRUNCATION.
636
+ //
637
+ // The id pattern was `\d+\.\d+`, so `3.3b` matched as `3.3`:
638
+ // `Phase 5 Task 3.1, 3.2, 3.3b, 3.4, 3.5` captured 3.1, 3.2, 3.3 — the list
639
+ // SILENTLY TRUNCATED at the b-clause and 3.4/3.5 went from cited to
640
+ // unevidenced with no warning.
641
+ // `Phase 5 Task 3.3b` credited `3.3` — a MISATTRIBUTION, ticking a different
642
+ // box than the one cited. The right answer arrived for the wrong reason once
643
+ // today and would have credited a false claim just as readily.
644
+ //
645
+ // A GRAMMAR THAT DEGRADES IN PROPORTION TO BEING USED is worse than one that
646
+ // fails randomly, because adoption looks like decay — and every disposition
647
+ // clause this convention adds is a b-clause.
648
+ //
649
+ // GRANULARITY THIS FIX COVERS: the ID TOKEN — `3.3` and `3.3b` are now distinct
650
+ // ids, in both the citation and the checkbox.
651
+ // THE NEXT ONE DOWN, STATED RATHER THAN DISCOVERED: a deeper id (`3.3b.i`, or a
652
+ // two-letter `3.3ab`) still truncates the list at that point. Nothing in the
653
+ // corpus uses one today; if that changes, this is the line that needs widening,
654
+ // and the same silent-truncation failure returns until it is.
655
+ // ONE GRAMMAR, SHARED THROUGH SEAM. This was a local regex until the bus's
656
+ // `merge` verb needed the same answer at merge time; ADR-004 forbids a
657
+ // groundwork↔mcp edge, so a copy in each package was the obvious route — and
658
+ // a copied grammar is two grammars the moment one is fixed. This very token
659
+ // was widened for b-clauses today, which a second copy would have missed.
617
660
  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
- }
661
+ for (const k of phaseCitationsIn(text)) if (!claims.has(k)) claims.set(k, where);
624
662
  };
625
663
  const dFile = path.join(docs, "DONE.md");
626
664
  if (exists(dFile)) harvest(readText(dFile), "DONE.md");
@@ -860,7 +898,48 @@ guard("version-truth", () => {
860
898
  }
861
899
  });
862
900
  }
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`;
901
+ // THE PUBLISHED COLUMN IS A CLAIM TOO, and it is checked BY COLUMN.
902
+ //
903
+ // My first attempt at this compared every version in the ROW and passed as soon
904
+ // as one matched — which is the occurrence-not-position error at column
905
+ // granularity, in the check I wrote after landing that rule. It false-positived
906
+ // on a workspace-deps table and MISSED the live case: `STACK_MAP` stated
907
+ // playbook pinned 0.5.17 and published 0.5.16, and the row contained 0.5.17 so
908
+ // it passed. Second time in one day a bump left that table behind.
909
+ //
910
+ // So the header decides which cell is which. A table without a `Latest
911
+ // published` column makes no published claim and is not checked — absence of a
912
+ // claim is not a wrong claim.
913
+ for (const rel of mdFiles) {
914
+ const text = readText(path.join(docs, rel));
915
+ if (!/Last audited|verified:/i.test(text.split("\n").slice(0, 15).join("\n"))) continue;
916
+ const lines = text.split("\n");
917
+ let col = -1;
918
+ lines.forEach((line, i) => {
919
+ if (!line.startsWith("|")) {
920
+ col = -1;
921
+ return;
922
+ }
923
+ const cells = line.split("|").slice(1, -1).map((c) => c.trim());
924
+ if (/latest published/i.test(line)) {
925
+ col = cells.findIndex((c) => /latest published/i.test(c));
926
+ return;
927
+ }
928
+ if (col < 0 || col >= cells.length) return;
929
+ const named = pkgs.find((p) =>
930
+ p.published && new RegExp(`${p.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w./-])`).test(line),
931
+ );
932
+ if (!named) return;
933
+ const claim = (cells[col].match(/\b\d+\.\d+\.\d+\b/) ?? [])[0];
934
+ if (!claim || claim === named.published) return;
935
+ stale.push(
936
+ `docs/${rel}:${i + 1} states ${named.name} PUBLISHED at ${claim} — kit.json records ${named.published}. ` +
937
+ `Checked by COLUMN: a row can carry the right pinned version and a stale published one, and comparing the whole row passes on the pinned half.`,
938
+ );
939
+ });
940
+ }
941
+
942
+ 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's version AND published records`;
864
943
  if (stale.length) {
865
944
  push("version-truth", "warn", [
866
945
  ...stale,
@@ -910,6 +989,47 @@ guard("sweep-census", () => {
910
989
  ]);
911
990
  });
912
991
 
992
+ // A pin that states a count and cannot reproduce it.
993
+ //
994
+ // A STATED METHOD THAT CONTRADICTS ITS OWN PUBLISHED RESULT IS WORSE THAN NO
995
+ // METHOD: it looks auditable, so it invites exactly the trust it cannot bear. The
996
+ // board's `[SWEEP:canon.2]` pin says "13 members" and carries a predicate that
997
+ // returns 9 — the four delivered members were closed and the prose count was not.
998
+ // A reader who runs the predicate finds the discrepancy; a reader who trusts the
999
+ // number does not, and the number is the part that looks like a fact.
1000
+ //
1001
+ // So a pin asserting a subset must be RE-DERIVABLE, and this re-derives it. The
1002
+ // predicate is the pin's own: position-matched sweep tags over parsed queue items,
1003
+ // which is the only method of the three that was ever right.
1004
+ guard("pin-reproducibility", () => {
1005
+ const board = path.join(docs, "WORKSTREAMS.md");
1006
+ if (!exists(board) || !queueDocs.length) return;
1007
+ const text = readText(board);
1008
+ // `### \`[SWEEP:<tag>]\` — pinned at <sha>, <n> members`
1009
+ const pins = [...text.matchAll(/^###\s+`\[SWEEP:([a-z0-9._-]+)\]`[^\n]*?(\d+)\s+members/gim)];
1010
+ if (!pins.length) {
1011
+ push("pin-reproducibility", "info", ["no pinned sweeps on the board — nothing to re-derive"]);
1012
+ return;
1013
+ }
1014
+ const items = queueDocs.flatMap((rel) => queueItemsOf(parseWorkDoc(readText(path.join(docs, rel)))));
1015
+ const problems = [];
1016
+ const okLines = [];
1017
+ for (const [, tag, claimed] of pins) {
1018
+ const actual = items.filter((i) => sweepTagOf(i) === tag.toLowerCase()).length;
1019
+ if (Number(claimed) !== actual) {
1020
+ problems.push(
1021
+ `[SWEEP:${tag}] pin claims ${claimed} members; its own predicate returns ${actual}. ` +
1022
+ `A stated count that the stated method contradicts is worse than no count — it looks auditable. ` +
1023
+ `Re-derive the pin, or record why the difference is expected.`,
1024
+ );
1025
+ } else {
1026
+ okLines.push(`[SWEEP:${tag}]: ${actual} members, predicate reproduces the count`);
1027
+ }
1028
+ }
1029
+ if (problems.length) push("pin-reproducibility", "warn", [...problems, ...okLines]);
1030
+ else push("pin-reproducibility", "ok", okLines);
1031
+ });
1032
+
913
1033
  const factsFile = path.join(docs, "FACTS.md");
914
1034
  guard("facts-freshness", () => {
915
1035
  if (exists(factsFile)) {
@@ -48,6 +48,12 @@ export const ARTIFACTS = [
48
48
  { path: "docs/phases/phaseN/README.md", scope: "project", purpose: "Phase overview", writtenBy: "/plan-phase", readBy: "/start-session", rules: "—" },
49
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" },
50
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
+ // DELIBERATELY NO `grammar`. This doc is registered so `doc-registration`
52
+ // can see it, but it is NOT a work-doc grammar: a David-blocked list that
53
+ // parses as a queue IS a queue, and every queue check would begin reading
54
+ // it as claimable work. The whole point of the file is that its contents
55
+ // are the items the fleet CANNOT pick up.
56
+ { path: "docs/DAVID_TASKS.md", scope: "project", purpose: "Standing list of what is blocked on David — his hands, accounts, money, or ruling", writtenBy: "aide (coordinator may append)", readBy: "David, /start-session", rules: "Not a queue: no `- [ ] (Pn)` lines. Items the fleet can do belong in QUEUE.md" },
51
57
  { 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
58
  { 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
59
  { 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" },