@friedbotstudio/create-baseline 0.3.0 → 0.5.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 (33) hide show
  1. package/README.md +10 -4
  2. package/bin/cli.js +252 -127
  3. package/obj/template/{manifest.json → .claude/manifest.json} +22 -8
  4. package/obj/template/.claude/skills/audit-baseline/audit.sh +16 -9
  5. package/obj/template/.claude/skills/changelog/SKILL.md +69 -0
  6. package/obj/template/.claude/skills/changelog/changelog.mjs +163 -0
  7. package/obj/template/.claude/skills/changelog/classifier.mjs +49 -0
  8. package/obj/template/.claude/skills/changelog/state-writer.mjs +19 -0
  9. package/obj/template/.claude/skills/changelog/tests/consent-expired_test.sh +126 -0
  10. package/obj/template/.claude/skills/changelog/tests/golden-path_test.sh +191 -0
  11. package/obj/template/.claude/skills/changelog/tests/idempotent-reentry_test.sh +121 -0
  12. package/obj/template/.claude/skills/changelog/tests/keepachangelog-unreleased-preserved_test.mjs +149 -0
  13. package/obj/template/.claude/skills/changelog/tests/non-git-shortcircuit_test.sh +98 -0
  14. package/obj/template/.claude/skills/changelog/tests/preview-only_test.sh +96 -0
  15. package/obj/template/.claude/skills/changelog/tests/run.sh +28 -0
  16. package/obj/template/.claude/skills/changelog/unreleased-writer.mjs +155 -0
  17. package/obj/template/.claude/skills/changelog/version-preview.mjs +124 -0
  18. package/obj/template/.claude/skills/commit/SKILL.md +1 -1
  19. package/obj/template/.claude/skills/harness/SKILL.md +3 -1
  20. package/obj/template/.claude/skills/triage/SKILL.md +6 -5
  21. package/obj/template/CLAUDE.md +5 -5
  22. package/obj/template/docs/init/seed.md +6 -6
  23. package/package.json +5 -2
  24. package/src/CLAUDE.template.md +5 -5
  25. package/src/cli/install.js +7 -9
  26. package/src/cli/merge.js +15 -10
  27. package/src/cli/tui/doctor.js +56 -0
  28. package/src/cli/tui/install.js +81 -0
  29. package/src/cli/tui/meta.js +63 -0
  30. package/src/cli/tui/splash.js +111 -0
  31. package/src/cli/tui/tokens.js +45 -0
  32. package/src/cli/tui/upgrade.js +119 -0
  33. package/src/seed.template.md +6 -6
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,12 +2,12 @@
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';
9
9
  import { scanSentinels } from '../src/cli/conflict.js';
10
- import { freshInstall, forceInstall } from '../src/cli/install.js';
10
+ import { freshInstall, forceInstall, COPY_EXCLUDE } from '../src/cli/install.js';
11
11
  import { threeWayMerge } from '../src/cli/merge.js';
12
12
  import { loadManifest, buildManifestFromDir } from '../src/cli/manifest.js';
13
13
  import { fetchPlantumlIfMissing, FETCH_OUTCOMES } from '../src/cli/plantuml.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,175 @@ 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
+ // COPY_EXCLUDE was used to keep the legacy `manifest.json` at the template
104
+ // root from being copied to the consumer target root. The manifest now
105
+ // ships at `.claude/manifest.json` so no path-level exclusion is needed;
106
+ // COPY_EXCLUDE is currently empty but the filter stays so future entries
107
+ // (e.g., dev-only artifacts that accidentally leak into obj/template) can
108
+ // be added in one place. See src/cli/install.js → COPY_EXCLUDE.
109
+ return out.filter((p) => !COPY_EXCLUDE.includes(p));
110
+ }
111
+
112
+ async function walkFiles(dir, base, acc) {
113
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
114
+ const full = join(dir, entry.name);
115
+ if (entry.isDirectory()) await walkFiles(full, base, acc);
116
+ else if (entry.isFile()) acc.push(full.slice(base.length + 1).split('/').join('/'));
117
+ }
118
+ }
119
+
120
+ // Renders a usage error and the canonical HELP_TEXT through the branded TUI
121
+ // when stderr is a TTY, falling back to plain `Error: <msg>` + help body
122
+ // otherwise. Every non-success exit from `main()` flows through here so the
123
+ // user always sees usage guidance alongside the failure.
124
+ async function usageError(msg) {
125
+ const version = await readPackageVersion();
126
+ const meta = await import('../src/cli/tui/meta.js');
127
+ meta.renderUsageError(msg, HELP_TEXT, version);
128
+ }
129
+
130
+ // Translates Node's parseArgs error noise into a short, branded message.
131
+ // Node emits e.g. `Unknown option '--upgrade'. To specify a positional ...`
132
+ // which leaks library implementation detail; we collapse it and, where we
133
+ // can identify the user's likely intent (typing `--upgrade` for the
134
+ // `upgrade` subcommand), we hint at the correct shape.
135
+ function friendlyParseArgsMessage(rawMessage) {
136
+ const firstLine = (rawMessage || '').split('\n')[0];
137
+ if (/Unknown option ['"]?--upgrade['"]?/.test(firstLine)) {
138
+ return 'Did you mean `create-baseline upgrade <target>`? `upgrade` is a subcommand, not a flag.';
139
+ }
140
+ if (/Unknown option ['"]?--doctor['"]?/.test(firstLine)) {
141
+ return 'Did you mean `create-baseline doctor <target>`? `doctor` is a subcommand, not a flag.';
142
+ }
143
+ const unknown = /Unknown option ['"]?([^'"]+?)['"]?(?:\.|$)/.exec(firstLine);
144
+ if (unknown) return `Unknown option '${unknown[1]}'.`;
145
+ return firstLine;
146
+ }
147
+
148
+ async function dispatchInstall(target, values, templateDir) {
149
+ const dryRun = !!values['dry-run'];
150
+ if (process.stdout.isTTY && !values.force && !dryRun) {
151
+ return await runBrandedInstall(target, values, templateDir);
152
+ }
153
+ return await runPlainInstall(target, values, templateDir);
154
+ }
155
+
156
+ async function runBrandedInstall(target, values, templateDir) {
157
+ const tui = await import('../src/cli/tui/install.js');
158
+ return tui.run({
159
+ target,
160
+ opts: {
161
+ templateDir,
162
+ noPlantuml: !!values['no-plantuml'],
163
+ requirePlantuml: !!values['require-plantuml'],
164
+ withNpmrc: !!values['with-npmrc'],
165
+ },
166
+ });
167
+ }
168
+
169
+ async function runPlainInstall(target, values, templateDir) {
170
+ const dryRun = !!values['dry-run'];
171
+ if (values.force) {
172
+ if (!process.stdin.isTTY) {
173
+ await usageError('--force requires an interactive TTY for the confirmation prompt');
174
+ return 2;
87
175
  }
88
- };
89
- return files;
176
+ if (!dryRun) {
177
+ const answer = await io.ask("type 'overwrite' to proceed: ");
178
+ if (answer.toLowerCase() !== 'overwrite') {
179
+ await usageError('confirmation declined');
180
+ return 1;
181
+ }
182
+ }
183
+ }
184
+
185
+ try {
186
+ if (values.force) {
187
+ if (dryRun) io.log(`Would force-install into ${target}`);
188
+ else await forceInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
189
+ } else {
190
+ if (dryRun) io.log(`Would fresh-install into ${target}`);
191
+ else await freshInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
192
+ }
193
+ } catch (err) {
194
+ await usageError(`install failed: ${err.message}`);
195
+ return 1;
196
+ }
197
+
198
+ if (!dryRun) {
199
+ const plantumlExit = await fetchPlantumlPlain(target, values);
200
+ if (plantumlExit !== 0) return plantumlExit;
201
+ io.log(`Installed manifest version 1 to ${target}.`);
202
+ io.log(`Pin via "@friedbotstudio/create-baseline@<exact-version>" in your bootstrap docs.`);
203
+ }
204
+ return 0;
205
+ }
206
+
207
+ async function fetchPlantumlPlain(target, values) {
208
+ const result = await fetchPlantumlIfMissing(target, {
209
+ noPlantuml: values['no-plantuml'],
210
+ requirePlantuml: values['require-plantuml'],
211
+ });
212
+ if (result.outcome === FETCH_OUTCOMES.WARNED_NETWORK_FAILURE
213
+ || result.outcome === FETCH_OUTCOMES.WARNED_HASH_MISMATCH) {
214
+ io.warn(`PlantUML jar fetch failed (${result.reason}); install continued. Retry with --require-plantuml or set system plantuml on PATH.`);
215
+ return 0;
216
+ }
217
+ if (result.outcome === FETCH_OUTCOMES.ERRORED_REQUIRE_PLANTUML) {
218
+ await usageError(`--require-plantuml: ${result.reason}`);
219
+ return 4;
220
+ }
221
+ return 0;
222
+ }
223
+
224
+ async function dispatchUpgrade(target, values, templateDir) {
225
+ const manifestPath = join(target, '.claude/.baseline-manifest.json');
226
+ if (!existsSync(manifestPath)) {
227
+ await usageError(`No baseline manifest at ${manifestPath}. Run a fresh install first.`);
228
+ return 2;
229
+ }
230
+ if (process.stdout.isTTY) {
231
+ const tui = await import('../src/cli/tui/upgrade.js');
232
+ return tui.run({
233
+ target,
234
+ opts: { templateDir, dryRun: !!values['dry-run'] },
235
+ });
236
+ }
237
+ return await runPlainUpgrade(target, values, templateDir, manifestPath);
238
+ }
239
+
240
+ async function runPlainUpgrade(target, values, templateDir, manifestPath) {
241
+ const oldManifest = await loadManifest(manifestPath);
242
+ const tplFiles = await listShippedFiles(templateDir);
243
+ const newManifest = await buildManifestFromDir(templateDir, tplFiles);
244
+ if (values['dry-run']) {
245
+ io.log(`Would upgrade ${tplFiles.length} files into ${target}`);
246
+ return 0;
247
+ }
248
+ const report = await threeWayMerge(templateDir, target, oldManifest, newManifest);
249
+ for (const action of report.actions) {
250
+ io.log(`${action.kind.padEnd(24)} ${action.path}`);
251
+ }
252
+ return report.exitCode;
253
+ }
254
+
255
+ async function dispatchDoctor(positionals, values) {
256
+ const target = resolve(positionals[1] ?? '.');
257
+ const report = await runDoctor(target, { strict: !!values.strict });
258
+ if (values.json) {
259
+ io.log(JSON.stringify(report));
260
+ return report.exitCode;
261
+ }
262
+ if (process.stdout.isTTY) {
263
+ const tui = await import('../src/cli/tui/doctor.js');
264
+ tui.render(report);
265
+ } else {
266
+ process.stdout.write(formatReport(report));
267
+ }
268
+ return report.exitCode;
90
269
  }
91
270
 
92
271
  async function main(argv) {
@@ -94,157 +273,103 @@ async function main(argv) {
94
273
  try {
95
274
  parsed = parseArgs({
96
275
  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
- },
276
+ options: OPTIONS,
108
277
  strict: true,
109
278
  allowPositionals: true,
110
279
  });
111
280
  } catch (err) {
112
- io.error(err.message);
281
+ if (/--merge/.test(err.message)) {
282
+ await usageError('--merge has been removed; use `create-baseline upgrade <target>` instead.');
283
+ return 2;
284
+ }
285
+ await usageError(friendlyParseArgsMessage(err.message));
113
286
  return 2;
114
287
  }
115
288
 
116
289
  const { values, positionals } = parsed;
117
290
 
118
291
  if (values.help) {
119
- io.log(HELP_TEXT);
292
+ const version = await readPackageVersion();
293
+ if (process.stdout.isTTY) {
294
+ const meta = await import('../src/cli/tui/meta.js');
295
+ meta.renderHelp(HELP_TEXT, version);
296
+ } else {
297
+ io.log(HELP_TEXT);
298
+ }
120
299
  return 0;
121
300
  }
122
301
  if (values.version) {
123
- io.log(await readPackageVersion());
302
+ const version = await readPackageVersion();
303
+ if (process.stdout.isTTY) {
304
+ const meta = await import('../src/cli/tui/meta.js');
305
+ meta.renderVersion(version);
306
+ } else {
307
+ io.log(version);
308
+ }
124
309
  return 0;
125
310
  }
126
311
 
127
- // `doctor` subcommand: read-only drift check against an installed target's manifest.
128
312
  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;
313
+ return await dispatchDoctor(positionals, values);
133
314
  }
134
315
 
135
- if (values.force && values.merge) {
136
- io.error('--force and --merge are mutually exclusive');
137
- return 2;
316
+ if (positionals[0] === 'upgrade') {
317
+ const target = resolve(positionals[1] ?? '.');
318
+ let templateDir;
319
+ try {
320
+ templateDir = getTemplateDir();
321
+ } catch (err) {
322
+ await usageError(err.message);
323
+ return 2;
324
+ }
325
+ return await dispatchUpgrade(target, values, templateDir);
138
326
  }
327
+
139
328
  if (values['no-plantuml'] && values['require-plantuml']) {
140
- io.error('--no-plantuml and --require-plantuml are mutually exclusive');
329
+ await usageError('--no-plantuml and --require-plantuml are mutually exclusive');
141
330
  return 2;
142
331
  }
143
332
  if (positionals.length === 0) {
144
- io.error('missing required <target> argument');
145
- io.error(HELP_TEXT);
333
+ // TTY landing: render the branded splash (skills.sh-style marquee) so the
334
+ // empty invocation reads as "here's what this tool does" instead of an
335
+ // angry error. Non-TTY keeps the strict error+help+exit-2 contract so
336
+ // scripts and CI still detect the missing argument.
337
+ if (process.stdout.isTTY) {
338
+ const splash = await import('../src/cli/tui/splash.js');
339
+ process.stdout.write(splash.renderSplash({
340
+ tryLine: 'npx @friedbotstudio/create-baseline ./my-project',
341
+ discoverUrl: 'https://baseline.friedbotstudio.com/',
342
+ }));
343
+ return 0;
344
+ }
345
+ await usageError('missing required <target> argument');
146
346
  return 2;
147
347
  }
148
348
  if (positionals.length > 1) {
149
- io.error(`unexpected positional arguments: ${positionals.slice(1).join(', ')}`);
349
+ await usageError(`unexpected positional arguments: ${positionals.slice(1).join(', ')}`);
150
350
  return 2;
151
351
  }
152
352
 
153
353
  const target = resolve(positionals[0]);
154
- const dryRun = !!values['dry-run'];
155
-
156
354
  let templateDir;
157
355
  try {
158
356
  templateDir = getTemplateDir();
159
357
  } catch (err) {
160
- io.error(err.message);
358
+ await usageError(err.message);
161
359
  return 2;
162
360
  }
163
361
 
164
362
  const sentinels = await scanSentinels(target);
165
363
  const hasConflict = sentinels.length > 0;
166
-
167
- if (hasConflict && !values.force && !values.merge) {
168
- io.error(`existing baseline detected at ${target}: ${sentinels.join(', ')}`);
169
- io.error('pass --force to overwrite or --merge to three-way merge');
364
+ if (hasConflict && !values.force) {
365
+ await usageError(
366
+ `existing baseline detected at ${target}: ${sentinels.join(', ')}. ` +
367
+ 'Pass --force to overwrite or use `create-baseline upgrade <target>` to three-way merge.'
368
+ );
170
369
  return 1;
171
370
  }
172
371
 
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;
372
+ return await dispatchInstall(target, values, templateDir);
248
373
  }
249
374
 
250
375
  main(process.argv).then((code) => { process.exit(code); }).catch((err) => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "manifest_version": 2,
3
- "generated_at": "2026-05-17T19:24:12.190Z",
3
+ "generated_at": "2026-05-20T09:13:44.485Z",
4
4
  "files": {
5
5
  ".claude/agents/swarm-worker.md": "1735a220f268c9765cb22e0567b728803f2edd7776cbde51dd017a9f062ae41f",
6
6
  ".claude/bin/LICENSE": "a8dcf2775ab71a58c7d4cc935e3a8e9974e87bb7d6082ee25ef52f8140be8e07",
@@ -56,7 +56,7 @@
56
56
  ".claude/skills/archive/SKILL.md": "5174e8b72ab1e830912c231052d1e535023d58f0808434fe1d4abce305b80318",
57
57
  ".claude/skills/archive/archive.sh": "29f5b3c8870d0665ce624c6d1b5770f691f1d19f82a201767d5685fc3b0a0b58",
58
58
  ".claude/skills/audit-baseline/SKILL.md": "86e9955a99ade89074571320b2b9b0780bc033d201968f62edcca35d9e25f7cb",
59
- ".claude/skills/audit-baseline/audit.sh": "8b8a7db4db2aba0e0dd9948e4472feabe568e06a5f4e752c10dcd3da655bedf3",
59
+ ".claude/skills/audit-baseline/audit.sh": "bd95803bc27cc398564e6df1b89521ead94f3cff458b50497e78fefcced2958e",
60
60
  ".claude/skills/audit-baseline/tests/fixtures/_pending_opener_only.md": "9c64d7ef0b3be48b3e87acd89f9ac638db956f2c7cc5adf569d9d97d16f18163",
61
61
  ".claude/skills/audit-baseline/tests/fixtures/preamble_full_empty_body.md": "b757223c9b4954882b7f4ac828de7d83a105f74ba0cdf0951fbbe19c07ae892d",
62
62
  ".claude/skills/audit-baseline/tests/fixtures/preamble_full_with_entries.md": "9fcd049b006a7474d182a04420df6b3af08e3b6777a2ec5b4031d661ba2f4c82",
@@ -65,6 +65,19 @@
65
65
  ".claude/skills/audit-baseline/tests/preamble_check_test.sh": "73239378e51528c37f7843ff540ddfed2cd0ee1b8259589bc7cdaefd71424d95",
66
66
  ".claude/skills/brd/SKILL.md": "260e5d18396de21b6842ad0bc5fe74b0e124f3c2e2a4ac2867eeb543c7c2c0a3",
67
67
  ".claude/skills/brd/template.md": "be4095b4bed70fcebc821ad92b0217db4fee1afb9a341a9e704d2c1869ec6f43",
68
+ ".claude/skills/changelog/SKILL.md": "2a305b04be3fb44dd9766fae0344725600c2269276d5fe9496d068cebcc4969c",
69
+ ".claude/skills/changelog/changelog.mjs": "662ae8142ea55c70375e45d874f97b40bcb85b0d6e5378393be56aba1b254eee",
70
+ ".claude/skills/changelog/classifier.mjs": "e17cd4dacb48b5ebf3addd2f651e8e2465c10aa51958c11c9272faff32e379f2",
71
+ ".claude/skills/changelog/state-writer.mjs": "a773f14eb1d7d990f64eb54f70720451db279954ea5cbd025ca15f1a8d39449d",
72
+ ".claude/skills/changelog/tests/consent-expired_test.sh": "60351d32cb544af33a6929f898411e43d20ec258f620ebc08179754a74811cf1",
73
+ ".claude/skills/changelog/tests/golden-path_test.sh": "1ef7cdaae2dbeea25f7165b182e4b2cc80e69f3cc918089552145421fcc35d26",
74
+ ".claude/skills/changelog/tests/idempotent-reentry_test.sh": "af11b08744ab3743989f9ae8017b6f01a32373971f072c233ce28741783f5de0",
75
+ ".claude/skills/changelog/tests/keepachangelog-unreleased-preserved_test.mjs": "981e1831893449667b1460b13b9ef9275cc3bd23b22cd6a3cfa3d56d239ed0ad",
76
+ ".claude/skills/changelog/tests/non-git-shortcircuit_test.sh": "ec5760c8790a3cd5cb21e93bd1742a4f30115f0e4e4829a8f765fecb4ddba5f4",
77
+ ".claude/skills/changelog/tests/preview-only_test.sh": "b30a6c4b94a1cb87596af317f460c57c62ecde257dcd81447932564ce9e542d2",
78
+ ".claude/skills/changelog/tests/run.sh": "55ba1d59d67f3e43b07fae08a7059c4e04cfe963a09a2367528d509e748f3116",
79
+ ".claude/skills/changelog/unreleased-writer.mjs": "a03d9080317e2fc9c4895b7b8c68efc2ab90497a9265091f5981859cda3d6885",
80
+ ".claude/skills/changelog/version-preview.mjs": "3843093b08b5407012e53d601fa6d3d4d3ddc04ae4911d0e5ad015622585c67f",
68
81
  ".claude/skills/chore/SKILL.md": "3479c4ef4c7706928b54ed89678c3526c790e2aeb629674bd60e5a9e4b90d80d",
69
82
  ".claude/skills/claude-automation-recommender/LICENSE": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
70
83
  ".claude/skills/claude-automation-recommender/NOTICE": "2076093d5af61a10b585562e3b40fd76ce178c56e5eebde93efdc08b3c6bb81a",
@@ -75,7 +88,7 @@
75
88
  ".claude/skills/claude-automation-recommender/references/skills-reference.md": "b4b57d953526a07146d5757ca5332034b9b4b4eeba839e4bb08111398c94556f",
76
89
  ".claude/skills/claude-automation-recommender/references/subagent-templates.md": "7f9a107d8619ff96fea07d5a17da31b269cec0ee343e5a9ff643c0d6cb4ba944",
77
90
  ".claude/skills/code-structure/SKILL.md": "4fa79beb83f9171e973cd2373af12f007aeaf95d156edf3a8e7cf3e9c2c22ace",
78
- ".claude/skills/commit/SKILL.md": "27efe44a50096be2a00e6ee2e7e4d7b158b3f0e8ed3bf18d65d5f03c02d0f8d1",
91
+ ".claude/skills/commit/SKILL.md": "7f22653ae6832a5fc5631774f78d3feabe47c5b21c5925ae8a77e656bbac1b9c",
79
92
  ".claude/skills/copywriting/LICENSE": "3d015bf779e8f6f4b9366e0862c0d560aa60979bbe8d90426cede05528dc5390",
80
93
  ".claude/skills/copywriting/NOTICE": "ffd65b16660e4e9cbf42c5ff9bdf67b482815ebedfb515bedb65c6eb54930a15",
81
94
  ".claude/skills/copywriting/SKILL.md": "4631aae3a65204a1dac89fbe7606481b867b30ae3f64482d337c1e6bd5b4c902",
@@ -108,7 +121,7 @@
108
121
  ".claude/skills/google-analytics/references/reporting.md": "37cb7cfd1dd547e86a41664ffe13893ef138194a2288d1a2c43c9195e8ec609f",
109
122
  ".claude/skills/google-analytics/references/setup.md": "26743b72341a5658e73c1f070b924de0f11b1681af06164cb13c19f2047a1087",
110
123
  ".claude/skills/google-analytics/references/user-tracking.md": "5a945df2a575291c6b35d24830b497915c82a843a93ee133577f1deda93f6d75",
111
- ".claude/skills/harness/SKILL.md": "84c4498e51b9e1c098ddaa99e7b150ab015b8e5c341a2e57300dbafe812acf4d",
124
+ ".claude/skills/harness/SKILL.md": "af7cf374013d881d216462ccba353747e1727f670bd9b1c45b089ffde247d67a",
112
125
  ".claude/skills/humanizer/LICENSE": "5dd2bb7cb6b254edd93a87718efbfef8b81c8ef90e3f9180f06723683d7733a3",
113
126
  ".claude/skills/humanizer/NOTICE": "5bdb69381ab078e7fbb5fb910f10253baa6fe9dabb88143c6f613959e131cbb3",
114
127
  ".claude/skills/humanizer/SKILL.md": "398beef0cabe34df435972a13fb9ef21be74d942f93321b220a5dd393d554e40",
@@ -211,17 +224,18 @@
211
224
  ".claude/skills/technical-tutorials/references/audience-context.md": "7e00189e72d7a87a0cf59f8302aa4adad26222d3bbd14ddb498515f137a32775",
212
225
  ".claude/skills/technical-tutorials/references/audience-example.md": "610f559f691de5169f672e8f69b72fd44790e5e3f894cebb7e644c3331db419c",
213
226
  ".claude/skills/technical-tutorials/references/audience-template.md": "9e8da0e05544df3961d75fb3a7b1d6374b6a1c129ac2fc9862dc111b5a75e433",
214
- ".claude/skills/triage/SKILL.md": "9416c9de58e3dbf8c3bcac4d6e258c9a8e9c38083bd75a35937eaae16b82b0db",
227
+ ".claude/skills/triage/SKILL.md": "499f82a26d77c60af827dd89a7fcb3eae0cd903e34c70740c297bc260aeed38e",
215
228
  ".claude/skills/verify/SKILL.md": "fcddba67cf7f3623fc8f8ae142303b16b9db0c9fc1652bc920e16c56fe4c7864",
216
229
  ".mcp.json": "8ebb7966045486187bbdf9bac643e690c4fbc7a9a70a8345e3665ba72fa19b96",
217
- "CLAUDE.md": "27d7cd145d1ba2a1b9dcc006e0ff3faa1af6c1a92f7d935c4a7eafa87accefc4",
218
- "docs/init/seed.md": "437a05e448f639f5214edda0dc155d9e2f7b5e88b2866333e6639e75ff8822ab"
230
+ "CLAUDE.md": "1d87311fa1b177944afe69f060b0ddd31359e5391445034d10dc51ced1503ada",
231
+ "docs/init/seed.md": "63f3a0c09895f2040f7fdc188cb0e759bd6a47d615759ec7c83671798504a71d"
219
232
  },
220
233
  "owners": {
221
234
  "skills": {
222
235
  "archive": "baseline",
223
236
  "audit-baseline": "baseline",
224
237
  "brd": "baseline",
238
+ "changelog": "baseline",
225
239
  "chore": "baseline",
226
240
  "claude-automation-recommender": "baseline",
227
241
  "code-structure": "baseline",
@@ -257,5 +271,5 @@
257
271
  "verify": "baseline"
258
272
  }
259
273
  },
260
- "build_id": "gha-26000362681"
274
+ "build_id": "gha-26153025904"
261
275
  }
@@ -49,18 +49,25 @@ EXPECTED_AGENTS = {
49
49
  # from main context; they never make decisions.
50
50
  "swarm-worker",
51
51
  }
52
- # Skill provenance comes from the shipped manifest at obj/template/manifest.json.
52
+ # Skill provenance comes from the shipped manifest. Two possible locations,
53
+ # tried in order:
54
+ # 1. <root>/.claude/manifest.json — present in consumer projects after the
55
+ # CLI installs the baseline (the recursive cp puts it there directly).
56
+ # 2. <root>/obj/template/.claude/manifest.json — present in the baseline
57
+ # dev repo, where `npm run build` writes the manifest before publishing.
53
58
  # The build (scripts/build-manifest.mjs) reads owner: frontmatter from every
54
59
  # .claude/skills/<slug>/SKILL.md and emits the canonical baseline-skill set as
55
60
  # manifest.owners.skills. See CLAUDE.md Article XI and seed.md §17.
56
61
  def load_manifest():
57
- path = root / "obj/template/manifest.json"
58
- if not path.exists():
59
- return None
60
- try:
61
- return json.loads(path.read_text(encoding="utf-8"))
62
- except Exception:
63
- return None
62
+ for rel in (".claude/manifest.json", "obj/template/.claude/manifest.json"):
63
+ path = root / rel
64
+ if not path.exists():
65
+ continue
66
+ try:
67
+ return json.loads(path.read_text(encoding="utf-8"))
68
+ except Exception:
69
+ return None
70
+ return None
64
71
 
65
72
  def read_skill_owner(slug):
66
73
  p = root / f".claude/skills/{slug}/SKILL.md"
@@ -245,7 +252,7 @@ def check_skill_ownership():
245
252
  # Manifest-driven baseline-skill presence + per-file hash check.
246
253
  manifest = load_manifest()
247
254
  if manifest is None:
248
- add("skill ownership: manifest", "WARN", "obj/template/manifest.json missing — run npm run build")
255
+ add("skill ownership: manifest", "WARN", ".claude/manifest.json (or obj/template/.claude/manifest.json) missing — run npm run build")
249
256
  return
250
257
  owners_skills = (manifest.get("owners") or {}).get("skills", {}) or {}
251
258
  files_map = manifest.get("files") or {}