@davidbalzan/groundwork 0.4.8 → 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.8",
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": {
@@ -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, withoutCodeContext } 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,6 +299,14 @@ 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 ?? "")));
302
310
  // THE REPO BEING CHECKED, so a BARE `#170` resolves instead of being unknown.
303
311
  //
304
312
  // The seam's primitive refuses a bare-vs-qualified tie by design, and doctor
@@ -318,18 +326,23 @@ guard("queue-done-loop", () => {
318
326
  const problems = [];
319
327
  for (const item of closed) {
320
328
  const cited = refsIn(String(item.text));
329
+ const citedCommits = commitRefsIn(String(item.text));
321
330
  const short = String(item.text).replace(/\s+/g, " ").slice(0, 70);
322
- if (cited.length === 0) {
323
- problems.push(`${item.where}: [x] item cites no PR, so nothing can tie it to DONE.md — "${short}…"`);
324
- } else if (!cited.some((r) => refIn(r, doneRefs, { contextRepo }))) {
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
+ ) {
325
337
  // NOT "this closure is unlogged" — the check cannot know that. The citation
326
- // is the only tie it has, and an item whose body never names the PR that
327
- // closed it is UNTIEABLE, even when DONE.md records the closure perfectly.
328
- // Measured live: an item citing `kit#84` and `console#39` was closed by
329
- // #119, and #119 IS in DONE.md — the item simply never mentions it.
330
- // 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.
331
344
  problems.push(
332
- `${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. ` +
333
346
  `It may still be logged under a ref the item does not name; the tie, not the log, is what is missing — "${short}…"`,
334
347
  );
335
348
  }
@@ -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";