@esneiderbravo/speclaw 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -185,9 +185,18 @@ speclaw update
185
185
  ```
186
186
 
187
187
  `update` upgrades the global package **and** brings the current project up to date
188
- without a re-init: any new standards, skills, commands, or feature steps are added
189
- **additively** — your existing files are never touched. It re-applies only the tool
190
- packs this project already uses.
188
+ without a re-init, splitting files by who owns them:
189
+
190
+ - **Managed files** (speclaw's workflow machinery — the skills, commands, rules,
191
+ and agent packs under `ai-specs/`) are **refreshed** to the new version, so
192
+ improvements actually reach your project. If you edited one locally, `update`
193
+ reports the overwrite so you can recover your copy from git; pass `--backup` to
194
+ also keep a `<file>.bak`. Any `*.bak` is gitignored.
195
+ - **Personalized files** (your constitution and standards — `CLAUDE.md`,
196
+ `AGENTS.md`, `LAWS.md`, `docs/standards/*`, `docs/compass.md`,
197
+ `lawbook/config.yaml`) are **never auto-edited**. When a release changes their
198
+ speclaw-authored content, `update` prints a prompt for **the agent you're
199
+ using** to apply the change while preserving your project's specifics.
191
200
 
192
201
  - `speclaw update --check` — report whether an update exists, change nothing.
193
202
  - `NO_UPDATE_NOTIFIER=1` — silence the reminder.
@@ -113,12 +113,12 @@ export async function runInit(flags) {
113
113
  c.bold(c.cream(String(stats.embeddings))) +
114
114
  c.muted(" embeddings"));
115
115
  }
116
- // 3. Handoff prompt for the chosen agent — printed as a single flush-left
117
- // line so it copy-pastes cleanly (no borders, no wrapping artifacts).
118
- const primary = agentById(agents[0]);
116
+ // 3. Handoff prompt — printed as a single flush-left line so it copy-pastes
117
+ // cleanly (no borders, no wrapping artifacts). Agent-generic on purpose:
118
+ // speclaw supports any agent, so it addresses "the agent you're using".
119
119
  ui.step("You're set — one last step");
120
120
  ui.plain();
121
- ui.info(`Copy this and paste it into ${c.cyan(primary.label)}:`);
121
+ ui.info(`Copy this and paste it into ${c.cyan("the agent you're using")}:`);
122
122
  ui.plain();
123
123
  console.log(c.cream("Complete speclaw's foundation: analyze this repo and fill LAWS.md and " +
124
124
  "docs/standards/* with its real architecture, quality gates and conventions. " +
@@ -5,12 +5,44 @@ import { ui, c } from "../lib/ui.js";
5
5
  import { checkForUpdates, isNewer } from "../lib/update-check.js";
6
6
  import { pkgName, pkgVersion } from "../../shared/version.js";
7
7
  import { scaffold } from "../../modules/foundation/scaffold.js";
8
+ import { PERSONALIZED } from "../../modules/foundation/ownership.js";
8
9
  import { detectConfiguredAgents } from "../../shared/agents.js";
9
10
  import { readManifest } from "../../shared/manifest.js";
10
11
  import { loadPacks } from "../../modules/tools/packs.js";
11
12
  import { detectProjectName } from "./init.js";
13
+ // The first migration must be tagged at the version that introduces this
14
+ // mechanism (0.1.12): `isNewer` is strict, so an entry tagged at an already-
15
+ // shipped version (e.g. 0.1.11) would never fire for projects already on it.
12
16
  const MIGRATIONS = [
13
- // e.g. { version: "0.3.0", describe: "…", run: (p, r) => { … } }
17
+ {
18
+ version: "0.1.12",
19
+ describe: "Compass-first rule + reports mandatory step in personalized files",
20
+ agentPrompt: "- In CLAUDE.md and AGENTS.md, change the Compass rule to 'Compass first, always': " +
21
+ "call Compass (compass_explore/search/recall) before grep/sed/cat/Read for any code " +
22
+ "question — including files you already know by name — and fall back to manual file " +
23
+ "tools only after a Compass call returns nothing useful, the graph is missing, or the " +
24
+ "target isn't indexed code (CSS/JSON/logs). Update the matching Compass row in LAWS.md " +
25
+ "and the intro of docs/compass.md the same way.\n" +
26
+ "- In lawbook/config.yaml, add this mandatory task step before the docs/archive steps: " +
27
+ '"Produce the discipline reports under reports/ (unit/integration/e2e results for what ' +
28
+ 'the feature touched)."\n' +
29
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
30
+ },
31
+ {
32
+ version: "0.1.13",
33
+ describe: "Required report structure in the testing standard (personalized file)",
34
+ agentPrompt: "- In docs/standards/testing-standards.md, strengthen the 'Reports' section so a " +
35
+ "discipline report MUST follow a fixed structure (evidence reproducible, not improvised): " +
36
+ "(1) a header — discipline, change, date, branch, and the environment/working directory " +
37
+ "the commands ran in; (2) a gates-and-results table with each check, the exact command, " +
38
+ "and the real result including pass/fail counts; (3) the tests added or updated and what " +
39
+ "each asserts; (4) a spec-scenario coverage table mapping every '#### Scenario' in the " +
40
+ "change's delta specs to how it was verified (a test, a gate, or a manual step); (5) an " +
41
+ "honest declaration of any pre-existing or unrelated failures with proof, or 'none'; " +
42
+ "(6) the manual steps not automated, or 'none'; (7) a one-line verdict. Keep the existing " +
43
+ "'test kind does not yet apply' escape hatch. If the section is missing, add it.\n" +
44
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
45
+ },
14
46
  ];
15
47
  /**
16
48
  * Update speclaw and bring the current project up to date without a full re-init:
@@ -25,6 +57,7 @@ export async function runUpdate(flags) {
25
57
  const cwd = process.cwd();
26
58
  const migrateOnly = Boolean(flags["migrate-only"]);
27
59
  const checkOnly = Boolean(flags.check);
60
+ const backup = Boolean(flags.backup);
28
61
  const winShell = process.platform === "win32";
29
62
  if (!migrateOnly) {
30
63
  ui.step("Checking for updates");
@@ -49,8 +82,10 @@ export async function runUpdate(flags) {
49
82
  }
50
83
  ui.ok(`Updated to ${latest}`);
51
84
  // Re-exec the NEWLY installed binary so migrations run with the new assets
52
- // and any new feature steps — not this (now-stale) process.
53
- const re = spawnSync("speclaw", ["update", "--migrate-only"], {
85
+ // and any new feature steps — not this (now-stale) process. Carry --backup
86
+ // through so the refresh honors it after the upgrade.
87
+ const reArgs = ["update", "--migrate-only", ...(backup ? ["--backup"] : [])];
88
+ const re = spawnSync("speclaw", reArgs, {
54
89
  stdio: "inherit",
55
90
  shell: winShell,
56
91
  });
@@ -66,13 +101,18 @@ export async function runUpdate(flags) {
66
101
  return;
67
102
  }
68
103
  }
69
- applyProjectMigrations(cwd);
104
+ applyProjectMigrations(cwd, backup);
70
105
  }
71
106
  /**
72
107
  * Additively apply the current version's content and feature steps to a project.
73
108
  * No-op with a hint when the directory isn't a speclaw project.
109
+ *
110
+ * @param cwd - Project root to update.
111
+ * @param backup - When true, a locally edited managed file is copied to
112
+ * `<file>.bak` before it is refreshed; the default overwrites it in place
113
+ * (recoverable from git) and only reports the overwrite.
74
114
  */
