@davidbalzan/groundwork 0.4.6 → 0.4.8

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.6",
3
+ "version": "0.4.8",
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.9"
32
+ "@davidbalzan/groundwork-seam": "^0.1.0"
33
33
  },
34
34
  "scripts": {
35
35
  "groundwork": "node src/cli.mjs",
@@ -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 { phaseCitationsIn, parseFactsDoc, parseWorkDoc, workDocIssues, workDocLegacyWriteIssues, workDocIssuesDetailed, queueItemsOf, doneEntriesOf, workstreamsV1RowsOf, sweepTagOf, sweepTagCensus, refsIn, refIn } from "@davidbalzan/groundwork-seam";
10
+ import { phaseCitationsIn, parseFactsDoc, parseWorkDoc, workDocIssues, workDocLegacyWriteIssues, workDocIssuesDetailed, queueItemsOf, doneEntriesOf, workstreamsV1RowsOf, sweepTagOf, sweepTagCensus, refsIn, refIn, withoutCodeContext } from "@davidbalzan/groundwork-seam";
11
11
 
12
12
  /**
13
13
  * `groundwork doctor` — flag drift between the docs and reality. Offline + deterministic.
@@ -299,13 +299,29 @@ guard("queue-done-loop", () => {
299
299
  // Fourth matcher in one day to re-derive ref/tag matching and get it wrong. The
300
300
  // primitive lives in the seam now so the fifth one written cannot.
301
301
  const doneRefs = doneEntries.flatMap((e) => refsIn(String(e.ref ?? "")));
302
+ // THE REPO BEING CHECKED, so a BARE `#170` resolves instead of being unknown.
303
+ //
304
+ // The seam's primitive refuses a bare-vs-qualified tie by design, and doctor
305
+ // deliberately passed nothing on the premise that "the corpus writes
306
+ // `kit#94`/`console#38`, so the tie is recoverable". MEASURED ON THIS REPO:
307
+ // it does not. Queue items cite bare `#170, #173` while DONE.md qualifies
308
+ // them, and six correctly-logged closures were reported as untieable — a
309
+ // warning ON CORRECT CONTENT, which is the whole subject of this task and
310
+ // reached David twice as a suspected data problem.
311
+ //
312
+ // This NARROWS the check, so it ships with the case where it still fires:
313
+ // `console#39` still does not match `groundwork-kit#39`, because the primitive
314
+ // compares repository leaves rather than numbers. Unknown becomes known only
315
+ // where the repo actually answers it; a doc outside a git repo passes null and
316
+ // gets exactly today's behaviour.
317
+ const contextRepo = originRepoOf(docs);
302
318
  const problems = [];
303
319
  for (const item of closed) {
304
320
  const cited = refsIn(String(item.text));
305
321
  const short = String(item.text).replace(/\s+/g, " ").slice(0, 70);
306
322
  if (cited.length === 0) {
307
323
  problems.push(`${item.where}: [x] item cites no PR, so nothing can tie it to DONE.md — "${short}…"`);
308
- } else if (!cited.some((r) => refIn(r, doneRefs))) {
324
+ } else if (!cited.some((r) => refIn(r, doneRefs, { contextRepo }))) {
309
325
  // NOT "this closure is unlogged" — the check cannot know that. The citation
310
326
  // is the only tie it has, and an item whose body never names the PR that
311
327
  // closed it is UNTIEABLE, even when DONE.md records the closure perfectly.
@@ -526,7 +542,12 @@ guard("undeclared-work-docs", () => {
526
542
  const rogue = [];
527
543
  for (const rel of mdFiles) {
528
544
  if (declared.has(rel) || isTemplate(rel)) continue;
529
- const text = readText(path.join(docs, rel));
545
+ // Same root cause as the wikilink resolver, and the same fix: an example
546
+ // item line inside a fence is a SPECIMEN, not work. A doc explaining
547
+ // `- [ ] (P1) …` must not be reported as an undeclared queue — declaring it
548
+ // to silence the warning would make every queue check read specimens as
549
+ // real items, which is the defect this check exists to find.
550
+ const text = withoutCodeContext(readText(path.join(docs, rel)));
530
551
  const count = (text.match(/^- \[[ x]\] \((?:P1|P2|P3)\) /gm) ?? []).length;
531
552
  if (count > 0 && ITEM.test(text)) rogue.push({ rel, count });
532
553
  }
@@ -536,12 +557,38 @@ guard("undeclared-work-docs", () => {
536
557
  // much it looked at.
537
558
  const scanned = `${mdFiles.filter((r) => !isTemplate(r)).length} doc(s) scanned (templates excluded), ${declared.size} declared queue.v1`;
538
559
  if (rogue.length) {
560
+ // AN ARCHIVE MUST NOT BE TOLD TO DECLARE `queue.v1`. Declaring it is what
561
+ // the remedy says, and it would make every archived row parse as OPEN work —
562
+ // the check would resurrect the rows someone archived on purpose. A consumer
563
+ // fleet already ships `QUEUE_PARKED_ARCHIVE` (58 rows) and
564
+ // `QUEUE_ITEMS_ARCHIVE` (48); following this advice there would revive 106
565
+ // closed items. The archive GRAMMAR itself is a separate item; what belongs
566
+ // here is not handing out a remedy that breaks the thing it is aimed at.
567
+ const isArchive = (rel) => /archive/i.test(rel);
568
+ const archives = rogue.filter((r) => isArchive(r.rel));
569
+ const live = rogue.filter((r) => !isArchive(r.rel));
570
+ // A REMEDY MUST NAME A PATH THAT EXISTS IN THE REPO BEING CHECKED.
571
+ // `src/lib/artifacts.mjs` is correct only inside the kit, and it survived
572
+ // review because its authors live in the one repo where it works. A consumer
573
+ // reading it goes looking for a file it does not have, which is worse than
574
+ // no remedy: it reads as "your repo is broken" rather than "this advice is
575
+ // not for you".
576
+ const manifestPath = path.join(root, "src/lib/artifacts.mjs");
577
+ const where = exists(manifestPath)
578
+ ? "`src/lib/artifacts.mjs`"
579
+ : "your artifact manifest (this repo has no `src/lib/artifacts.mjs` — that path exists only in the groundwork kit itself)";
539
580
  push("undeclared-work-docs", "warn", [
540
581
  scanned,
541
- ...rogue.map(
582
+ ...live.map(
542
583
  (r) => `docs/${r.rel} holds ${r.count} queue item(s) and is NOT declared queue.v1 in the manifest — no queue check reads it`,
543
584
  ),
544
- "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",
585
+ ...archives.map(
586
+ (r) =>
587
+ `docs/${r.rel} looks like an ARCHIVE holding ${r.count} archived item(s) — it is NOT declared, and it must NOT be declared queue.v1: that would make every archived row parse as open work`,
588
+ ),
589
+ ...(live.length
590
+ ? [`declare it in ${where} with \`grammar: "queue.v1"\` and re-run \`groundwork artifacts\` — a manifest-derived population cannot find what the manifest does not name`]
591
+ : []),
545
592
  ]);
546
593
  } else {
547
594
  push("undeclared-work-docs", "ok", [`${scanned}, no undeclared queue-shaped doc`]);
@@ -582,7 +629,10 @@ guard("doc-registration", () => {
582
629
  if (unregistered.length) {
583
630
  push("doc-registration", "warn", [
584
631
  ...unregistered.map((f) => `docs/${f} is not in the artifact manifest — no stated writer, reader, or purpose`),
585
- "add it to src/lib/artifacts.mjs and re-run `groundwork artifacts`, or delete it — an unregistered doc is ungoverned, not merely undocumented",
632
+ // SAME REMEDY DEFECT AS undeclared-work-docs, and the same fix: name a
633
+ // path the CHECKED repo has. This line is the one a consumer sees most,
634
+ // because an unregistered doc is the most common finding in a fresh repo.
635
+ `add it to ${exists(path.join(root, "src/lib/artifacts.mjs")) ? "src/lib/artifacts.mjs" : "your artifact manifest (this repo has no src/lib/artifacts.mjs — that path exists only in the groundwork kit itself)"} and re-run \`groundwork artifacts\`, or delete it — an unregistered doc is ungoverned, not merely undocumented`,
586
636
  ]);
587
637
  } else {
588
638
  push("doc-registration", "ok", [`${onDisk.length} doc(s) under docs/, all registered in the manifest`]);
@@ -1123,8 +1173,21 @@ export function doctor(targetDir, opts = {}) {
1123
1173
 
1124
1174
  // ---------- helpers ----------
1125
1175
  function phaseNumberFromDir(label) {
1126
- const m = String(label).match(/^phase(\d+)/i);
1127
- return m ? Number(m[1]) : null;
1176
+ // THE PHASE TOKEN CAN HAVE A MINOR PART, and truncating it is the b-clause
1177
+ // defect at a second granularity. There the ID token was too narrow — `3.3b`
1178
+ // matched as `3.3`. Here the PHASE token is: `phase5.1` matched as `5`, so
1179
+ // every box in phase 5.1 was keyed under phase 5 and the citation that names
1180
+ // the phase correctly — `Phase 5.1 Task 7.1` — parsed as nothing at all.
1181
+ //
1182
+ // The consequence was worse than a miss: the citation that LOOKS right is the
1183
+ // one that does not tie, so a correct-looking record reports unevidenced
1184
+ // ticks. Every 5.1 citation had to be written in a form contradicting the
1185
+ // phase's own name.
1186
+ //
1187
+ // Returned as a STRING, because "5.1" is not a number and Number("5.1")
1188
+ // silently collides `phase5.1` with any future `phase5.10`.
1189
+ const m = String(label).match(/^phase(\d+(?:\.\d+)?)/i);
1190
+ return m ? m[1] : null;
1128
1191
  }
1129
1192
 
1130
1193
  function roadmapPhaseComplete(roadmapText, n) {
@@ -1138,9 +1201,36 @@ function doneLineNamesPhase(doneText, n) {
1138
1201
  return String(doneText).split("\n").some((line) => re.test(line));
1139
1202
  }
1140
1203
 
1204
+ /**
1205
+ * `owner/repo` for the repository being checked, or null.
1206
+ *
1207
+ * Read from the git remote rather than from package.json: the remote is what
1208
+ * the refs in these documents actually name, and a package can be published
1209
+ * under a name that differs from its repository.
1210
+ */
1211
+ function originRepoOf(dir) {
1212
+ try {
1213
+ const url = execFileSync("git", ["remote", "get-url", "origin"], {
1214
+ cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
1215
+ }).trim();
1216
+ const m = /[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url);
1217
+ return m ? m[1] : null;
1218
+ } catch {
1219
+ // No remote, or not a repo. Null is today's behaviour exactly — an unknown
1220
+ // context must not become a guessed one.
1221
+ return null;
1222
+ }
1223
+ }
1224
+
1141
1225
  function wikilinks(text) {
1142
1226
  const out = [];
1143
- for (const m of text.matchAll(/\[\[([^\]]+)\]\]/g)) {
1227
+ // A WIKILINK IN A CODE SPAN IS NOT A LINK. Documentation contains examples of
1228
+ // what it documents, and this check flagged 15/15 quoted specimens — one of
1229
+ // them a deliberately planted `[[orphan]]` control, which means the check
1230
+ // reported a failure ON THE EVIDENCE THAT IT WORKS. Stripping preserves line
1231
+ // structure, so every location this function's callers report still points at
1232
+ // the same place (§withoutCodeContext).
1233
+ for (const m of withoutCodeContext(text).matchAll(/\[\[([^\]]+)\]\]/g)) {
1144
1234
  let t = m[1].split("|")[0].split("#")[0].trim(); // strip alias + heading
1145
1235
  if (!t) continue; // intra-doc heading link
1146
1236
  out.push(t);
@@ -54,6 +54,7 @@ export const ARTIFACTS = [
54
54
  // it as claimable work. The whole point of the file is that its contents
55
55
  // are the items the fleet CANNOT pick up.
56
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" },
57
+ { path: "docs/improvements.md", scope: "project", purpose: "Improvements found by USING the kit, not by reviewing it — each entry records how it was hit", writtenBy: "aide (any agent may append an encounter)", readBy: "David (walkthrough), /plan-phase", rules: "An entry requires an ENCOUNTER: how it was hit, in the tool, by whom. A proposal with no encounter belongs in QUEUE.md. Entries keep their line after the fix lands and gain a reference, so the record of what using it felt like survives the repair. Includes a do-not-improve section: surfaces measured as working, which an in-flight phase must not regress." },
57
58
  { 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" },
58
59
  { 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" },
59
60
  { 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" },