@davidbalzan/groundwork 0.4.7 → 0.4.9

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/README.md CHANGED
@@ -158,6 +158,35 @@ Shipped to `docs/.groundwork/scripts/` and preferred by the matching skills:
158
158
 
159
159
  ---
160
160
 
161
+ ## Upgrading: nothing migrates your content
162
+
163
+ **`groundwork update` never rewrites your work.** It refreshes the files Groundwork
164
+ installed and leaves everything else alone. There is no auto-migration, and that is a
165
+ decision rather than a gap — an auto-migrating skill was considered and rejected, because
166
+ the failure mode is silent data loss in the one place you would not look for it.
167
+
168
+ What that means in practice:
169
+
170
+ - **A doc you edited is KEPT and named in the summary**, not overwritten. Taking an
171
+ upstream version is a deliberate act: `--force-skills` or `--force-docs`.
172
+ - **An older board grammar keeps parsing as itself.** A 5-column Active Streams table is
173
+ read as `workstreams.lanes-v0` — it is not upgraded, not rewritten, and not treated as
174
+ broken. You move to the 6-column `workstreams.v1` grammar when you choose to, by
175
+ editing the table.
176
+ - **Nothing reformats a file to normalise it.** If a table renders, it stays as written.
177
+
178
+ One honest exception, and it is narrow. A file installed **before Groundwork recorded
179
+ install baselines** has no recorded hash to compare against, so `update` cannot tell an
180
+ edited file from an untouched one. Those used to be kept forever — which quietly meant a
181
+ card could stay two weeks stale while `update` reported success. Now, **if git says the
182
+ file is tracked and has no uncommitted changes, it is refreshed and listed under
183
+ "Refreshed N pre-baseline file(s)"** — because git can give it back (`git checkout --
184
+ <path>`), so the refresh costs a command rather than a fact. If the file is untracked,
185
+ uncommitted, or the project is not a git repo, it is still kept.
186
+
187
+ If you want to see what would change before it does, `groundwork update --dry-run` reports
188
+ the same decisions without writing.
189
+
161
190
  ## Versions stay honest
162
191
 
163
192
  Two mechanisms keep dependency versions from rotting:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidbalzan/groundwork",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
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.10"
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, commitRefsIn, commitRefMatches, withoutCodeContext } from "@davidbalzan/groundwork-seam";
11
11
 
