@davidbalzan/groundwork 0.3.4 → 0.4.1

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/LICENSE CHANGED
@@ -1,16 +1,14 @@
1
- MIT License
2
-
3
1
  Copyright (c) 2026 David Balzan
4
2
 
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
3
+ All rights reserved.
4
+
5
+ This software and its accompanying files are proprietary. No licence is
6
+ granted to use, copy, modify, merge, publish, distribute, sublicense, or sell
7
+ copies of this software, in whole or in part, except by a separate written
8
+ agreement signed by the copyright holder.
11
9
 
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
10
+ Possession of these files including by download from a public package
11
+ registry does not constitute such an agreement and grants no rights.
14
12
 
15
13
  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
14
  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidbalzan/groundwork",
3
- "version": "0.3.4",
3
+ "version": "0.4.1",
4
4
  "description": "Groundwork — an installable AI development workflow (skills + doc methodology) you bolt onto any repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,8 @@
13
13
  "src",
14
14
  "payload",
15
15
  "docs",
16
- "README.md"
16
+ "README.md",
17
+ "LICENSE"
17
18
  ],
18
19
  "keywords": [
19
20
  "ai",
@@ -26,13 +27,13 @@
26
27
  "methodology"
27
28
  ],
28
29
  "author": "David Balzan",
29
- "license": "MIT",
30
+ "license": "UNLICENSED",
30
31
  "dependencies": {
31
- "@davidbalzan/groundwork-seam": "0.1.2"
32
+ "@davidbalzan/groundwork-seam": "0.1.5"
32
33
  },
33
34
  "scripts": {
34
35
  "groundwork": "node src/cli.mjs",
35
- "test": "node --test test/*.test.mjs",
36
+ "test": "node ../../scripts/assert-tests-exist.mjs test/*.test.mjs && node --test test/*.test.mjs",
36
37
  "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
38
  }
38
39
  }
@@ -38,6 +38,6 @@ glyphs, not ASCII. Parsers (doctor, `set-fact.mjs`, UIs) split on those glyphs.
38
38
  ## Facts
39
39
 
40
40
  <!-- add entries in the pinned format above, e.g.:
41
- - `auto-disavow-flag` — does NOT exist anywhere in the codebase
42
- verified: 2026-07-02T14:30Z · by: disavow-worker-1 · method: git grep + gh api origin/main
41
+ - `nightly-export-job` — does NOT exist anywhere in the codebase
42
+ verified: 2026-07-02T14:30Z · by: <project>-worker-1 · method: git grep + gh api origin/main
43
43
  -->
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
  "",
@@ -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)");
@@ -15,7 +15,7 @@ import path from "node:path";
15
15
  *
16
16
  * Shared-token suppression: tokens that also appear in the ADR title / Decision / Context
17
17
  * (alias-expanded) are the accepted path, not evidence of the reject. Generic project-wide
18
- * paths (`docs`, `templates`, `readme`, `wiki`) are not hits. Cass burned an audit cycle on
18
+ * paths (`docs`, `templates`, `readme`, `wiki`) are not hits. A consumer fleet burned an audit cycle on
19
19
  * `next` (accepted Next.js), top-level `docs/`, and `docs/templates`.
20
20
  */
21
21
 
@@ -85,7 +85,7 @@ const STOP = new Set(["the", "and", "with", "only", "over", "for", "app", "api",
85
85
 
86
86
  /**
87
87
  * Top-level / workspace path tokens that every repo tends to have. A hit on these is
88
- * project-wide, not evidence of a rejected alternative (cass: `docs/`, `docs/templates`).
88
+ * project-wide, not evidence of a rejected alternative (field report: `docs/`, `docs/templates`).
89
89
  * Dependency names are not filtered here — `next` still fires unless it is in `own`.
90
90
  */
91
91
  export const GENERIC_PATHS = new Set(["docs", "templates", "readme", "wiki"]);
@@ -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";