@davidbalzan/groundwork 0.3.3 → 0.4.0
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 +3 -3
- package/src/cli.mjs +4 -1
- package/src/commands/doctor.mjs +47 -3
- package/src/commands/init.mjs +13 -5
- package/src/commands/update.mjs +136 -36
- package/src/lib/artifacts.mjs +1 -1
- package/src/lib/baseline.mjs +91 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidbalzan/groundwork",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Groundwork — an installable AI development workflow (skills + doc methodology) you bolt onto any repo.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
"author": "David Balzan",
|
|
29
29
|
"license": "MIT",
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@davidbalzan/groundwork-seam": "0.1.
|
|
31
|
+
"@davidbalzan/groundwork-seam": "0.1.3"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"groundwork": "node src/cli.mjs",
|
|
35
|
-
"test": "node --test test/*.test.mjs",
|
|
35
|
+
"test": "node ../../scripts/assert-tests-exist.mjs test/*.test.mjs && node --test test/*.test.mjs",
|
|
36
36
|
"test:init": "node src/cli.mjs init /tmp/groundwork-smoke --force && node src/cli.mjs list /tmp/groundwork-smoke && node src/cli.mjs status /tmp/groundwork-smoke"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -39,7 +39,7 @@ function help() {
|
|
|
39
39
|
"",
|
|
40
40
|
bold("Commands:"),
|
|
41
41
|
` ${cyan("init")} [dir] Install skills + IDE mirrors + docs into a repo (default: .)`,
|
|
42
|
-
` ${cyan("update")} [dir] Upgrade skills + scripts in place (keeps your docs); ${dim("--all")} adds new skills, ${dim("--docs")} refreshes generic reference docs`,
|
|
42
|
+
` ${cyan("update")} [dir] Upgrade skills + scripts in place (keeps your docs AND your local edits); ${dim("--all")} adds new skills, ${dim("--docs")} refreshes generic reference docs`,
|
|
43
43
|
` ${cyan("add")} <skill> [dir] Add one optional skill (e.g. add-data-layer)`,
|
|
44
44
|
` ${cyan("list")} [dir] List available skills and install state`,
|
|
45
45
|
` ${cyan("status")} [dir] Show live workstreams, next backlog item, phase progress`,
|
|
@@ -51,6 +51,9 @@ function help() {
|
|
|
51
51
|
"",
|
|
52
52
|
bold("Flags:"),
|
|
53
53
|
` --minimal init only the core 5 skills`,
|
|
54
|
+
` --dry-run update: report what would change, write nothing`,
|
|
55
|
+
` --force-skills update: overwrite locally-modified SKILL.md files`,
|
|
56
|
+
` --force-docs update: overwrite locally-modified reference docs`,
|
|
54
57
|
` --force overwrite files that already exist`,
|
|
55
58
|
` --json machine-readable output (doctor / status / list)`,
|
|
56
59
|
"",
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -26,8 +26,26 @@ export function collectDoctor(targetDir) {
|
|
|
26
26
|
|
|
27
27
|
const checks = [];
|
|
28
28
|
const push = (check, level, items) => checks.push({ check, level, items });
|
|
29
|
+
// A broken check degrades to a named skip instead of taking the whole doctor
|
|
30
|
+
// down (field crash 2026-08-21: a TypeError from one check aborted the run
|
|
31
|
+
// and every later check with it, including the pure-fs ones). Doctor never
|
|
32
|
+
// writes anything — a skipped check must not tempt anyone to "fix" a target
|
|
33
|
+
// into existence.
|
|
34
|
+
const skipped = [];
|
|
35
|
+
const guard = (name, fn) => {
|
|
36
|
+
try {
|
|
37
|
+
fn();
|
|
38
|
+
} catch (e) {
|
|
39
|
+
skipped.push(name);
|
|
40
|
+
push(name, "info", [
|
|
41
|
+
`check skipped — ${String(e?.message ?? e).split("\n")[0]} (doctor kept going; other checks are unaffected)`,
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
29
45
|
const mdFiles = [...walk(docs)].filter((f) => f.endsWith(".md"));
|
|
46
|
+
const phaseDir = path.join(docs, "phases");
|
|
30
47
|
|
|
48
|
+
guard("orphan-wikilinks", () => {
|
|
31
49
|
const valid = validLinkTargets(docs, mdFiles);
|
|
32
50
|
const orphanItems = [];
|
|
33
51
|
const seen = new Set();
|
|
@@ -42,8 +60,9 @@ export function collectDoctor(targetDir) {
|
|
|
42
60
|
}
|
|
43
61
|
}
|
|
44
62
|
push("orphan-wikilinks", orphanItems.length ? "warn" : "ok", orphanItems.length ? orphanItems : ["all wikilinks resolve"]);
|
|
63
|
+
});
|
|
45
64
|
|
|
46
|
-
|
|
65
|
+
guard("phase-progress", () => {
|
|
47
66
|
const roadmapText = exists(path.join(docs, "PRODUCTION_ROADMAP.md"))
|
|
48
67
|
? readText(path.join(docs, "PRODUCTION_ROADMAP.md"))
|
|
49
68
|
: "";
|
|
@@ -71,7 +90,10 @@ export function collectDoctor(targetDir) {
|
|
|
71
90
|
}
|
|
72
91
|
if (!phaseItems.length) push("phase-progress", "ok", ["no phase task files yet (run /plan-phase)"]);
|
|
73
92
|
else push("phase-progress", phaseWarn ? "warn" : "ok", phaseItems);
|
|
93
|
+
});
|
|
94
|
+
|
|
74
95
|
|
|
96
|
+
guard("spec-coverage", () => {
|
|
75
97
|
const coverageItems = [];
|
|
76
98
|
if (exists(phaseDir)) {
|
|
77
99
|
for (const dir of listDirs(phaseDir)) {
|
|
@@ -100,7 +122,10 @@ export function collectDoctor(targetDir) {
|
|
|
100
122
|
const coverageInfoOnly = coverageItems.length === 1 && coverageItems[0].startsWith("no PRD");
|
|
101
123
|
if (!coverageItems.length) push("spec-coverage", "ok", ["phase structure + PRD sections look complete"]);
|
|
102
124
|
else push("spec-coverage", coverageInfoOnly ? "info" : "warn", coverageItems);
|
|
125
|
+
});
|
|
126
|
+
|
|
103
127
|
|
|
128
|
+
guard("version-marker", () => {
|
|
104
129
|
const marker = path.join(docs, ".groundwork", "VERSION");
|
|
105
130
|
const cliV = pkgVersion();
|
|
106
131
|
if (exists(marker)) {
|
|
@@ -109,7 +134,10 @@ export function collectDoctor(targetDir) {
|
|
|
109
134
|
push("version-marker", "warn", [`installed v${got}, CLI is v${cliV} — run \`groundwork update --all --docs\``]);
|
|
110
135
|
else push("version-marker", "ok", [`up to date (v${got})`]);
|
|
111
136
|
} else push("version-marker", "warn", ["no VERSION marker — re-run `groundwork init`/`update`"]);
|
|
137
|
+
});
|
|
112
138
|
|
|
139
|
+
|
|
140
|
+
guard("stack-map-freshness", () => {
|
|
113
141
|
const sm = path.join(docs, "STACK_MAP.md");
|
|
114
142
|
if (exists(sm)) {
|
|
115
143
|
const m = readText(sm).match(/Last audited[^\d]*(\d{4})-(\d{2})-(\d{2})/);
|
|
@@ -120,7 +148,10 @@ export function collectDoctor(targetDir) {
|
|
|
120
148
|
else push("stack-map-freshness", "ok", [`STACK_MAP audited ${days} days ago`]);
|
|
121
149
|
}
|
|
122
150
|
} else push("stack-map-freshness", "info", ["no STACK_MAP.md yet (run /kickstart)"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
123
153
|
|
|
154
|
+
guard("workstreams", () => {
|
|
124
155
|
const wsFile = path.join(docs, "WORKSTREAMS.md");
|
|
125
156
|
if (exists(wsFile)) {
|
|
126
157
|
const wsDoc = parseWorkDoc(readText(wsFile));
|
|
@@ -128,15 +159,21 @@ export function collectDoctor(targetDir) {
|
|
|
128
159
|
if (wsIssues.length) push("workstreams", "warn", wsIssues);
|
|
129
160
|
else push("workstreams", "ok", ["write grammar is workstreams.v1"]);
|
|
130
161
|
} else push("workstreams", "info", ["no WORKSTREAMS.md yet (run `groundwork update --docs`)"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
131
164
|
|
|
165
|
+
guard("queue", () => {
|
|
132
166
|
const qFile = path.join(docs, "QUEUE.md");
|
|
133
167
|
if (exists(qFile)) {
|
|
134
168
|
const qIssues = workDocIssues(parseWorkDoc(readText(qFile)));
|
|
135
169
|
if (qIssues.length) push("queue", "warn", qIssues);
|
|
136
170
|
else push("queue", "ok", ["queue.v1 — one line per item"]);
|
|
137
171
|
}
|
|
172
|
+
});
|
|
138
173
|
|
|
139
|
-
|
|
174
|
+
|
|
175
|
+
const factsFile = path.join(docs, "FACTS.md");
|
|
176
|
+
guard("facts-freshness", () => {
|
|
140
177
|
if (exists(factsFile)) {
|
|
141
178
|
const { entries, issues } = parseFactsDoc(readText(factsFile));
|
|
142
179
|
if (issues.length) push("facts-freshness", "warn", issues);
|
|
@@ -147,7 +184,10 @@ export function collectDoctor(targetDir) {
|
|
|
147
184
|
[entries.length ? `${entries.length} fact(s), all verified and fresh` : "no facts recorded yet"],
|
|
148
185
|
);
|
|
149
186
|
} else push("facts-freshness", "info", ["no FACTS.md yet (run `groundwork update --docs`)"]);
|
|
187
|
+
});
|
|
188
|
+
|
|
150
189
|
|
|
190
|
+
guard("adr-tripwire", () => {
|
|
151
191
|
const decisions = path.join(docs, "DECISIONS.md");
|
|
152
192
|
if (exists(decisions)) {
|
|
153
193
|
const acceptedIds = new Set();
|
|
@@ -169,9 +209,11 @@ export function collectDoctor(targetDir) {
|
|
|
169
209
|
if (adrItems.length) push("adr-tripwire", "warn", adrItems);
|
|
170
210
|
else push("adr-tripwire", "ok", ["no Accepted ADR contradicted by dependencies/paths"]);
|
|
171
211
|
} else push("adr-tripwire", "info", ["no DECISIONS.md yet (run /kickstart)"]);
|
|
212
|
+
});
|
|
213
|
+
|
|
172
214
|
|
|
173
215
|
const warnCount = checks.filter((c) => c.level === "warn").length;
|
|
174
|
-
return { ok: warnCount === 0, exit: warnCount === 0 ? 0 : 1, checks };
|
|
216
|
+
return { ok: warnCount === 0, exit: warnCount === 0 ? 0 : 1, checks, ...(skipped.length ? { skipped } : {}) };
|
|
175
217
|
}
|
|
176
218
|
|
|
177
219
|
export function doctor(targetDir, opts = {}) {
|
|
@@ -207,6 +249,8 @@ export function doctor(targetDir, opts = {}) {
|
|
|
207
249
|
}
|
|
208
250
|
}
|
|
209
251
|
log.heading("Summary");
|
|
252
|
+
if (report.skipped?.length)
|
|
253
|
+
console.log(` ${yellow("⚠")} ${report.skipped.length} check(s) skipped (${report.skipped.join(", ")}) — see above; fs-only checks still ran.`);
|
|
210
254
|
if (report.ok) log.ok("No drift detected — docs match reality.");
|
|
211
255
|
else console.log(` ${yellow(String(report.checks.filter((c) => c.level === "warn").length))} issue(s) to look at.`);
|
|
212
256
|
process.exitCode = report.exit;
|
package/src/commands/init.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { copyFileSafe, walk, exists } from "../lib/fs.mjs";
|
|
11
11
|
import { loadSkills, mirrorFiles } from "../lib/skills.mjs";
|
|
12
12
|
import { writeArtifacts } from "./artifacts.mjs";
|
|
13
|
+
import { hashFile, readBaseline, writeBaseline } from "../lib/baseline.mjs";
|
|
13
14
|
import { log, bold, cyan, dim } from "../lib/log.mjs";
|
|
14
15
|
|
|
15
16
|
/** Docs that are about Groundwork-the-product, not a per-project doc. */
|
|
@@ -36,6 +37,9 @@ export function init(targetDir, flags) {
|
|
|
36
37
|
if (minimal) log.info(dim(` (minimal: ${MINIMAL_SKILLS.join(", ")})`));
|
|
37
38
|
|
|
38
39
|
const counts = { added: 0, skipped: 0 };
|
|
40
|
+
// Hashes of everything this install writes — the baseline `update` compares
|
|
41
|
+
// against before overwriting anything.
|
|
42
|
+
const baselineFiles = {};
|
|
39
43
|
const bump = (r) => counts[r]++;
|
|
40
44
|
|
|
41
45
|
// 1. Skills → .claude/skills
|
|
@@ -46,6 +50,9 @@ export function init(targetDir, flags) {
|
|
|
46
50
|
const dest = path.join(root, TARGET.skills, name, "SKILL.md");
|
|
47
51
|
const r = copyFileSafe(src, dest, { force });
|
|
48
52
|
bump(r);
|
|
53
|
+
// Record what we wrote, so a later `update` can tell a local edit from an
|
|
54
|
+
// upstream change and refuse to clobber the former.
|
|
55
|
+
if (r === "added") baselineFiles[path.join(TARGET.skills, name, "SKILL.md")] = hashFile(src);
|
|
49
56
|
r === "added" ? log.added(`${TARGET.skills}/${name}/SKILL.md`) : null;
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -70,12 +77,10 @@ export function init(targetDir, flags) {
|
|
|
70
77
|
log.step("Docs (methodology, templates, phases, WORKSTREAMS)");
|
|
71
78
|
for (const rel of walk(PAYLOAD_DOCS)) {
|
|
72
79
|
if (DOC_EXCLUDE.has(rel)) continue;
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
path.join(root, TARGET.docs, rel),
|
|
76
|
-
{ force }
|
|
77
|
-
);
|
|
80
|
+
const src = path.join(PAYLOAD_DOCS, rel);
|
|
81
|
+
const r = copyFileSafe(src, path.join(root, TARGET.docs, rel), { force });
|
|
78
82
|
bump(r);
|
|
83
|
+
if (r === "added") baselineFiles[path.join(TARGET.docs, rel)] = hashFile(src);
|
|
79
84
|
}
|
|
80
85
|
// ARTIFACTS.md is generated from the manifest (single source), not copied.
|
|
81
86
|
if (!exists(path.join(root, TARGET.docs, "ARTIFACTS.md")) || force) {
|
|
@@ -102,6 +107,9 @@ export function init(targetDir, flags) {
|
|
|
102
107
|
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
103
108
|
fs.writeFileSync(marker, readPkgVersion() + "\n");
|
|
104
109
|
}
|
|
110
|
+
// Merge rather than replace: re-running init over an existing project must not
|
|
111
|
+
// discard baselines for files it skipped this time.
|
|
112
|
+
writeBaseline(root, { ...readBaseline(root), ...baselineFiles });
|
|
105
113
|
|
|
106
114
|
log.heading("Done");
|
|
107
115
|
log.ok(`${counts.added} files written, ${counts.skipped} skipped (already present)`);
|
package/src/commands/update.mjs
CHANGED
|
@@ -11,17 +11,33 @@ import { copyFileSafe, listDirs, exists, walk } from "../lib/fs.mjs";
|
|
|
11
11
|
import { loadSkills, mirrorFiles } from "../lib/skills.mjs";
|
|
12
12
|
import { writeArtifacts } from "./artifacts.mjs";
|
|
13
13
|
import { refreshableDocs } from "../lib/artifacts.mjs";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
classify,
|
|
16
|
+
hashFile,
|
|
17
|
+
isProtected,
|
|
18
|
+
readBaseline,
|
|
19
|
+
writeBaseline,
|
|
20
|
+
} from "../lib/baseline.mjs";
|
|
21
|
+
import { log, cyan, dim, green, bold, yellow } from "../lib/log.mjs";
|
|
15
22
|
|
|
16
23
|
/**
|
|
17
24
|
* Upgrade the workflow layer in an existing project, in place, WITHOUT touching
|
|
18
|
-
* project-authored docs
|
|
19
|
-
* scripts + the version marker. New skills
|
|
20
|
-
* installed too when `--all` is passed) —
|
|
25
|
+
* project-authored docs OR locally-customised skills. Refreshes installed
|
|
26
|
+
* skills + their mirrors + the helper scripts + the version marker. New skills
|
|
27
|
+
* introduced upstream are reported (and installed too when `--all` is passed) —
|
|
28
|
+
* never silently added by default.
|
|
29
|
+
*
|
|
30
|
+
* Anything edited locally is KEPT and named in the summary. `docs/.groundwork/
|
|
31
|
+
* baseline.json` records the hash of what we last installed, which is what lets
|
|
32
|
+
* "the project edited this" be told apart from "upstream changed this"; a file
|
|
33
|
+
* with no baseline entry is kept too, because absence of a record is not
|
|
34
|
+
* evidence that a file is untouched. `--force-skills` / `--force-docs` override,
|
|
35
|
+
* and `--dry-run` reports the plan without writing anything.
|
|
21
36
|
*/
|
|
22
37
|
export function update(targetDir, flags = {}) {
|
|
23
38
|
const root = path.resolve(targetDir || ".");
|
|
24
|
-
|
|
39
|
+
const dry = flags["dry-run"] === true || flags.dryRun === true;
|
|
40
|
+
log.heading(`${dry ? "Update plan (dry run)" : "Updating Groundwork"} → ${cyan(root)}`);
|
|
25
41
|
|
|
26
42
|
const installed = listDirs(path.join(root, TARGET.skills));
|
|
27
43
|
if (installed.length === 0) {
|
|
@@ -34,77 +50,161 @@ export function update(targetDir, flags = {}) {
|
|
|
34
50
|
// With --all, also install skills added upstream since this project's init.
|
|
35
51
|
const toRefresh = flags.all ? [...installed, ...newSkills] : installed;
|
|
36
52
|
|
|
37
|
-
|
|
53
|
+
const baseline = readBaseline(root);
|
|
54
|
+
const nextBaseline = { ...baseline };
|
|
55
|
+
// Files we declined to overwrite, with why — reported at the end, never
|
|
56
|
+
// buried. A silent skip and a silent clobber are the same failure: the user
|
|
57
|
+
// cannot tell what happened to their file.
|
|
58
|
+
const kept = [];
|
|
59
|
+
|
|
60
|
+
// 1. Skills — refresh, EXCEPT where the project has edited the SKILL.md.
|
|
38
61
|
let refreshed = 0;
|
|
39
62
|
let added = 0;
|
|
63
|
+
let unchanged = 0;
|
|
40
64
|
for (const name of toRefresh) {
|
|
41
65
|
const src = path.join(PAYLOAD_SKILLS, name, "SKILL.md");
|
|
42
66
|
if (!exists(src)) {
|
|
43
67
|
log.warn(`${name}: not in payload (kept as-is)`);
|
|
44
68
|
continue;
|
|
45
69
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
const rel = path.join(TARGET.skills, name, "SKILL.md");
|
|
71
|
+
const dest = path.join(root, rel);
|
|
72
|
+
const verdict = classify(dest, src, baseline[rel]);
|
|
73
|
+
|
|
74
|
+
if (verdict === "current") {
|
|
75
|
+
unchanged++;
|
|
76
|
+
nextBaseline[rel] = hashFile(src);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (isProtected(verdict) && !flags["force-skills"]) {
|
|
80
|
+
kept.push({ rel, verdict, kind: "skill" });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!dry) {
|
|
84
|
+
copyFileSafe(src, dest, { force: true });
|
|
85
|
+
nextBaseline[rel] = hashFile(src);
|
|
86
|
+
}
|
|
49
87
|
installed.includes(name) ? refreshed++ : added++;
|
|
50
88
|
}
|
|
51
89
|
|
|
52
|
-
// 2. Regenerate mirrors for the resulting skill set
|
|
90
|
+
// 2. Regenerate mirrors for the resulting skill set. Mirrors are DERIVED from
|
|
91
|
+
// the installed skills, so a kept customisation propagates into them — the
|
|
92
|
+
// mirror must never contradict the SKILL.md it came from.
|
|
53
93
|
const skillSet = flags.all ? toRefresh : installed;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
94
|
+
if (!dry) {
|
|
95
|
+
for (const f of mirrorFiles(loadSkills(skillSet), {
|
|
96
|
+
cursorDir: path.join(root, TARGET.cursor),
|
|
97
|
+
vscodeDir: path.join(root, TARGET.vscode),
|
|
98
|
+
})) {
|
|
99
|
+
fs.mkdirSync(path.dirname(f.rel), { recursive: true });
|
|
100
|
+
fs.writeFileSync(f.rel, f.content);
|
|
101
|
+
}
|
|
60
102
|
}
|
|
61
103
|
|
|
62
104
|
// 3. Refresh helper scripts (safe — tooling, not your docs)
|
|
63
105
|
let scripts = 0;
|
|
64
106
|
if (exists(PAYLOAD_SCRIPTS)) {
|
|
65
107
|
for (const rel of walk(PAYLOAD_SCRIPTS)) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
108
|
+
if (!dry) {
|
|
109
|
+
copyFileSafe(
|
|
110
|
+
path.join(PAYLOAD_SCRIPTS, rel),
|
|
111
|
+
path.join(root, TARGET.scripts, rel),
|
|
112
|
+
{ force: true }
|
|
113
|
+
);
|
|
114
|
+
}
|
|
71
115
|
scripts++;
|
|
72
116
|
}
|
|
73
117
|
}
|
|
74
118
|
|
|
75
119
|
// 4. Regenerate the generated reference doc (ARTIFACTS.md) — not project-authored
|
|
76
|
-
writeArtifacts(root);
|
|
120
|
+
if (!dry) writeArtifacts(root);
|
|
77
121
|
|
|
78
|
-
// 4b. With --docs, refresh the generic reference docs (methodology, COMMANDS
|
|
79
|
-
//
|
|
122
|
+
// 4b. With --docs, refresh the generic reference docs (methodology, COMMANDS).
|
|
123
|
+
// Generic across projects — but still skipped when this project has edited
|
|
124
|
+
// one, on the same rule as skills: we refresh what we wrote, not what you did.
|
|
80
125
|
let docs = 0;
|
|
81
126
|
if (flags.docs) {
|
|
82
127
|
for (const rel of refreshableDocs()) {
|
|
83
128
|
const src = path.join(PAYLOAD_DOCS, rel.replace(/^docs\//, ""));
|
|
84
129
|
if (!exists(src)) continue; // ARTIFACTS.md is generated, not in payload — skip
|
|
85
|
-
|
|
130
|
+
const dest = path.join(root, rel);
|
|
131
|
+
const verdict = classify(dest, src, baseline[rel]);
|
|
132
|
+
if (verdict === "current") {
|
|
133
|
+
nextBaseline[rel] = hashFile(src);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (isProtected(verdict) && !flags["force-docs"]) {
|
|
137
|
+
kept.push({ rel, verdict, kind: "doc" });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (!dry) {
|
|
141
|
+
copyFileSafe(src, dest, { force: true });
|
|
142
|
+
nextBaseline[rel] = hashFile(src);
|
|
143
|
+
}
|
|
86
144
|
docs++;
|
|
87
145
|
}
|
|
88
146
|
}
|
|
89
147
|
|
|
90
|
-
// 5. Stamp the version marker
|
|
148
|
+
// 5. Stamp the version marker + the baseline of what we just installed
|
|
91
149
|
const version = pkgVersion();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
150
|
+
if (!dry) {
|
|
151
|
+
fs.mkdirSync(path.join(root, TARGET.docs, ".groundwork"), { recursive: true });
|
|
152
|
+
fs.writeFileSync(
|
|
153
|
+
path.join(root, TARGET.docs, ".groundwork", "VERSION"),
|
|
154
|
+
version + "\n"
|
|
155
|
+
);
|
|
156
|
+
writeBaseline(root, nextBaseline);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (dry) {
|
|
160
|
+
log.ok(
|
|
161
|
+
`Would refresh ${refreshed} skills${added ? `, add ${added}` : ""}, ${scripts} scripts${docs ? `, ${docs} reference docs` : ""} → v${version}`
|
|
162
|
+
);
|
|
163
|
+
} else {
|
|
164
|
+
log.ok(
|
|
165
|
+
`Refreshed ${refreshed} skills${added ? `, added ${added}` : ""}, ${scripts} scripts${docs ? `, ${docs} reference docs` : ""} → v${version}`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (unchanged) log.info(dim(` ${unchanged} skill(s) already current.`));
|
|
169
|
+
|
|
170
|
+
// Report what we did NOT overwrite. This block is the whole point of the
|
|
171
|
+
// change: the previous version force-copied these and then printed that your
|
|
172
|
+
// docs had been left untouched.
|
|
173
|
+
if (kept.length) {
|
|
174
|
+
const skills = kept.filter((k) => k.kind === "skill");
|
|
175
|
+
log.heading(`Kept ${kept.length} locally-modified file(s) — NOT overwritten`);
|
|
176
|
+
for (const k of kept) {
|
|
177
|
+
const why =
|
|
178
|
+
k.verdict === "modified"
|
|
179
|
+
? "edited since Groundwork installed it"
|
|
180
|
+
: "differs from the payload, and predates the install baseline — cannot verify it is unedited";
|
|
181
|
+
console.log(` ${yellow("~")} ${k.rel} ${dim(`(${why})`)}`);
|
|
182
|
+
}
|
|
183
|
+
console.log(
|
|
184
|
+
dim(
|
|
185
|
+
` Review with \`git diff\`, then overwrite deliberately: ${bold(
|
|
186
|
+
skills.length ? "--force-skills" : "--force-docs"
|
|
187
|
+
)}.`
|
|
188
|
+
)
|
|
189
|
+
);
|
|
190
|
+
if (skills.length) {
|
|
191
|
+
console.log(
|
|
192
|
+
dim(
|
|
193
|
+
" A reverted SKILL.md changes agent behaviour silently — the next agent to run it\n" +
|
|
194
|
+
" does the wrong thing while looking correct. That is why these are kept by default."
|
|
195
|
+
)
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
97
199
|
|
|
98
|
-
log.ok(
|
|
99
|
-
`Refreshed ${refreshed} skills${added ? `, added ${added}` : ""}, ${scripts} scripts${docs ? `, ${docs} reference docs` : ""} → v${version}`
|
|
100
|
-
);
|
|
101
200
|
log.info(
|
|
102
201
|
dim(
|
|
103
202
|
flags.docs
|
|
104
|
-
? " Generic reference docs refreshed;
|
|
105
|
-
: " Project docs left untouched. Use --docs to also refresh the generic reference docs (methodology, COMMANDS
|
|
203
|
+
? " Generic reference docs refreshed; project-authored docs and local edits were left untouched."
|
|
204
|
+
: " Project docs left untouched. Use --docs to also refresh the generic reference docs (methodology, COMMANDS)."
|
|
106
205
|
)
|
|
107
206
|
);
|
|
207
|
+
if (dry) log.info(dim(" Dry run — nothing was written."));
|
|
108
208
|
|
|
109
209
|
if (!flags.all && newSkills.length) {
|
|
110
210
|
log.heading("New skills available upstream (not installed)");
|
package/src/lib/artifacts.mjs
CHANGED
|
@@ -26,11 +26,11 @@ export const ARTIFACTS = [
|
|
|
26
26
|
{ path: "docs/DONE.md", 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" },
|
|
27
27
|
{ path: "docs/DESIGN_SYSTEM.md", scope: "project", purpose: "Visual language (optional)", writtenBy: "/kickstart", readBy: "frontend work", rules: "Only when there's a UI" },
|
|
28
28
|
{ path: "docs/FACTS.md", scope: "project", purpose: "Verified project facts (settled world-model)", writtenBy: "whoever verified (or set-fact.mjs)", readBy: "everyone, groundwork doctor", rules: "One writer per fact; entries carry verified/by/method; doctor flags stale (>14d)" },
|
|
29
|
+
{ path: "docs/_INDEX.md", scope: "project", purpose: "Obsidian Map of Content (human navigation)", writtenBy: "/kickstart (scaffold), then you", readBy: "humans, agents navigating docs", rules: "Curated per project — installed once, never auto-refreshed" },
|
|
29
30
|
|
|
30
31
|
// --- reference (shipped, generic, same across projects → refreshable by `update --docs`) ---
|
|
31
32
|
{ path: "docs/GROUNDWORK_METHODOLOGY.md", scope: "reference", purpose: "The full methodology" },
|
|
32
33
|
{ path: "docs/COMMANDS.md", scope: "reference", purpose: "Command/skill guide" },
|
|
33
|
-
{ path: "docs/_INDEX.md", scope: "reference", purpose: "Obsidian Map of Content (human navigation)" },
|
|
34
34
|
{ path: "docs/ARTIFACTS.md", scope: "reference", purpose: "This file (generated from the manifest)" },
|
|
35
35
|
{ path: "docs/.groundwork/scripts/", scope: "reference", purpose: "Deterministic helpers: check-task.mjs (/check-task), phase-status.mjs (groundwork status), check-versions.mjs (/check-versions), set-fact.mjs (FACTS upsert)" },
|
|
36
36
|
{ path: "docs/.groundwork/VERSION", scope: "reference", purpose: "Installed Groundwork version" },
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Install baseline: what Groundwork last WROTE to this project, by content hash.
|
|
3
|
+
*
|
|
4
|
+
* Why this file exists. `update` force-refreshes skills and reference docs, and
|
|
5
|
+
* before this it did so unconditionally — so a project that had customised an
|
|
6
|
+
* installed SKILL.md lost the customisation on the next `groundwork update`,
|
|
7
|
+
* with no flag involved and a summary line that said "your project-authored docs
|
|
8
|
+
* were left untouched". Overwriting a *skill* is worse than overwriting a doc:
|
|
9
|
+
* it silently changes agent BEHAVIOUR, and the next agent to run the reverted
|
|
10
|
+
* skill does the wrong thing while looking correct.
|
|
11
|
+
*
|
|
12
|
+
* Comparing the project file against the payload cannot tell the two cases
|
|
13
|
+
* apart — "the project edited it" and "upstream changed it" both read as
|
|
14
|
+
* different. So we record the hash of what we installed. Then:
|
|
15
|
+
*
|
|
16
|
+
* project == baseline → untouched since we wrote it → safe to refresh
|
|
17
|
+
* project != baseline → someone edited it locally → keep, and say so
|
|
18
|
+
* no baseline entry → we cannot know → keep, and say so
|
|
19
|
+
*
|
|
20
|
+
* The no-baseline case matters: every project installed before this existed has
|
|
21
|
+
* no entries, so `update` must fail SAFE there — keep the file, name it, and let
|
|
22
|
+
* the user pass --force-skills / --force-docs once they have looked. A missing
|
|
23
|
+
* record is not evidence of an unmodified file.
|
|
24
|
+
*/
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
import { TARGET } from "./paths.mjs";
|
|
29
|
+
import { exists } from "./fs.mjs";
|
|
30
|
+
|
|
31
|
+
const BASELINE_REL = path.join(".groundwork", "baseline.json");
|
|
32
|
+
|
|
33
|
+
/** Absolute path to a project's baseline file. */
|
|
34
|
+
export function baselinePath(root) {
|
|
35
|
+
return path.join(root, TARGET.docs, BASELINE_REL);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function hashText(text) {
|
|
39
|
+
// Normalize line endings so a CRLF checkout is not reported as edited.
|
|
40
|
+
return createHash("sha256").update(String(text).replace(/\r\n/g, "\n")).digest("hex");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function hashFile(p) {
|
|
44
|
+
try {
|
|
45
|
+
return hashText(fs.readFileSync(p, "utf8"));
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read the baseline map ({ "<rel>": "<sha256>" }). Missing/corrupt → empty. */
|
|
52
|
+
export function readBaseline(root) {
|
|
53
|
+
try {
|
|
54
|
+
const raw = JSON.parse(fs.readFileSync(baselinePath(root), "utf8"));
|
|
55
|
+
return raw && typeof raw === "object" && raw.files && typeof raw.files === "object"
|
|
56
|
+
? raw.files
|
|
57
|
+
: {};
|
|
58
|
+
} catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function writeBaseline(root, files) {
|
|
64
|
+
const p = baselinePath(root);
|
|
65
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
66
|
+
// Sorted keys so the file is diff-stable across runs.
|
|
67
|
+
const sorted = {};
|
|
68
|
+
for (const k of Object.keys(files).sort()) sorted[k] = files[k];
|
|
69
|
+
fs.writeFileSync(p, JSON.stringify({ version: 1, files: sorted }, null, 2) + "\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What `update` should do with one managed file.
|
|
74
|
+
*
|
|
75
|
+
* "absent" — not installed here; copying it adds a file, destroys nothing.
|
|
76
|
+
* "clean" — matches what we installed; refreshing is lossless.
|
|
77
|
+
* "current" — already identical to the payload; nothing to do.
|
|
78
|
+
* "modified" — differs from the recorded baseline: a local edit. KEEP.
|
|
79
|
+
* "unknown" — installed, differs from payload, no baseline to judge by. KEEP.
|
|
80
|
+
*/
|
|
81
|
+
export function classify(projectFile, payloadFile, baselineHash) {
|
|
82
|
+
if (!exists(projectFile)) return "absent";
|
|
83
|
+
const now = hashFile(projectFile);
|
|
84
|
+
const payload = hashFile(payloadFile);
|
|
85
|
+
if (now !== undefined && now === payload) return "current";
|
|
86
|
+
if (baselineHash === undefined) return "unknown";
|
|
87
|
+
return now === baselineHash ? "clean" : "modified";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when a classification means "do not overwrite without an explicit flag". */
|
|
91
|
+
export const isProtected = (verdict) => verdict === "modified" || verdict === "unknown";
|