12
12
  /**
13
13
  * `groundwork doctor` — flag drift between the docs and reality. Offline + deterministic.
@@ -299,21 +299,50 @@ 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
+ // A CLOSURE CAN BE TIED BY COMMIT, NOT ONLY BY PR — `land` requires a PR by
303
+ // rule, but a `.md`-direct workflow with no PR (this repo's own aide pushes
304
+ // docs straight to `main` all day) still closes an item, and its only
305
+ // citable evidence is `owner/repo@sha`. Before this, a genuinely-closed
306
+ // item with no PR read as "cites no PR, so nothing can tie it to DONE.md" —
307
+ // its ONLY remedies were to fabricate a PR reference or to prune delivered
308
+ // work, and both falsify the record. Surfaced 2026-08-31.
309
+ const doneCommits = doneEntries.flatMap((e) => commitRefsIn(String(e.ref ?? "")));
310
+ // THE REPO BEING CHECKED, so a BARE `#170` resolves instead of being unknown.
311
+ //
312
+ // The seam's primitive refuses a bare-vs-qualified tie by design, and doctor
313
+ // deliberately passed nothing on the premise that "the corpus writes
314
+ // `kit#94`/`console#38`, so the tie is recoverable". MEASURED ON THIS REPO:
315
+ // it does not. Queue items cite bare `#170, #173` while DONE.md qualifies
316
+ // them, and six correctly-logged closures were reported as untieable — a
317
+ // warning ON CORRECT CONTENT, which is the whole subject of this task and
318
+ // reached David twice as a suspected data problem.
319
+ //
320
+ // This NARROWS the check, so it ships with the case where it still fires:
321
+ // `console#39` still does not match `groundwork-kit#39`, because the primitive
322
+ // compares repository leaves rather than numbers. Unknown becomes known only
323
+ // where the repo actually answers it; a doc outside a git repo passes null and
324
+ // gets exactly today's behaviour.
325
+ const contextRepo = originRepoOf(docs);
302
326
  const problems = [];
303
327
  for (const item of closed) {
304
328
  const cited = refsIn(String(item.text));
329
+ const citedCommits = commitRefsIn(String(item.text));
305
330
  const short = String(item.text).replace(/\s+/g, " ").slice(0, 70);
306
- if (cited.length === 0) {
307
- 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))) {
331
+ if (cited.length === 0 && citedCommits.length === 0) {
332
+ problems.push(`${item.where}: [x] item cites neither a PR nor a commit, so nothing can tie it to DONE.md — "${short}…"`);
333
+ } else if (
334
+ !cited.some((r) => refIn(r, doneRefs, { contextRepo })) &&
335
+ !citedCommits.some((c) => doneCommits.some((d) => commitRefMatches(c, d)))
336
+ ) {
309
337
  // 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.
338
+ // is the only tie it has, and an item whose body never names the PR or
339
+ // commit that closed it is UNTIEABLE, even when DONE.md records the
340
+ // closure perfectly. Measured live: an item citing `kit#84` and
341
+ // `console#39` was closed by #119, and #119 IS in DONE.md — the item
342
+ // simply never mentions it. Reporting that as "no entry" would send
343
+ // someone to add a duplicate.
315
344
  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. ` +
345
+ `${item.where}: [x] item cites ${[...cited.map((r) => r.raw), ...citedCommits.map((c) => `@${c}`)].join(", ")}, none of which DONE.md records — so this closure CANNOT BE TIED by citation. ` +
317
346
  `It may still be logged under a ref the item does not name; the tie, not the log, is what is missing — "${short}…"`,
318
347
  );
319
348
  }
@@ -526,7 +555,12 @@ guard("undeclared-work-docs", () => {
526
555
  const rogue = [];
527
556
  for (const rel of mdFiles) {
528
557
  if (declared.has(rel) || isTemplate(rel)) continue;
529
- const text = readText(path.join(docs, rel));
558
+ // Same root cause as the wikilink resolver, and the same fix: an example
559
+ // item line inside a fence is a SPECIMEN, not work. A doc explaining
560
+ // `- [ ] (P1) …` must not be reported as an undeclared queue — declaring it
561
+ // to silence the warning would make every queue check read specimens as
562
+ // real items, which is the defect this check exists to find.
563
+ const text = withoutCodeContext(readText(path.join(docs, rel)));
530
564
  const count = (text.match(/^- \[[ x]\] \((?:P1|P2|P3)\) /gm) ?? []).length;
531
565
  if (count > 0 && ITEM.test(text)) rogue.push({ rel, count });
532
566
  }
@@ -536,12 +570,38 @@ guard("undeclared-work-docs", () => {
536
570
  // much it looked at.
537
571
  const scanned = `${mdFiles.filter((r) => !isTemplate(r)).length} doc(s) scanned (templates excluded), ${declared.size} declared queue.v1`;
538
572
  if (rogue.length) {
573
+ // AN ARCHIVE MUST NOT BE TOLD TO DECLARE `queue.v1`. Declaring it is what
574
+ // the remedy says, and it would make every archived row parse as OPEN work —
575
+ // the check would resurrect the rows someone archived on purpose. A consumer
576
+ // fleet already ships `QUEUE_PARKED_ARCHIVE` (58 rows) and
577
+ // `QUEUE_ITEMS_ARCHIVE` (48); following this advice there would revive 106
578
+ // closed items. The archive GRAMMAR itself is a separate item; what belongs
579
+ // here is not handing out a remedy that breaks the thing it is aimed at.
580
+ const isArchive = (rel) => /archive/i.test(rel);
581
+ const archives = rogue.filter((r) => isArchive(r.rel));
582
+ const live = rogue.filter((r) => !isArchive(r.rel));
583
+ // A REMEDY MUST NAME A PATH THAT EXISTS IN THE REPO BEING CHECKED.
584
+ // `src/lib/artifacts.mjs` is correct only inside the kit, and it survived
585
+ // review because its authors live in the one repo where it works. A consumer
586
+ // reading it goes looking for a file it does not have, which is worse than
587
+ // no remedy: it reads as "your repo is broken" rather than "this advice is
588
+ // not for you".
589
+ const manifestPath = path.join(root, "src/lib/artifacts.mjs");
590
+ const where = exists(manifestPath)
591
+ ? "`src/lib/artifacts.mjs`"
592
+ : "your artifact manifest (this repo has no `src/lib/artifacts.mjs` — that path exists only in the groundwork kit itself)";
539
593
  push("undeclared-work-docs", "warn", [
540
594
  scanned,
541
- ...rogue.map(
595
+ ...live.map(
542
596
  (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
597
  ),
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",
598
+ ...archives.map(
599
+ (r) =>
600
+ `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`,
601
+ ),
602
+ ...(live.length
603
+ ? [`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`]
604
+ : []),
545
605
  ]);
546
606
  } else {
547
607
  push("undeclared-work-docs", "ok", [`${scanned}, no undeclared queue-shaped doc`]);
@@ -582,7 +642,10 @@ guard("doc-registration", () => {
582
642
  if (unregistered.length) {
583
643
  push("doc-registration", "warn", [
584
644
  ...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",
645
+ // SAME REMEDY DEFECT AS undeclared-work-docs, and the same fix: name a
646
+ // path the CHECKED repo has. This line is the one a consumer sees most,
647
+ // because an unregistered doc is the most common finding in a fresh repo.
648
+ `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
649
  ]);
587
650
  } else {
588
651
  push("doc-registration", "ok", [`${onDisk.length} doc(s) under docs/, all registered in the manifest`]);
@@ -1151,9 +1214,36 @@ function doneLineNamesPhase(doneText, n) {
1151
1214
  return String(doneText).split("\n").some((line) => re.test(line));
1152
1215
  }
1153
1216
 
1217
+ /**
1218
+ * `owner/repo` for the repository being checked, or null.
1219
+ *
1220
+ * Read from the git remote rather than from package.json: the remote is what
1221
+ * the refs in these documents actually name, and a package can be published
1222
+ * under a name that differs from its repository.
1223
+ */
1224
+ function originRepoOf(dir) {
1225
+ try {
1226
+ const url = execFileSync("git", ["remote", "get-url", "origin"], {
1227
+ cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
1228
+ }).trim();
1229
+ const m = /[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url);
1230
+ return m ? m[1] : null;
1231
+ } catch {
1232
+ // No remote, or not a repo. Null is today's behaviour exactly — an unknown
1233
+ // context must not become a guessed one.
1234
+ return null;
1235
+ }
1236
+ }
1237
+
1154
1238
  function wikilinks(text) {
1155
1239
  const out = [];
1156
- for (const m of text.matchAll(/\[\[([^\]]+)\]\]/g)) {
1240
+ // A WIKILINK IN A CODE SPAN IS NOT A LINK. Documentation contains examples of
1241
+ // what it documents, and this check flagged 15/15 quoted specimens — one of
1242
+ // them a deliberately planted `[[orphan]]` control, which means the check
1243
+ // reported a failure ON THE EVIDENCE THAT IT WORKS. Stripping preserves line
1244
+ // structure, so every location this function's callers report still points at
1245
+ // the same place (§withoutCodeContext).
1246
+ for (const m of withoutCodeContext(text).matchAll(/\[\[([^\]]+)\]\]/g)) {
1157
1247
  let t = m[1].split("|")[0].split("#")[0].trim(); // strip alias + heading
1158
1248
  if (!t) continue; // intra-doc heading link
1159
1249
  out.push(t);
@@ -13,6 +13,8 @@ import { writeArtifacts } from "./artifacts.mjs";
13
13
  import { refreshableDocs } from "../lib/artifacts.mjs";
14
14
  import {
15
15
  classify,
16
+ gitVerdictFor,
17
+ isRecoverableRefresh,
16
18
  hashFile,
17
19
  isProtected,
18
20
  readBaseline,
@@ -61,6 +63,8 @@ export function update(targetDir, flags = {}) {
61
63
  let refreshed = 0;
62
64
  let added = 0;
63
65
  let unchanged = 0;
66
+ /** Pre-baseline files refreshed only because git can restore them. */
67
+ const recovered = [];
64
68
  for (const name of toRefresh) {
65
69
  const src = path.join(PAYLOAD_SKILLS, name, "SKILL.md");
66
70
  if (!exists(src)) {
@@ -69,7 +73,7 @@ export function update(targetDir, flags = {}) {
69
73
  }
70
74
  const rel = path.join(TARGET.skills, name, "SKILL.md");
71
75
  const dest = path.join(root, rel);
72
- const verdict = classify(dest, src, baseline[rel]);
76
+ const verdict = classify(dest, src, baseline[rel], gitVerdictFor(root, rel));
73
77
 
74
78
  if (verdict === "current") {
75
79
  unchanged++;
@@ -80,6 +84,7 @@ export function update(targetDir, flags = {}) {
80
84
  kept.push({ rel, verdict, kind: "skill" });
81
85
  continue;
82
86
  }
87
+ if (isRecoverableRefresh(verdict)) recovered.push(rel);
83
88
  if (!dry) {
84
89
  copyFileSafe(src, dest, { force: true });
85
90
  nextBaseline[rel] = hashFile(src);
@@ -128,7 +133,7 @@ export function update(targetDir, flags = {}) {
128
133
  const src = path.join(PAYLOAD_DOCS, rel.replace(/^docs\//, ""));
129
134
  if (!exists(src)) continue; // ARTIFACTS.md is generated, not in payload — skip
130
135
  const dest = path.join(root, rel);
131
- const verdict = classify(dest, src, baseline[rel]);
136
+ const verdict = classify(dest, src, baseline[rel], gitVerdictFor(root, rel));
132
137
  if (verdict === "current") {
133
138
  nextBaseline[rel] = hashFile(src);
134
139
  continue;
@@ -167,6 +172,21 @@ export function update(targetDir, flags = {}) {
167
172
  }
168
173
  if (unchanged) log.info(dim(` ${unchanged} skill(s) already current.`));
169
174
 
175
+ // REFRESHING A PRE-BASELINE FILE IS A CHOICE, SO IT IS REPORTED.
176
+ //
177
+ // These had no baseline to judge by and were previously kept forever on the
178
+ // assumption they had been edited. They are refreshed now because git says
179
+ // they are tracked and clean — recoverable, not unexamined — and saying so is
180
+ // what keeps that from being a silent overwrite.
181
+ if (recovered.length) {
182
+ log.heading(`Refreshed ${recovered.length} pre-baseline file(s) — git shows them tracked and unmodified`);
183
+ for (const rel of recovered) console.log(` ${dim(rel)}`);
184
+ console.log(
185
+ dim(" No baseline existed for these, so they were previously kept indefinitely.\n" +
186
+ " Recover any of them with `git checkout -- <path>`; review with `git diff`.")
187
+ );
188
+ }
189
+
170
190
  // Report what we did NOT overwrite. This block is the whole point of the
171
191
  // change: the previous version force-copied these and then printed that your
172
192
  // docs had been left untouched.
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import fs from "node:fs";
26
26
  import path from "node:path";
27
+ import { execFileSync } from "node:child_process";
27
28
  import { createHash } from "node:crypto";
28
29
  import { TARGET } from "./paths.mjs";
29
30
  import { exists } from "./fs.mjs";
@@ -78,14 +79,59 @@ export function writeBaseline(root, files) {
78
79
  * "modified" — differs from the recorded baseline: a local edit. KEEP.
79
80
  * "unknown" — installed, differs from payload, no baseline to judge by. KEEP.
80
81
  */
81
- export function classify(projectFile, payloadFile, baselineHash) {
82
+ export function classify(projectFile, payloadFile, baselineHash, gitVerdict) {
82
83
  if (!exists(projectFile)) return "absent";
83
84
  const now = hashFile(projectFile);
84
85
  const payload = hashFile(payloadFile);
85
86
  if (now !== undefined && now === payload) return "current";
86
- if (baselineHash === undefined) return "unknown";
87
+ if (baselineHash === undefined) {
88
+ // ASK GIT BEFORE ASSUMING AN EDIT.
89
+ //
90
+ // A file installed before baselines existed has no hash to judge by, and
91
+ // the safe answer was to assume it had been edited and keep it. Safe, and
92
+ // permanent: it kept a two-week-stale card while reporting success, because
93
+ // "no baseline" never becomes "baseline" for a file that is never
94
+ // overwritten. The protection outlived the uncertainty that justified it.
95
+ //
96
+ // What the protection is actually FOR is preventing IRRECOVERABLE loss — a
97
+ // reverted SKILL.md changes agent behaviour silently. Git recoverability
98
+ // removes exactly that: a tracked file with no uncommitted changes can be
99
+ // restored with `git checkout`, so refreshing it costs a command and never
100
+ // a fact. Untracked or dirty, the old assumption still holds, because there
101
+ // the overwrite really is unrecoverable.
102
+ return gitVerdict === "tracked-clean" ? "stale-recoverable" : "unknown";
103
+ }
87
104
  return now === baselineHash ? "clean" : "modified";
88
105
  }
89
106
 
107
+ /**
108
+ * What git knows about one path: "tracked-clean" | "tracked-dirty" |
109
+ * "untracked" | "unknown".
110
+ *
111
+ * "unknown" for a non-repo or a failed call, and that default is the
112
+ * conservative one — an unanswerable question must not read as a clean answer.
113
+ */
114
+ export function gitVerdictFor(root, rel) {
115
+ try {
116
+ const tracked = execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], {
117
+ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
118
+ }).trim();
119
+ if (!tracked) return "untracked";
120
+ } catch {
121
+ return "untracked";
122
+ }
123
+ try {
124
+ const dirty = execFileSync("git", ["status", "--porcelain", "--", rel], {
125
+ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
126
+ }).trim();
127
+ return dirty ? "tracked-dirty" : "tracked-clean";
128
+ } catch {
129
+ return "unknown";
130
+ }
131
+ }
132
+
90
133
  /** True when a classification means "do not overwrite without an explicit flag". */
91
134
  export const isProtected = (verdict) => verdict === "modified" || verdict === "unknown";
135
+
136
+ /** True when the file was refreshed only because git can restore it. */
137
+ export const isRecoverableRefresh = (verdict) => verdict === "stale-recoverable";