@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidbalzan/groundwork",
3
- "version": "0.3.3",
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.2"
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
  "",
@@ -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
- const phaseDir = path.join(docs, "phases");
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
- const factsFile = path.join(docs, "FACTS.md");
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;
@@ -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 r = copyFileSafe(
74
- path.join(PAYLOAD_DOCS, rel),
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)`);
@@ -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 { log, cyan, dim, green, bold } from "../lib/log.mjs";
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. Refreshes installed skills + their mirrors + the helper
19
- * scripts + the version marker. New skills introduced upstream are reported (and
20
- * installed too when `--all` is passed) — never silently added by default.
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
- log.heading(`Updating Groundwork → ${cyan(root)}`);
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
- // 1. Skills (force-refresh)
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
- copyFileSafe(src, path.join(root, TARGET.skills, name, "SKILL.md"), {
47
- force: true,
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
- for (const f of mirrorFiles(loadSkills(skillSet), {
55
- cursorDir: path.join(root, TARGET.cursor),
56
- vscodeDir: path.join(root, TARGET.vscode),
57
- })) {
58
- fs.mkdirSync(path.dirname(f.rel), { recursive: true });
59
- fs.writeFileSync(f.rel, f.content);
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
- copyFileSafe(
67
- path.join(PAYLOAD_SCRIPTS, rel),
68
- path.join(root, TARGET.scripts, rel),
69
- { force: true }
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, _INDEX).
79
- // These are the same across projects; project-authored docs are NEVER touched.
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
- copyFileSafe(src, path.join(root, rel), { force: true });
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
- fs.mkdirSync(path.join(root, TARGET.docs, ".groundwork"), { recursive: true });
93
- fs.writeFileSync(
94
- path.join(root, TARGET.docs, ".groundwork", "VERSION"),
95
- version + "\n"
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; your project-authored docs were left untouched."
105
- : " Project docs left untouched. Use --docs to also refresh the generic reference docs (methodology, COMMANDS, _INDEX)."
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)");
@@ -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";