@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 +29 -0
- package/package.json +1 -1
- package/src/commands/doctor.mjs +23 -10
- package/src/commands/update.mjs +22 -2
- package/src/lib/baseline.mjs +48 -2
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
package/src/commands/doctor.mjs
CHANGED
|
@@ -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
|
|
324
|
-
} else if (
|
|
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
|
|
327
|
-
// closed it is UNTIEABLE, even when DONE.md records the
|
|
328
|
-
// Measured live: an item citing `kit#84` and
|
|
329
|
-
// #119, and #119 IS in DONE.md — the item
|
|
330
|
-
// Reporting that as "no entry" would send
|
|
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
|
}
|
package/src/commands/update.mjs
CHANGED
|
@@ -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.
|
package/src/lib/baseline.mjs
CHANGED
|
@@ -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)
|
|
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";
|