75
- function applyProjectMigrations(cwd) {
115
+ function applyProjectMigrations(cwd, backup) {
76
116
  const initialized = fs.existsSync(path.join(cwd, "ai-specs")) || fs.existsSync(path.join(cwd, "LAWS.md"));
77
117
  if (!initialized) {
78
118
  ui.step("Project");
@@ -84,23 +124,62 @@ function applyProjectMigrations(cwd) {
84
124
  const agents = detectConfiguredAgents(cwd);
85
125
  const known = loadPacks();
86
126
  const packs = (readManifest(cwd)?.packs ?? []).filter((p) => p in known);
87
- // scaffold is non-destructive: only missing files are written, and it refreshes
88
- // the manifest to the current version. Existing files are left exactly as-is.
89
- const report = scaffold(cwd, { project_name: detectProjectName(cwd) }, packs, agents);
90
- const newFiles = report.written.filter((w) => !w.includes(".gitignore"));
91
- if (newFiles.length) {
92
- for (const w of newFiles)
93
- ui.ok(c.cream(path.relative(cwd, w.split(" (")[0])));
94
- ui.info(`${newFiles.length} new file(s) added · ${report.skipped.length} left untouched.`);
127
+ // Managed files (skills/commands/rules/agents) are refreshed to the current
128
+ // version; personalized files (constitution/standards/config) are never
129
+ // rewritten those changes are handed to the user's agent below.
130
+ const report = scaffold(cwd, { project_name: detectProjectName(cwd) }, packs, agents, {
131
+ refreshManaged: true,
132
+ backup,
133
+ });
134
+ const changed = report.written.filter((w) => !w.includes(".gitignore"));
135
+ if (changed.length) {
136
+ for (const w of changed)
137
+ ui.ok(c.cream(path.relative(cwd, w)));
138
+ ui.info(`${changed.length} file(s) added/refreshed · ${report.skipped.length} left untouched.`);
95
139
  }
96
140
  else {
97
- ui.ok("Content already up to date — nothing new to add.");
141
+ ui.ok("Managed content already up to date — nothing to refresh.");
142
+ }
143
+ // A diverged managed file is always reported so the user can recover their
144
+ // edits; with --backup it is also kept as a `.bak`, otherwise it is overwritten
145
+ // in place (git holds the prior content).
146
+ for (const b of report.backedUp) {
147
+ const rel = path.relative(cwd, b);
148
+ ui.warn(`${c.cream(rel)} had local edits — saved as ${rel}.bak before refreshing.`);
98
149
  }
99
- const pending = MIGRATIONS.filter((m) => isNewer(m.version, fromVersion));
150
+ const overwritten = report.refreshedDiverged.filter((f) => !report.backedUp.includes(f));
151
+ for (const f of overwritten) {
152
+ const rel = path.relative(cwd, f);
153
+ ui.warn(`${c.cream(rel)} had local edits — overwritten with the current version. ` +
154
+ `Recover from git, or re-run with ${ui.code("--backup")} to keep a .bak.`);
155
+ }
156
+ // A project several releases behind jumps straight to @latest, so apply EVERY
157
+ // migration newer than its recorded version — not just the next one — oldest
158
+ // first (sorted, so array order can't matter). Entries are cumulative: never
159
+ // delete a shipped migration, or a laggard crossing it later would miss it.
160
+ const pending = MIGRATIONS.filter((m) => isNewer(m.version, fromVersion)).sort((a, b) => isNewer(a.version, b.version) ? 1 : isNewer(b.version, a.version) ? -1 : 0);
100
161
  for (const m of pending) {
162
+ if (!m.run)
163
+ continue;
101
164
  ui.step(`Step for ${m.version}: ${m.describe}`);
102
165
  m.run(cwd, report);
103
166
  }
167
+ // Personalized files can't be auto-edited (they hold project specifics), so
168
+ // hand their changes to whatever agent the user runs — never a hardcoded one.
169
+ const prompt = pending
170
+ .map((m) => m.agentPrompt)
171
+ .filter(Boolean)
172
+ .join("\n");
173
+ if (prompt) {
174
+ ui.plain();
175
+ ui.step("One step for the agent you're using");
176
+ ui.info(`Personalized files (${PERSONALIZED.join(", ")}) hold your project specifics, so ` +
177
+ `speclaw won't edit them. Paste this into the agent you're using to apply this ` +
178
+ `release's changes while keeping your content:`);
179
+ ui.plain();
180
+ console.log(c.cream(prompt));
181
+ ui.plain();
182
+ }
104
183
  ui.plain();
105
184
  ui.ok(`On ${c.cyan(pkgVersion())}. No re-init needed.`);
106
185
  }
@@ -32,3 +32,30 @@ suppressing a linter or deleting a test.
32
32
  - The mandatory spec task steps
33
33
  ([`lawbook.md`](lawbook.md)) define which manual checks
34
34
  the agent must execute itself.
35
+
36
+ ## Reports — evidence travels with the change
37
+
38
+ Every change records its testing under `lawbook/changes/<name>/reports/`, one
39
+ file per discipline it touched (`backend.md`, `frontend.md`, …). `build`
40
+ produces them; archiving is blocked until the change has at least one discipline
41
+ report.
42
+
43
+ Each report MUST follow a fixed structure, so the evidence is reproducible rather
44
+ than improvised:
45
+
46
+ 1. a **header** — discipline, change, date, branch, and the environment or
47
+ working directory the commands ran in;
48
+ 2. a **gates-and-results table** — each check, the exact command, and its real
49
+ result with pass/fail counts;
50
+ 3. the **tests added or updated** and what each asserts (TDD evidence where it
51
+ applies);
52
+ 4. a **spec-scenario coverage table** mapping every `#### Scenario` in the
53
+ change's delta specs to how it was verified (a test, a gate, or a manual step);
54
+ 5. an honest declaration of any **pre-existing or unrelated failures** with proof
55
+ they are not caused by the change — or "none";
56
+ 6. the **manual steps not automated** — or "none";
57
+ 7. a one-line **verdict**.
58
+
59
+ When a test kind does not yet apply (e.g. no unit runner), the report says so in
60
+ place of that evidence and records the gates and manual verification that stood
61
+ in.
@@ -0,0 +1,32 @@
1
+ // Which scaffolded files speclaw owns vs the user owns. `speclaw update` uses
2
+ // this split to decide what it may overwrite automatically (managed) and what it
3
+ // must leave to the user's agent via a prompt (personalized).
4
+ /**
5
+ * Project-relative trees that hold speclaw's workflow machinery. The user is not
6
+ * meant to edit these, so `speclaw update` overwrites them with the current
7
+ * version (backing up any local edits to `<file>.bak` first).
8
+ */
9
+ export const MANAGED_TREES = [
10
+ "ai-specs/skills",
11
+ "ai-specs/commands",
12
+ "ai-specs/rules",
13
+ "ai-specs/agents",
14
+ ];
15
+ /**
16
+ * Files filled with project specifics at init. `speclaw update` never rewrites
17
+ * these; when a release changes their speclaw-authored content, update emits an
18
+ * agent prompt to apply the change while preserving the user's content.
19
+ */
20
+ export const PERSONALIZED = [
21
+ "CLAUDE.md",
22
+ "AGENTS.md",
23
+ "LAWS.md",
24
+ "docs/standards",
25
+ "docs/compass.md",
26
+ "lawbook/config.yaml",
27
+ ];
28
+ /** True if a project-relative path sits under a managed tree. */
29
+ export function isManaged(relPath) {
30
+ const norm = relPath.split("\\").join("/");
31
+ return MANAGED_TREES.some((t) => norm === t || norm.startsWith(t + "/"));
32
+ }
@@ -6,7 +6,7 @@ import { emptyReport, ensureGitignore } from "../../shared/install.js";
6
6
  import { configureAgent } from "../../shared/agents.js";
7
7
  import { installWorkflow } from "../lawbook/register.js";
8
8
  import { installPack, loadPacks } from "../tools/packs.js";
9
- import { writeManifest } from "../../shared/manifest.js";
9
+ import { readManifest, writeManifest } from "../../shared/manifest.js";
10
10
  import { pkgVersion } from "../../shared/version.js";
11
11
  const ASSETS = assetsDir(import.meta.url);
12
12
  // Every {{var}} the foundation templates may reference. Ones the agent didn't
@@ -70,10 +70,15 @@ function renderFoundation(projectPath, vars, report) {
70
70
  * @param profile - Project identity and conventions for template rendering.
71
71
  * @param packNames - Tool pack names to install (the spec workflow is always installed).
72
72
  * @param agents - Agent ids to configure with symlinks + MCP; empty writes content only.
73
+ * @param opts - `refreshManaged: true` overwrites the managed trees (skills,
74
+ * commands, rules, agents) with the current version; `backup: true` also keeps
75
+ * a `<file>.bak` of any locally edited managed file before overwriting it (the
76
+ * default overwrites in place — git preserves the prior content). Default
77
+ * (init) is additive — existing files are kept.
73
78
  * @returns The install report augmented with the ordered next steps to run.
74
79
  * @throws If `projectPath` does not exist, or any pack name is unknown.
75
80
  */
76
- export function scaffold(projectPath, profile, packNames, agents = []) {
81
+ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}) {
77
82
  if (!fs.existsSync(projectPath)) {
78
83
  throw new Error(`projectPath does not exist: ${projectPath}`);
79
84
  }
@@ -83,16 +88,28 @@ export function scaffold(projectPath, profile, packNames, agents = []) {
83
88
  throw new Error(`Unknown packs: ${unknown.join(", ")}`);
84
89
  const report = { ...emptyReport(), nextSteps: [] };
85
90
  const vars = { ...FOUNDATION_DEFAULTS, ...profile };
86
- renderFoundation(projectPath, vars, report);
87
- installWorkflow(projectPath, vars, report); // spec module always
91
+ // Managed trees (MANAGED_TREES) carry speclaw's workflow logic and may be
92
+ // overwritten on update; the foundation (personalized) is always additive.
93
+ const record = {};
94
+ const managedOpts = {
95
+ overwrite: Boolean(opts.refreshManaged),
96
+ backup: Boolean(opts.backup),
97
+ projectPath,
98
+ baselines: readManifest(projectPath)?.baselines ?? {},
99
+ record,
100
+ };
101
+ renderFoundation(projectPath, vars, report); // personalized — never overwritten
102
+ installWorkflow(projectPath, vars, report, managedOpts); // managed
88
103
  for (const name of packNames)
89
- installPack(projectPath, name, vars, report); // tools module
104
+ installPack(projectPath, name, vars, report, managedOpts); // managed
90
105
  ensureGitignore(projectPath, ".speclaw/", "speclaw local code Compass (never commit)", report);
106
+ ensureGitignore(projectPath, "*.bak", "speclaw managed-file refresh backups", report);
91
107
  for (const id of agents)
92
108
  configureAgent(projectPath, id, report); // only the chosen agents
93
- // Record what was installed so `speclaw update` can re-apply only these packs
94
- // (additively) and gate feature migrations by version no full re-init.
95
- writeManifest(projectPath, pkgVersion(), packNames);
109
+ // Record what was installed so `speclaw update` can re-apply these packs and
110
+ // gate feature migrations by version, plus the managed-file baselines that let
111
+ // a later update tell user edits from stale files.
112
+ writeManifest(projectPath, pkgVersion(), packNames, record);
96
113
  report.nextSteps = [
97
114
  "Run the `lawbook_init` tool to set up the spec-driven workflow (creates lawbook/). No external CLI needed — it's built into speclaw.",
98
115
  "Run the `compass_index` tool to build the local code graph (.speclaw/). No install, no LLM — it's built into speclaw. Re-run it after significant edits.",
@@ -46,12 +46,33 @@ delegate manual testing to the user. Record what you verified.
46
46
  ## Step 5 — Write the discipline reports (mandatory)
47
47
 
48
48
  Record the evidence of testing under `lawbook/changes/<name>/reports/`, one file
49
- per discipline the change touched (`backend.md`, `frontend.md`, …). Each report
50
- states what was tested and the real results unit, integration, and end-to-end
51
- as applicable — with the commands run and their output and a one-line verdict.
52
- If a test kind does not yet apply (e.g. no unit runner), say so and record the
53
- gates and manual verification that stood in. Omit disciplines the change did not
54
- touch. The archive is blocked until at least one discipline report exists.
49
+ per discipline the change touched (`backend.md`, `frontend.md`, …). Omit
50
+ disciplines the change did not touch; the archive is blocked until at least one
51
+ discipline report exists.
52
+
53
+ Each report MUST follow this structure, in order the fixed shape is what makes
54
+ the evidence trustworthy and reproducible, rather than left to improvisation:
55
+
56
+ 1. **Title + header** — `# <Discipline> checks — <change> (<date>)`, then a line
57
+ `Date · Branch · Environment/cwd` naming where the commands ran.
58
+ 2. **Gates & results** — a `| Check | Command | Result |` table: each gate, the
59
+ exact command, and its real result with pass/fail counts (e.g. "62 files, 434
60
+ passed") and ✅/⚠️/❌. Quote real output — never paraphrase a green you did
61
+ not see.
62
+ 3. **Tests added / updated** — each new or changed test and what it asserts; note
63
+ TDD evidence ("failed before the fix, passes after") where it applies.
64
+ 4. **Spec-scenario coverage** — a table mapping each `#### Scenario` in this
65
+ change's delta specs to how it was verified (a test id, a gate, or a manual
66
+ step). Every scenario must appear.
67
+ 5. **Pre-existing / unrelated failures** — any failing check not caused by this
68
+ change, with proof it is pre-existing (e.g. it reproduces with the change
69
+ stashed) — or state "none".
70
+ 6. **Pending manual steps** — anything not automated, stated plainly — or "none".
71
+ 7. **Verdict** — one line.
72
+
73
+ If a test kind does not yet apply (e.g. no unit runner), the report says so in
74
+ place of that evidence and records the gates and manual verification that stood
75
+ in.
55
76
 
56
77
  ## Step 6 — Hand off
57
78
 
@@ -54,8 +54,10 @@ Create under `lawbook/changes/<name>/`:
54
54
  updated; archive within the PR).
55
55
  - **reports/** — create the folder with a short `reports/README.md` naming the
56
56
  discipline reports (`backend.md`, `frontend.md`, … as relevant) that `build`
57
- will fill with real test results. Every change ships this folder; archive is
58
- blocked until it holds at least one discipline report.
57
+ will fill, following the required report structure (header · gates table ·
58
+ tests added · spec-scenario coverage · pre-existing failures · pending manual ·
59
+ verdict — see the `build` skill, Step 5). Every change ships this folder;
60
+ archive is blocked until it holds at least one discipline report.
59
61
 
60
62
  ## Step 4 — Validate
61
63
 
@@ -10,11 +10,11 @@ const ASSETS = assetsDir(import.meta.url);
10
10
  * the draft/build/sync/archive/explore skills, the /spec commands, and the
11
11
  * mandatory-task-steps rule. Always installed — it's the core workflow.
12
12
  */
13
- export function installWorkflow(projectPath, vars, report) {
13
+ export function installWorkflow(projectPath, vars, report, opts) {
14
14
  const aiSpecs = path.join(projectPath, "ai-specs");
15
- copyRendered(path.join(ASSETS, "skills"), path.join(aiSpecs, "skills"), vars, report);
16
- copyRendered(path.join(ASSETS, "commands"), path.join(aiSpecs, "commands", "lawbook"), vars, report);
17
- copyRendered(path.join(ASSETS, "rules"), path.join(aiSpecs, "rules"), vars, report);
15
+ copyRendered(path.join(ASSETS, "skills"), path.join(aiSpecs, "skills"), vars, report, opts);
16
+ copyRendered(path.join(ASSETS, "commands"), path.join(aiSpecs, "commands", "lawbook"), vars, report, opts);
17
+ copyRendered(path.join(ASSETS, "rules"), path.join(aiSpecs, "rules"), vars, report, opts);
18
18
  }
19
19
  // ─── The spec module: speclaw's own spec-driven workflow (no external OpenSpec) ───
20
20
  // Mechanical operations behind the draft/build/sync/archive/explore commands.
@@ -21,9 +21,11 @@ export function loadPacks() {
21
21
  * @param name - Pack name to install (key in the manifest).
22
22
  * @param vars - Template variables applied while copying the pack's assets.
23
23
  * @param report - Mutated in place with the copy results.
24
+ * @param opts - Overwrite/baseline behavior forwarded to {@link copyRendered}
25
+ * (packs write into managed trees, so update refreshes them).
24
26
  * @throws If no pack with the given name exists in the manifest.
25
27
  */
26
- export function installPack(projectPath, name, vars, report) {
28
+ export function installPack(projectPath, name, vars, report, opts) {
27
29
  const packs = loadPacks();
28
30
  const def = packs[name];
29
31
  if (!def) {
@@ -33,11 +35,11 @@ export function installPack(projectPath, name, vars, report) {
33
35
  const aiSpecs = path.join(projectPath, "ai-specs");
34
36
  for (const sub of fs.readdirSync(packRoot, { withFileTypes: true })) {
35
37
  if (sub.isDirectory()) {
36
- copyRendered(path.join(packRoot, sub.name), path.join(aiSpecs, sub.name), vars, report);
38
+ copyRendered(path.join(packRoot, sub.name), path.join(aiSpecs, sub.name), vars, report, opts);
37
39
  }
38
40
  else if (sub.name.endsWith(".md")) {
39
41
  // loose .md at pack root = agent definitions
40
- copyRendered(packRoot, path.join(aiSpecs, "agents"), vars, report);
42
+ copyRendered(packRoot, path.join(aiSpecs, "agents"), vars, report, opts);
41
43
  break;
42
44
  }
43
45
  }
@@ -1,9 +1,21 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { render } from "./render.js";
4
5
  /** Create a fresh, empty {@link InstallReport} to accumulate results into. */
5
6
  export function emptyReport() {
6
- return { written: [], skipped: [], symlinks: [], unresolvedVars: [] };
7
+ return {
8
+ written: [],
9
+ skipped: [],
10
+ refreshedDiverged: [],
11
+ backedUp: [],
12
+ symlinks: [],
13
+ unresolvedVars: [],
14
+ };
15
+ }
16
+ /** SHA-256 of a file's intended content, used to track managed-file baselines. */
17
+ export function sha256(content) {
18
+ return crypto.createHash("sha256").update(content).digest("hex");
7
19
  }
8
20
  /**
9
21
  * Recursively copy a source tree into a destination, rendering {{var}}
@@ -13,33 +25,65 @@ export function emptyReport() {
13
25
  * @param srcDir - Source directory tree to copy from.
14
26
  * @param destDir - Destination directory (created if missing).
15
27
  * @param vars - Placeholder values used to render `.md`/`.mdc` files.
16
- * @param report - Report mutated in place with written, skipped, and unresolved-var entries.
28
+ * @param report - Report mutated in place with written, skipped, backed-up, and
29
+ * unresolved-var entries.
30
+ * @param opts - Overwrite/baseline behavior; omitted means additive (skip existing).
17
31
  */
18
- export function copyRendered(srcDir, destDir, vars, report) {
32
+ export function copyRendered(srcDir, destDir, vars, report, opts) {
19
33
  fs.mkdirSync(destDir, { recursive: true });
20
34
  for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
21
35
  const src = path.join(srcDir, entry.name);
22
36
  const dest = path.join(destDir, entry.name);
23
37
  if (entry.isDirectory()) {
24
- copyRendered(src, dest, vars, report);
25
- continue;
26
- }
27
- if (fs.existsSync(dest)) {
28
- report.skipped.push(dest);
38
+ copyRendered(src, dest, vars, report, opts);
29
39
  continue;
30
40
  }
41
+ // Compute the content speclaw wants at dest (rendered for md/mdc, raw otherwise).
42
+ let content;
31
43
  if (entry.name.endsWith(".md") || entry.name.endsWith(".mdc")) {
32
44
  const { output, unresolved } = render(fs.readFileSync(src, "utf8"), vars);
33
45
  unresolved.forEach((v) => {
34
46
  if (!report.unresolvedVars.includes(v))
35
47
  report.unresolvedVars.push(v);
36
48
  });
37
- fs.writeFileSync(dest, output);
49
+ content = output;
50
+ }
51
+ else {
52
+ content = fs.readFileSync(src);
53
+ }
54
+ const rel = opts?.projectPath ? path.relative(opts.projectPath, dest) : dest;
55
+ const newSha = sha256(content);
56
+ if (fs.existsSync(dest)) {
57
+ if (!opts?.overwrite) {
58
+ report.skipped.push(dest);
59
+ continue;
60
+ }
61
+ const current = fs.readFileSync(dest);
62
+ if (sha256(current) === newSha) {
63
+ // Already the current version — nothing to write, but keep the baseline.
64
+ if (opts.record)
65
+ opts.record[rel] = newSha;
66
+ continue;
67
+ }
68
+ const baseline = opts.baselines?.[rel];
69
+ if (!baseline || sha256(current) !== baseline) {
70
+ // Diverged from what we last wrote (or unknown). Record it so update can
71
+ // report it; keep a `.bak` only when asked — git already preserves the
72
+ // prior content, so the backup is opt-in, not the default.
73
+ if (opts.backup) {
74
+ fs.copyFileSync(dest, dest + ".bak");
75
+ report.backedUp.push(dest);
76
+ }
77
+ report.refreshedDiverged.push(dest);
78
+ }
79
+ fs.writeFileSync(dest, content);
38
80
  }
39
81
  else {
40
- fs.copyFileSync(src, dest);
82
+ fs.writeFileSync(dest, content);
41
83
  }
42
84
  report.written.push(dest);
85
+ if (opts?.record)
86
+ opts.record[rel] = newSha;
43
87
  }
44
88
  }
45
89
  /**
@@ -15,6 +15,9 @@ export function readManifest(projectPath) {
15
15
  return {
16
16
  version: String(m.version ?? "0.0.0"),
17
17
  packs: Array.isArray(m.packs) ? m.packs.map(String) : [],
18
+ baselines: m.baselines && typeof m.baselines === "object"
19
+ ? m.baselines
20
+ : {},
18
21
  };
19
22
  }
20
23
  catch {
@@ -28,11 +31,13 @@ export function readManifest(projectPath) {
28
31
  * @param projectPath - Project root to write into.
29
32
  * @param version - The speclaw version doing the write.
30
33
  * @param packs - Pack names installed in this run.
34
+ * @param baselines - Managed-file hashes to merge over the recorded ones.
31
35
  */
32
- export function writeManifest(projectPath, version, packs) {
36
+ export function writeManifest(projectPath, version, packs, baselines = {}) {
33
37
  const prev = readManifest(projectPath);
34
38
  const merged = Array.from(new Set([...(prev?.packs ?? []), ...packs]));
39
+ const mergedBaselines = { ...(prev?.baselines ?? {}), ...baselines };
35
40
  const p = manifestPath(projectPath);
36
41
  fs.mkdirSync(path.dirname(p), { recursive: true });
37
- fs.writeFileSync(p, JSON.stringify({ version, packs: merged }, null, 2) + "\n");
42
+ fs.writeFileSync(p, JSON.stringify({ version, packs: merged, baselines: mergedBaselines }, null, 2) + "\n");
38
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },