@friedbotstudio/create-baseline 0.3.0 → 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.
Files changed (30) hide show
  1. package/README.md +10 -4
  2. package/bin/cli.js +197 -119
  3. package/obj/template/.claude/skills/changelog/SKILL.md +69 -0
  4. package/obj/template/.claude/skills/changelog/changelog.mjs +163 -0
  5. package/obj/template/.claude/skills/changelog/classifier.mjs +49 -0
  6. package/obj/template/.claude/skills/changelog/state-writer.mjs +19 -0
  7. package/obj/template/.claude/skills/changelog/tests/consent-expired_test.sh +126 -0
  8. package/obj/template/.claude/skills/changelog/tests/golden-path_test.sh +191 -0
  9. package/obj/template/.claude/skills/changelog/tests/idempotent-reentry_test.sh +121 -0
  10. package/obj/template/.claude/skills/changelog/tests/keepachangelog-unreleased-preserved_test.mjs +149 -0
  11. package/obj/template/.claude/skills/changelog/tests/non-git-shortcircuit_test.sh +98 -0
  12. package/obj/template/.claude/skills/changelog/tests/preview-only_test.sh +96 -0
  13. package/obj/template/.claude/skills/changelog/tests/run.sh +28 -0
  14. package/obj/template/.claude/skills/changelog/unreleased-writer.mjs +155 -0
  15. package/obj/template/.claude/skills/changelog/version-preview.mjs +124 -0
  16. package/obj/template/.claude/skills/commit/SKILL.md +1 -1
  17. package/obj/template/.claude/skills/harness/SKILL.md +3 -1
  18. package/obj/template/.claude/skills/triage/SKILL.md +6 -5
  19. package/obj/template/CLAUDE.md +2 -2
  20. package/obj/template/docs/init/seed.md +4 -4
  21. package/obj/template/manifest.json +21 -7
  22. package/package.json +5 -2
  23. package/src/CLAUDE.template.md +2 -2
  24. package/src/cli/merge.js +15 -10
  25. package/src/cli/tui/doctor.js +56 -0
  26. package/src/cli/tui/install.js +79 -0
  27. package/src/cli/tui/meta.js +30 -0
  28. package/src/cli/tui/tokens.js +38 -0
  29. package/src/cli/tui/upgrade.js +100 -0
  30. package/src/seed.template.md +4 -4
package/README.md CHANGED
@@ -37,11 +37,11 @@ A discipline layer for Claude Code. Hooks at every tool boundary, a workflow tha
37
37
  > [!IMPORTANT]
38
38
  > **Install in one line:** `npx @friedbotstudio/create-baseline ./your-project`
39
39
  >
40
- > The CLI fetches the published package, runs the install, and leaves your project with `.claude/`, `CLAUDE.md`, `docs/init/seed.md`, and `.mcp.json`. Re-run with `--merge` to bring an existing install forward; with `--dry-run` to preview without writing; with `doctor` to report drift.
40
+ > The CLI fetches the published package, runs the install, and leaves your project with `.claude/`, `CLAUDE.md`, `docs/init/seed.md`, and `.mcp.json`. Re-run with the `upgrade` subcommand to bring an existing install forward (interactive in a TTY, batch-mode in CI). Add `--dry-run` to preview, and run `doctor` to report drift (pass `--json` for machine output).
41
41
 
42
42
  ## What this is
43
43
 
44
- The Claude Code Baseline is a repository overlay shipped via `npx @friedbotstudio/create-baseline ./target`. It installs **22 hooks** at Claude's tool boundaries, **36 skills** organised into nine categories, **1 subagent** for parallel work in isolated worktrees, an **11-phase workflow** from intake to commit, and **3 user-typed consent gates** that Claude cannot forge.
44
+ The Claude Code Baseline is a repository overlay shipped via `npx @friedbotstudio/create-baseline ./target`. It installs **22 hooks** at Claude's tool boundaries, **37 skills** organised into nine categories, **1 subagent** for parallel work in isolated worktrees, an **11-phase workflow** from intake to commit, and **3 user-typed consent gates** that Claude cannot forge.
45
45
 
46
46
  Every soft engineering rule a team usually repeats every session — *don't push, don't `--amend`, don't self-approve specs, don't skip phases, don't mock internal modules* — becomes a structural guarantee because the hooks run **outside Claude's tool boundary**. Claude cannot disable a hook with a flag, cannot write a consent marker, cannot reorder the phases without an explicit exception that triage records on disk.
47
47
 
@@ -94,12 +94,14 @@ npx @friedbotstudio/create-baseline ./your-project
94
94
  # Force-overwrite an existing install (interactive — type 'overwrite')
95
95
  npx @friedbotstudio/create-baseline ./your-project --overwrite
96
96
 
97
- # Three-way merge against a previously-installed baseline:
97
+ # Upgrade an existing install against a newer baseline version.
98
+ # In a TTY, each customised file becomes a keep-mine / take-theirs / abort
99
+ # prompt. In CI / piped stdout, reproduces the prior --merge behaviour:
98
100
  # - adds new baseline files
99
101
  # - refreshes baseline files the user has not touched
100
102
  # - preserves user-customised files (exit 3 if any)
101
103
  # - removes baseline files the upstream removed (only if untouched locally)
102
- npx @friedbotstudio/create-baseline ./your-project --merge
104
+ npx @friedbotstudio/create-baseline upgrade ./your-project
103
105
 
104
106
  # Preview without writing anything
105
107
  npx @friedbotstudio/create-baseline ./your-project --dry-run
@@ -124,6 +126,10 @@ npx @friedbotstudio/create-baseline doctor ./your-project
124
126
  # Strict mode — print TAMPERED: shipped vs observed sha256 for every
125
127
  # customised file and exit 1 on any drift.
126
128
  npx @friedbotstudio/create-baseline doctor ./your-project --strict
129
+
130
+ # JSON mode — emit the structured report on stdout for CI parsers.
131
+ # Same exit codes; honours --strict.
132
+ npx @friedbotstudio/create-baseline doctor ./your-project --json
127
133
  ```
128
134
 
129
135
  ## Quickstart
package/bin/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { parseArgs } from 'node:util';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join, resolve } from 'node:path';
5
- import { readFile } from 'node:fs/promises';
5
+ import { readFile, readdir } from 'node:fs/promises';
6
6
  import { existsSync } from 'node:fs';
7
7
 
8
8
  import * as io from '../src/cli/io.js';
@@ -17,18 +17,24 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
17
17
  const PKG_ROOT = resolve(__dirname, '..');
18
18
 
19
19
  const HELP_TEXT = `Usage:
20
- create-baseline <target> [options] install/merge the baseline
20
+ create-baseline <target> [options] install the baseline
21
+ create-baseline upgrade [target] three-way merge against an installed baseline
21
22
  create-baseline doctor [target] report drift in an installed target
22
23
 
23
24
  Materializes the Claude Code baseline (.claude/, CLAUDE.md, .mcp.json,
24
25
  docs/init/seed.md, plus vendored LICENSE/NOTICE) into <target>.
25
26
 
26
- Modes:
27
+ Install modes:
27
28
  (default) Fresh install. Refuses if any sentinel path already present.
28
29
  --force Overwrite unconditionally (requires typing 'overwrite' in TTY).
29
- --merge Three-way merge against existing .baseline-manifest.json.
30
- Prunes baseline files removed upstream that the user hadn't
31
- touched; customized stale files are preserved (exit 3).
30
+ --dry-run Print intended actions without writing.
31
+
32
+ Upgrade:
33
+ Replaces the prior --merge flag. Reads <target>/.claude/.baseline-manifest.json
34
+ and runs a three-way merge against the shipped template. Prunes baseline files
35
+ removed upstream that the user hadn't touched; customized-stale files are
36
+ preserved (exit 3) — or interactively resolved when stdout is a TTY (keep
37
+ mine / take theirs / abort).
32
38
  --dry-run Print intended actions without writing.
33
39
 
34
40
  Doctor:
@@ -38,6 +44,8 @@ Doctor:
38
44
  --strict Promote customized to exit 1 and prefix tampered paths
39
45
  with "TAMPERED:" with shipped vs observed sha256. Detects
40
46
  post-install supply-chain tampering of the baseline tree.
47
+ --json Emit the structured report as JSON to stdout instead of
48
+ the text renderer. Honours --strict; same exit codes.
41
49
 
42
50
  PlantUML jar (~19 MB, fetched at install time from upstream):
43
51
  --no-plantuml Skip the jar download entirely.
@@ -54,12 +62,24 @@ Misc:
54
62
 
55
63
  Exit codes:
56
64
  0 success / clean doctor
57
- 1 user abort, conflict-without-force/merge, or doctor reports missing files
58
- 2 argv error, non-TTY where TTY required, or doctor finds no manifest
59
- 3 --merge had skipped customizations (or stale-customized prunes)
65
+ 1 user abort, conflict-without-force, doctor reports missing files, or upgrade aborted
66
+ 2 argv error, non-TTY where TTY required, doctor finds no manifest, or --merge passed
67
+ 3 upgrade had skipped customizations (or stale-customized prunes)
60
68
  4 --require-plantuml fetch failure
61
69
  `;
62
70
 
71
+ const OPTIONS = {
72
+ help: { type: 'boolean', short: 'h' },
73
+ version: { type: 'boolean' },
74
+ force: { type: 'boolean' },
75
+ 'dry-run': { type: 'boolean' },
76
+ 'no-plantuml': { type: 'boolean' },
77
+ 'require-plantuml': { type: 'boolean' },
78
+ 'with-npmrc': { type: 'boolean' },
79
+ strict: { type: 'boolean' },
80
+ json: { type: 'boolean' },
81
+ };
82
+
63
83
  async function readPackageVersion() {
64
84
  try {
65
85
  const pkg = JSON.parse(await readFile(join(PKG_ROOT, 'package.json'), 'utf8'));
@@ -77,16 +97,141 @@ function getTemplateDir() {
77
97
  throw new Error(`Template directory not found at ${candidate}. Run \`npm run build\` (or rely on prepack).`);
78
98
  }
79
99
 
80
- function listShippedFiles(templateDir) {
81
- const files = [];
82
- const walk = (dir, base) => {
83
- for (const entry of require('node:fs').readdirSync(dir, { withFileTypes: true })) {
84
- const full = join(dir, entry.name);
85
- if (entry.isDirectory()) walk(full, base);
86
- else if (entry.isFile()) files.push(full.slice(base.length + 1).split(require('node:path').sep).join('/'));
100
+ async function listShippedFiles(templateDir) {
101
+ const out = [];
102
+ await walkFiles(templateDir, templateDir, out);
103
+ return out;
104
+ }
105
+
106
+ async function walkFiles(dir, base, acc) {
107
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
108
+ const full = join(dir, entry.name);
109
+ if (entry.isDirectory()) await walkFiles(full, base, acc);
110
+ else if (entry.isFile()) acc.push(full.slice(base.length + 1).split('/').join('/'));
111
+ }
112
+ }
113
+
114
+ async function dispatchInstall(target, values, templateDir) {
115
+ const dryRun = !!values['dry-run'];
116
+ if (process.stdout.isTTY && !values.force && !dryRun) {
117
+ return await runBrandedInstall(target, values, templateDir);
118
+ }
119
+ return await runPlainInstall(target, values, templateDir);
120
+ }
121
+
122
+ async function runBrandedInstall(target, values, templateDir) {
123
+ const tui = await import('../src/cli/tui/install.js');
124
+ return tui.run({
125
+ target,
126
+ opts: {
127
+ templateDir,
128
+ noPlantuml: !!values['no-plantuml'],
129
+ requirePlantuml: !!values['require-plantuml'],
130
+ withNpmrc: !!values['with-npmrc'],
131
+ },
132
+ });
133
+ }
134
+
135
+ async function runPlainInstall(target, values, templateDir) {
136
+ const dryRun = !!values['dry-run'];
137
+ if (values.force) {
138
+ if (!process.stdin.isTTY) {
139
+ io.error('--force requires an interactive TTY for the confirmation prompt');
140
+ return 2;
141
+ }
142
+ if (!dryRun) {
143
+ const answer = await io.ask("type 'overwrite' to proceed: ");
144
+ if (answer.toLowerCase() !== 'overwrite') {
145
+ io.error('confirmation declined');
146
+ return 1;
147
+ }
87
148
  }
88
- };
89
- return files;
149
+ }
150
+
151
+ try {
152
+ if (values.force) {
153
+ if (dryRun) io.log(`Would force-install into ${target}`);
154
+ else await forceInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
155
+ } else {
156
+ if (dryRun) io.log(`Would fresh-install into ${target}`);
157
+ else await freshInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
158
+ }
159
+ } catch (err) {
160
+ io.error(`install failed: ${err.message}`);
161
+ return 1;
162
+ }
163
+
164
+ if (!dryRun) {
165
+ const plantumlExit = await fetchPlantumlPlain(target, values);
166
+ if (plantumlExit !== 0) return plantumlExit;
167
+ io.log(`Installed manifest version 1 to ${target}.`);
168
+ io.log(`Pin via "@friedbotstudio/create-baseline@<exact-version>" in your bootstrap docs.`);
169
+ }
170
+ return 0;
171
+ }
172
+
173
+ async function fetchPlantumlPlain(target, values) {
174
+ const result = await fetchPlantumlIfMissing(target, {
175
+ noPlantuml: values['no-plantuml'],
176
+ requirePlantuml: values['require-plantuml'],
177
+ });
178
+ if (result.outcome === FETCH_OUTCOMES.WARNED_NETWORK_FAILURE
179
+ || result.outcome === FETCH_OUTCOMES.WARNED_HASH_MISMATCH) {
180
+ io.warn(`PlantUML jar fetch failed (${result.reason}); install continued. Retry with --require-plantuml or set system plantuml on PATH.`);
181
+ return 0;
182
+ }
183
+ if (result.outcome === FETCH_OUTCOMES.ERRORED_REQUIRE_PLANTUML) {
184
+ io.error(`--require-plantuml: ${result.reason}`);
185
+ return 4;
186
+ }
187
+ return 0;
188
+ }
189
+
190
+ async function dispatchUpgrade(target, values, templateDir) {
191
+ const manifestPath = join(target, '.claude/.baseline-manifest.json');
192
+ if (!existsSync(manifestPath)) {
193
+ io.error(`No baseline manifest at ${manifestPath}. Run a fresh install first.`);
194
+ return 2;
195
+ }
196
+ if (process.stdout.isTTY) {
197
+ const tui = await import('../src/cli/tui/upgrade.js');
198
+ return tui.run({
199
+ target,
200
+ opts: { templateDir, dryRun: !!values['dry-run'] },
201
+ });
202
+ }
203
+ return await runPlainUpgrade(target, values, templateDir, manifestPath);
204
+ }
205
+
206
+ async function runPlainUpgrade(target, values, templateDir, manifestPath) {
207
+ const oldManifest = await loadManifest(manifestPath);
208
+ const tplFiles = await listShippedFiles(templateDir);
209
+ const newManifest = await buildManifestFromDir(templateDir, tplFiles);
210
+ if (values['dry-run']) {
211
+ io.log(`Would upgrade ${tplFiles.length} files into ${target}`);
212
+ return 0;
213
+ }
214
+ const report = await threeWayMerge(templateDir, target, oldManifest, newManifest);
215
+ for (const action of report.actions) {
216
+ io.log(`${action.kind.padEnd(24)} ${action.path}`);
217
+ }
218
+ return report.exitCode;
219
+ }
220
+
221
+ async function dispatchDoctor(positionals, values) {
222
+ const target = resolve(positionals[1] ?? '.');
223
+ const report = await runDoctor(target, { strict: !!values.strict });
224
+ if (values.json) {
225
+ io.log(JSON.stringify(report));
226
+ return report.exitCode;
227
+ }
228
+ if (process.stdout.isTTY) {
229
+ const tui = await import('../src/cli/tui/doctor.js');
230
+ tui.render(report);
231
+ } else {
232
+ process.stdout.write(formatReport(report));
233
+ }
234
+ return report.exitCode;
90
235
  }
91
236
 
92
237
  async function main(argv) {
@@ -94,21 +239,15 @@ async function main(argv) {
94
239
  try {
95
240
  parsed = parseArgs({
96
241
  args: argv.slice(2),
97
- options: {
98
- help: { type: 'boolean', short: 'h' },
99
- version: { type: 'boolean' },
100
- force: { type: 'boolean' },
101
- merge: { type: 'boolean' },
102
- 'dry-run': { type: 'boolean' },
103
- 'no-plantuml': { type: 'boolean' },
104
- 'require-plantuml': { type: 'boolean' },
105
- 'with-npmrc': { type: 'boolean' },
106
- strict: { type: 'boolean' },
107
- },
242
+ options: OPTIONS,
108
243
  strict: true,
109
244
  allowPositionals: true,
110
245
  });
111
246
  } catch (err) {
247
+ if (/--merge/.test(err.message)) {
248
+ io.error('--merge has been removed; use `create-baseline upgrade <target>` instead.');
249
+ return 2;
250
+ }
112
251
  io.error(err.message);
113
252
  return 2;
114
253
  }
@@ -116,26 +255,42 @@ async function main(argv) {
116
255
  const { values, positionals } = parsed;
117
256
 
118
257
  if (values.help) {
119
- io.log(HELP_TEXT);
258
+ const version = await readPackageVersion();
259
+ if (process.stdout.isTTY) {
260
+ const meta = await import('../src/cli/tui/meta.js');
261
+ meta.renderHelp(HELP_TEXT, version);
262
+ } else {
263
+ io.log(HELP_TEXT);
264
+ }
120
265
  return 0;
121
266
  }
122
267
  if (values.version) {
123
- io.log(await readPackageVersion());
268
+ const version = await readPackageVersion();
269
+ if (process.stdout.isTTY) {
270
+ const meta = await import('../src/cli/tui/meta.js');
271
+ meta.renderVersion(version);
272
+ } else {
273
+ io.log(version);
274
+ }
124
275
  return 0;
125
276
  }
126
277
 
127
- // `doctor` subcommand: read-only drift check against an installed target's manifest.
128
278
  if (positionals[0] === 'doctor') {
129
- const target = resolve(positionals[1] ?? '.');
130
- const report = await runDoctor(target, { strict: !!values.strict });
131
- io.log(formatReport(report));
132
- return report.exitCode;
279
+ return await dispatchDoctor(positionals, values);
133
280
  }
134
281
 
135
- if (values.force && values.merge) {
136
- io.error('--force and --merge are mutually exclusive');
137
- return 2;
282
+ if (positionals[0] === 'upgrade') {
283
+ const target = resolve(positionals[1] ?? '.');
284
+ let templateDir;
285
+ try {
286
+ templateDir = getTemplateDir();
287
+ } catch (err) {
288
+ io.error(err.message);
289
+ return 2;
290
+ }
291
+ return await dispatchUpgrade(target, values, templateDir);
138
292
  }
293
+
139
294
  if (values['no-plantuml'] && values['require-plantuml']) {
140
295
  io.error('--no-plantuml and --require-plantuml are mutually exclusive');
141
296
  return 2;
@@ -151,8 +306,6 @@ async function main(argv) {
151
306
  }
152
307
 
153
308
  const target = resolve(positionals[0]);
154
- const dryRun = !!values['dry-run'];
155
-
156
309
  let templateDir;
157
310
  try {
158
311
  templateDir = getTemplateDir();
@@ -163,88 +316,13 @@ async function main(argv) {
163
316
 
164
317
  const sentinels = await scanSentinels(target);
165
318
  const hasConflict = sentinels.length > 0;
166
-
167
- if (hasConflict && !values.force && !values.merge) {
319
+ if (hasConflict && !values.force) {
168
320
  io.error(`existing baseline detected at ${target}: ${sentinels.join(', ')}`);
169
- io.error('pass --force to overwrite or --merge to three-way merge');
321
+ io.error('pass --force to overwrite or use `create-baseline upgrade <target>` to three-way merge');
170
322
  return 1;
171
323
  }
172
324
 
173
- if (values.force) {
174
- if (!process.stdin.isTTY) {
175
- io.error('--force requires an interactive TTY for the confirmation prompt');
176
- return 2;
177
- }
178
- if (!dryRun) {
179
- const answer = await io.ask("type 'overwrite' to proceed: ");
180
- if (answer.toLowerCase() !== 'overwrite') {
181
- io.error('confirmation declined');
182
- return 1;
183
- }
184
- }
185
- }
186
-
187
- if (values.merge) {
188
- if (!process.stdin.isTTY && !dryRun) {
189
- io.error('--merge requires an interactive TTY for the confirmation prompt');
190
- return 2;
191
- }
192
- if (!dryRun) {
193
- const answer = await io.ask("type 'merge' to proceed: ");
194
- if (answer.toLowerCase() !== 'merge') {
195
- io.error('confirmation declined');
196
- return 1;
197
- }
198
- }
199
- }
200
-
201
- let exitCode = 0;
202
- try {
203
- if (values.merge) {
204
- const oldManifest = await loadManifest(join(target, '.claude/.baseline-manifest.json'));
205
- const tplFiles = listShippedFiles(templateDir);
206
- const newManifest = await buildManifestFromDir(templateDir, tplFiles);
207
- if (dryRun) {
208
- io.log(`Would merge ${tplFiles.length} files into ${target}`);
209
- } else {
210
- const report = await threeWayMerge(templateDir, target, oldManifest, newManifest);
211
- for (const a of report.actions) {
212
- io.log(`${a.kind.padEnd(24)} ${a.path}`);
213
- }
214
- exitCode = report.exitCode;
215
- }
216
- } else if (values.force) {
217
- if (dryRun) io.log(`Would force-install into ${target}`);
218
- else await forceInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
219
- } else {
220
- if (dryRun) io.log(`Would fresh-install into ${target}`);
221
- else await freshInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
222
- }
223
- } catch (err) {
224
- io.error(`install failed: ${err.message}`);
225
- return 1;
226
- }
227
-
228
- if (!dryRun) {
229
- const fetchResult = await fetchPlantumlIfMissing(target, {
230
- noPlantuml: values['no-plantuml'],
231
- requirePlantuml: values['require-plantuml'],
232
- });
233
- if (fetchResult.outcome === FETCH_OUTCOMES.WARNED_NETWORK_FAILURE
234
- || fetchResult.outcome === FETCH_OUTCOMES.WARNED_HASH_MISMATCH) {
235
- io.warn(`PlantUML jar fetch failed (${fetchResult.reason}); install continued. Retry with --require-plantuml or set system plantuml on PATH.`);
236
- } else if (fetchResult.outcome === FETCH_OUTCOMES.ERRORED_REQUIRE_PLANTUML) {
237
- io.error(`--require-plantuml: ${fetchResult.reason}`);
238
- return 4;
239
- }
240
- }
241
-
242
- if (!dryRun && exitCode === 0) {
243
- io.log(`Installed manifest version 1 to ${target}.`);
244
- io.log(`Pin via "@friedbotstudio/create-baseline@<exact-version>" in your bootstrap docs.`);
245
- }
246
-
247
- return exitCode;
325
+ return await dispatchInstall(target, values, templateDir);
248
326
  }
249
327
 
250
328
  main(process.argv).then((code) => { process.exit(code); }).catch((err) => {
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: changelog
3
+ owner: baseline
4
+ description: Workflow Phase 11.5 — Pre-commit changelog curation. Reads the staged commit's diff + conventional-type, classifies entries into keepachangelog 1.0.0 sections, appends them under `## [Unreleased]` in CHANGELOG.md, and writes ChangelogState to `.claude/state/changelog/<slug>.json`. Runs between `/grant-commit` (gate C) and `/commit`. Authorized by the same `commit_consent` token that authorizes `/commit` — no new gate. Also supports `--preview-only` for ad-hoc projected-version lookups outside a workflow.
5
+ argument-hint: "[--preview-only]"
6
+ ---
7
+
8
+ # changelog — Phase 11.5
9
+
10
+ Curates the `## [Unreleased]` section of `CHANGELOG.md` per [keepachangelog.com 1.0.0](https://keepachangelog.com/en/1.0.0/) before `/commit` stages the diff. Pure local curation — `@semantic-release/changelog` continues to own release-time version-block insertion.
11
+
12
+ ## Prereq
13
+
14
+ ALL of `archive` AND `memory-flush` AND (implicitly) a fresh `commit_consent` token MUST be in place. Verified by the actuator at runtime, NOT by a separate guard hook.
15
+
16
+ ## Applicability
17
+
18
+ Git projects only. Non-git projects auto-except this phase at `/triage` time alongside `commit` and the swarm phases (CLAUDE.md Article IV).
19
+
20
+ ## Steps
21
+
22
+ 1. **Prereq check.** Read `.claude/state/workflow.json`. Confirm `archive` and `memory-flush` are in `completed`. If not, exit 1 with a clear error.
23
+ 2. **Invoke the actuator.** `node .claude/skills/changelog/changelog.mjs --slug <slug> --project-root <root>`. The actuator does all the work: reads the consent token, classifies commits, appends to `## [Unreleased]`, writes state.
24
+ 3. **On actuator success.** The harness marks this task `completed`, appends `"changelog"` to `workflow.json → completed`, and continues to `/commit`.
25
+ 4. **On actuator failure (exit 1).** Surface the stderr to the user. Most likely cause: `commit_consent` expired. User re-runs `/grant-commit`.
26
+
27
+ ## Ad-hoc preview mode
28
+
29
+ The skill is also invokable outside an active workflow via `--preview-only`. The actuator calls `semantic-release` as a JS API with `dryRun: true` and prints the projected next version + a draft fragment to stdout. No files are written; no consent gesture is required. Useful for answering "what version would my next push deploy?" without running the full workflow.
30
+
31
+ ## Companion files
32
+
33
+ - `changelog.mjs` — CLI actuator. The decision logic.
34
+ - `classifier.mjs` — conventional-commit type → keepachangelog section.
35
+ - `version-preview.mjs` — semantic-release JS API call for projected version.
36
+ - `state-writer.mjs` — idempotent writes to `.claude/state/changelog/<slug>.json`.
37
+ - `unreleased-writer.mjs` — `CHANGELOG.md` RMW under `## [Unreleased]`; also exports `reinsertUnreleasedHeading` for the release-time fallback (`@semantic-release/changelog` destroys the heading position; this restores it).
38
+
39
+ ## Constraints
40
+
41
+ - **Idempotent.** Re-invocation on the same slug + same HEAD SHA does NOT duplicate entries. The actuator computes a digest from `(slug, source_commit_sha, entries)` and skips writes if the digest matches the prior state file.
42
+ - **No internal mocks.** The actuator imports `semantic-release` (devDep) directly; no mock layer. The system clock IS mocked in `consent-expired` tests with `touch -d`.
43
+ - **TTL fit.** The skill is designed to complete inside the 300 s `commit_consent` window. Typical runtime: under 5 s. If the token expires mid-run, the actuator exits 1 BEFORE writing — partial writes are not allowed.
44
+ - **CHANGELOG.md migration is in scope of the workflow that introduces this skill.** Subsequent workflows assume the file already has the keepachangelog `## [Unreleased]` heading.
45
+
46
+ ## Spec traceability
47
+
48
+ The acceptance criteria from `docs/specs/changelog-skill-and-responsive-svgs.md` map to the skill's components as follows:
49
+
50
+ - **AC-001** (harness invokes changelog between gate C and commit; Unreleased section grows) — `changelog.mjs` `runActiveMode` + `unreleased-writer.mjs` `appendUnderUnreleased`.
51
+ - **AC-002** (CHANGELOG.md included in commit stage list) — `commit/SKILL.md` Step 3 named-path enumeration grows `CHANGELOG.md` via the actuator's write; verified in `golden-path_test.sh`.
52
+ - **AC-003** (non-git short-circuit) — `triage/SKILL.md` step 2 non-git auto-except list grew to include `"changelog"` alongside `"commit"`; verified in `non-git-shortcircuit_test.sh`.
53
+ - **AC-004** (audit-baseline byte-mirror invariants after Article IV amendment) — handled in `CLAUDE.md` ↔ `src/CLAUDE.template.md` mirror + `docs/init/seed.md` ↔ `src/seed.template.md` mirror; verified by `audit.sh` PASS.
54
+ - **AC-005** (site-src narrative names new phase; Article X.1 em-dash discipline) — handled in `/document` Phase 10 per design-ui row 1 misroute terminal at `.claude/state/design/changelog-skill-and-responsive-svgs-row1.json`.
55
+ - **AC-006** (SVG legible at 320 px) — handled in `site-src/assets/site.css` bento `@media (max-width: 768px)` block per design-ui row 0 audit at `docs/design/changelog-skill-and-responsive-svgs.audit.md`.
56
+ - **AC-007** (bento composition at 1920 px) — same design-ui row 0 deliverable; audit verdict 20/20 PASS.
57
+ - **AC-008** (workflow.json completed sequence ends with `[..., "changelog", "commit"]`) — `harness/SKILL.md` phase-ordering fence + `commit/SKILL.md` prereq line.
58
+ - **AC-009** (source_backlog_keys stamp-closure on commit) — `commit/SKILL.md` Step 6 invokes `sweep.py --mode stamp-closure`; no change to that flow needed in this workflow.
59
+ - **AC-010** (consent-expired denial) — `changelog.mjs` `checkConsent` reads epoch from line 1 of `commit_consent`; exits 1 on stale; verified in `consent-expired_test.sh`.
60
+ - **AC-011** (TaskList re-seed across session boundary) — `triage/SKILL.md` four task-seeding templates updated to insert `Run /changelog` between `Wait for /grant-commit` and `Run /commit`; `harness/SKILL.md` state-machine table grew a row for the new gap.
61
+ - **AC-012** (ad-hoc `--preview-only` mode) — `changelog.mjs` `runPreviewMode` calls semantic-release JS API with `dryRun:true`; no consent required; verified in `preview-only_test.sh`.
62
+ - **AC-013** (`@semantic-release/changelog` preserves Unreleased OR fallback re-inserts) — `unreleased-writer.mjs` `reinsertUnreleasedHeading` export; verified in `keepachangelog-unreleased-preserved_test.mjs` (test 1 documents the plugin behavior empirically; test 2 confirms the fallback restores canonical structure).
63
+
64
+ Design call rows from the spec:
65
+
66
+ - **`architecture-svg-bento-grid-responsive`** (design lane) — completed at design-ui row 0; site-src/index.njk + site-src/assets/site.css written; audit 20/20 PASS.
67
+ - **`site-narrative-new-phase-mention`** (copy lane) — Stage 0 misroute to `/document` Phase 10; state checkpoint at row1.json; `/document` reads the misroute terminal and routes the three target files through `Skill(prose)` with mandatory humanizer pass.
68
+
69
+ Phase 11.5 introduces gate-adjacent automation between gate C `/grant-commit` (Article IV phase 11 gate) and the `/commit` skill body. No new gate (no gate D); the existing `commit_consent` token authorizes both.
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ // Phase 11.5 Changelog actuator.
3
+ //
4
+ // CLI:
5
+ // node changelog.mjs --slug <slug> [--project-root <path>]
6
+ // node changelog.mjs --preview-only --slug <slug> [--project-root <path>]
7
+ //
8
+ // Active mode: verifies commit_consent freshness, classifies new commits,
9
+ // appends keepachangelog entries under ## [Unreleased] in CHANGELOG.md,
10
+ // writes ChangelogState to .claude/state/changelog/<slug>.json.
11
+ //
12
+ // Preview mode: prints projected next version + draft fragment; no writes.
13
+
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { join, resolve } from 'node:path';
16
+ import { execFileSync } from 'node:child_process';
17
+ import { parseArgs } from 'node:util';
18
+ import { classify } from './classifier.mjs';
19
+ import { previewProjectedVersion } from './version-preview.mjs';
20
+ import { writeState } from './state-writer.mjs';
21
+ import { appendUnderUnreleased } from './unreleased-writer.mjs';
22
+
23
+ const TTL_SECONDS = 300;
24
+
25
+ function parseCli() {
26
+ const { values } = parseArgs({
27
+ options: {
28
+ slug: { type: 'string' },
29
+ 'preview-only': { type: 'boolean', default: false },
30
+ 'project-root': { type: 'string', default: '.' },
31
+ },
32
+ strict: true,
33
+ });
34
+ if (!values.slug) {
35
+ process.stderr.write('error: --slug required\n');
36
+ process.exit(2);
37
+ }
38
+ return {
39
+ slug: values.slug,
40
+ previewOnly: values['preview-only'],
41
+ projectRoot: resolve(values['project-root']),
42
+ };
43
+ }
44
+
45
+ function checkConsent(projectRoot) {
46
+ const path = join(projectRoot, '.claude/state/commit_consent');
47
+ if (!existsSync(path)) {
48
+ return { ok: false, reason: 'consent absent (no commit_consent token)' };
49
+ }
50
+ // Token contract: line 1 is the unix epoch when /grant-commit was issued.
51
+ // Reading the epoch (not filesystem mtime) keeps the freshness check
52
+ // consistent with how `/grant-commit` writes the file and with how tests
53
+ // stale the consent via `echo "<old-epoch>" > commit_consent`.
54
+ const firstLine = readFileSync(path, 'utf8').split('\n', 1)[0].trim();
55
+ const tokenEpoch = parseInt(firstLine, 10);
56
+ if (!Number.isFinite(tokenEpoch)) {
57
+ return { ok: false, reason: 'consent malformed (line 1 not an epoch)' };
58
+ }
59
+ const ageSeconds = Math.floor(Date.now() / 1000) - tokenEpoch;
60
+ if (ageSeconds > TTL_SECONDS) {
61
+ return {
62
+ ok: false,
63
+ reason: `consent expired (${ageSeconds}s > ${TTL_SECONDS}s)`,
64
+ };
65
+ }
66
+ return { ok: true };
67
+ }
68
+
69
+ function getHeadSha(projectRoot) {
70
+ try {
71
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
72
+ cwd: projectRoot, encoding: 'utf8',
73
+ }).trim();
74
+ } catch {
75
+ return 'unknown';
76
+ }
77
+ }
78
+
79
+ function buildEntry(commit) {
80
+ const cls = classify(commit);
81
+ if (!cls) return null;
82
+ return {
83
+ section: cls.section,
84
+ body: commit.subject,
85
+ conventional_type: commit.type,
86
+ conventional_scope: commit.scope,
87
+ breaking: cls.breaking,
88
+ };
89
+ }
90
+
91
+ function renderPreviewFragment(projection) {
92
+ const lines = [];
93
+ lines.push(`Projected: ${projection.version} (${projection.type || 'no release'})`);
94
+ lines.push(`Commits analyzed: ${projection.commits.length}`);
95
+ if (projection.commits.length > 0) {
96
+ lines.push('');
97
+ lines.push('Draft fragment under ## [Unreleased]:');
98
+ const entries = projection.commits.map(buildEntry).filter(Boolean);
99
+ if (entries.length === 0) {
100
+ lines.push('(no commits map to keepachangelog sections)');
101
+ } else {
102
+ const grouped = new Map();
103
+ for (const e of entries) {
104
+ if (!grouped.has(e.section)) grouped.set(e.section, []);
105
+ grouped.get(e.section).push(e);
106
+ }
107
+ for (const [section, items] of grouped) {
108
+ lines.push('');
109
+ lines.push(`### ${section}`);
110
+ for (const item of items) {
111
+ const prefix = item.breaking ? '**BREAKING:** ' : '';
112
+ lines.push(`- ${prefix}${item.body}`);
113
+ }
114
+ }
115
+ }
116
+ }
117
+ return lines.join('\n') + '\n';
118
+ }
119
+
120
+ async function runPreviewMode({ projectRoot }) {
121
+ const projection = await previewProjectedVersion(projectRoot);
122
+ process.stdout.write(renderPreviewFragment(projection));
123
+ process.exit(0);
124
+ }
125
+
126
+ async function runActiveMode({ slug, projectRoot }) {
127
+ const consent = checkConsent(projectRoot);
128
+ if (!consent.ok) {
129
+ process.stderr.write(`error: ${consent.reason}\n`);
130
+ process.exit(1);
131
+ }
132
+ const projection = await previewProjectedVersion(projectRoot);
133
+ const entries = projection.commits.map(buildEntry).filter(Boolean);
134
+ const changelogPath = join(projectRoot, 'CHANGELOG.md');
135
+ await appendUnderUnreleased(changelogPath, entries);
136
+ const state = {
137
+ slug,
138
+ source_commit_sha: getHeadSha(projectRoot),
139
+ projected_version: projection.version,
140
+ projected_type: projection.type,
141
+ entries,
142
+ generated_at: new Date().toISOString(),
143
+ unreleased_inserted_at: new Date().toISOString(),
144
+ };
145
+ await writeState(projectRoot, slug, state);
146
+ process.stdout.write(
147
+ `changelog: wrote ${entries.length} entries to ${changelogPath} (projected ${projection.version})\n`,
148
+ );
149
+ }
150
+
151
+ async function main() {
152
+ const cli = parseCli();
153
+ if (cli.previewOnly) {
154
+ await runPreviewMode(cli);
155
+ } else {
156
+ await runActiveMode(cli);
157
+ }
158
+ }
159
+
160
+ main().catch((err) => {
161
+ process.stderr.write(`error: ${err.message}\n`);
162
+ process.exit(1);
163
+ });