@friedbotstudio/create-baseline 0.4.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.
package/bin/cli.js CHANGED
@@ -7,7 +7,7 @@ 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';
@@ -100,7 +100,13 @@ function getTemplateDir() {
100
100
  async function listShippedFiles(templateDir) {
101
101
  const out = [];
102
102
  await walkFiles(templateDir, templateDir, out);
103
- return 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));
104
110
  }
105
111
 
106
112
  async function walkFiles(dir, base, acc) {
@@ -111,6 +117,34 @@ async function walkFiles(dir, base, acc) {
111
117
  }
112
118
  }
113
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
+
114
148
  async function dispatchInstall(target, values, templateDir) {
115
149
  const dryRun = !!values['dry-run'];
116
150
  if (process.stdout.isTTY && !values.force && !dryRun) {
@@ -136,13 +170,13 @@ async function runPlainInstall(target, values, templateDir) {
136
170
  const dryRun = !!values['dry-run'];
137
171
  if (values.force) {
138
172
  if (!process.stdin.isTTY) {
139
- io.error('--force requires an interactive TTY for the confirmation prompt');
173
+ await usageError('--force requires an interactive TTY for the confirmation prompt');
140
174
  return 2;
141
175
  }
142
176
  if (!dryRun) {
143
177
  const answer = await io.ask("type 'overwrite' to proceed: ");
144
178
  if (answer.toLowerCase() !== 'overwrite') {
145
- io.error('confirmation declined');
179
+ await usageError('confirmation declined');
146
180
  return 1;
147
181
  }
148
182
  }
@@ -157,7 +191,7 @@ async function runPlainInstall(target, values, templateDir) {
157
191
  else await freshInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
158
192
  }
159
193
  } catch (err) {
160
- io.error(`install failed: ${err.message}`);
194
+ await usageError(`install failed: ${err.message}`);
161
195
  return 1;
162
196
  }
163
197
 
@@ -181,7 +215,7 @@ async function fetchPlantumlPlain(target, values) {
181
215
  return 0;
182
216
  }
183
217
  if (result.outcome === FETCH_OUTCOMES.ERRORED_REQUIRE_PLANTUML) {
184
- io.error(`--require-plantuml: ${result.reason}`);
218
+ await usageError(`--require-plantuml: ${result.reason}`);
185
219
  return 4;
186
220
  }
187
221
  return 0;
@@ -190,7 +224,7 @@ async function fetchPlantumlPlain(target, values) {
190
224
  async function dispatchUpgrade(target, values, templateDir) {
191
225
  const manifestPath = join(target, '.claude/.baseline-manifest.json');
192
226
  if (!existsSync(manifestPath)) {
193
- io.error(`No baseline manifest at ${manifestPath}. Run a fresh install first.`);
227
+ await usageError(`No baseline manifest at ${manifestPath}. Run a fresh install first.`);
194
228
  return 2;
195
229
  }
196
230
  if (process.stdout.isTTY) {
@@ -245,10 +279,10 @@ async function main(argv) {
245
279
  });
246
280
  } catch (err) {
247
281
  if (/--merge/.test(err.message)) {
248
- io.error('--merge has been removed; use `create-baseline upgrade <target>` instead.');
282
+ await usageError('--merge has been removed; use `create-baseline upgrade <target>` instead.');
249
283
  return 2;
250
284
  }
251
- io.error(err.message);
285
+ await usageError(friendlyParseArgsMessage(err.message));
252
286
  return 2;
253
287
  }
254
288
 
@@ -285,23 +319,34 @@ async function main(argv) {
285
319
  try {
286
320
  templateDir = getTemplateDir();
287
321
  } catch (err) {
288
- io.error(err.message);
322
+ await usageError(err.message);
289
323
  return 2;
290
324
  }
291
325
  return await dispatchUpgrade(target, values, templateDir);
292
326
  }
293
327
 
294
328
  if (values['no-plantuml'] && values['require-plantuml']) {
295
- io.error('--no-plantuml and --require-plantuml are mutually exclusive');
329
+ await usageError('--no-plantuml and --require-plantuml are mutually exclusive');
296
330
  return 2;
297
331
  }
298
332
  if (positionals.length === 0) {
299
- io.error('missing required <target> argument');
300
- 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');
301
346
  return 2;
302
347
  }
303
348
  if (positionals.length > 1) {
304
- io.error(`unexpected positional arguments: ${positionals.slice(1).join(', ')}`);
349
+ await usageError(`unexpected positional arguments: ${positionals.slice(1).join(', ')}`);
305
350
  return 2;
306
351
  }
307
352
 
@@ -310,15 +355,17 @@ async function main(argv) {
310
355
  try {
311
356
  templateDir = getTemplateDir();
312
357
  } catch (err) {
313
- io.error(err.message);
358
+ await usageError(err.message);
314
359
  return 2;
315
360
  }
316
361
 
317
362
  const sentinels = await scanSentinels(target);
318
363
  const hasConflict = sentinels.length > 0;
319
364
  if (hasConflict && !values.force) {
320
- io.error(`existing baseline detected at ${target}: ${sentinels.join(', ')}`);
321
- io.error('pass --force to overwrite or use `create-baseline upgrade <target>` to three-way merge');
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
+ );
322
369
  return 1;
323
370
  }
324
371
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "manifest_version": 2,
3
- "generated_at": "2026-05-18T19:07:49.408Z",
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",
@@ -227,8 +227,8 @@
227
227
  ".claude/skills/triage/SKILL.md": "499f82a26d77c60af827dd89a7fcb3eae0cd903e34c70740c297bc260aeed38e",
228
228
  ".claude/skills/verify/SKILL.md": "fcddba67cf7f3623fc8f8ae142303b16b9db0c9fc1652bc920e16c56fe4c7864",
229
229
  ".mcp.json": "8ebb7966045486187bbdf9bac643e690c4fbc7a9a70a8345e3665ba72fa19b96",
230
- "CLAUDE.md": "5aff9bb3534792961b3e5c79a23f7ae3a859bb22efc3553c62847f555c61a08d",
231
- "docs/init/seed.md": "e0d708ccf06ae1ae124e0a3ada3e77bfbdbb9f2899a548a1729c9270a12f3e98"
230
+ "CLAUDE.md": "1d87311fa1b177944afe69f060b0ddd31359e5391445034d10dc51ced1503ada",
231
+ "docs/init/seed.md": "63f3a0c09895f2040f7fdc188cb0e759bd6a47d615759ec7c83671798504a71d"
232
232
  },
233
233
  "owners": {
234
234
  "skills": {
@@ -271,5 +271,5 @@
271
271
  "verify": "baseline"
272
272
  }
273
273
  },
274
- "build_id": "gha-26054481720"
274
+ "build_id": "gha-26153025904"
275
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 {}
@@ -272,13 +272,13 @@ The vendored `impeccable` skill stays untouched (Article IX). `design-ui` is the
272
272
 
273
273
  ## Article XI — Skill provenance and the baseline manifest
274
274
 
275
- A skill at `.claude/skills/<slug>/SKILL.md` is **baseline-owned** iff its YAML frontmatter declares `owner: baseline`. Every other skill on disk — those without an `owner:` field, or those declaring `owner: user` — is user/third-party and out-of-scope of baseline audit checks. Absence-of-`owner` is the deliberate default so a project with pre-existing skills can install the baseline without annotating any of its own files. The build script `scripts/build-manifest.mjs` reads each `owner:` value and emits the canonical baseline-skill set into `obj/template/manifest.json` under `owners.skills` (a JSON object mapping slug → `"baseline"`). The CLI mirrors this manifest verbatim to `<target>/.claude/.baseline-manifest.json` on install. The audit at `.claude/skills/audit-baseline/audit.sh` consumes `manifest.owners.skills` as the canonical baseline-skill enumeration — the previous hard-coded `EXPECTED_SKILLS` set is removed.
275
+ A skill at `.claude/skills/<slug>/SKILL.md` is **baseline-owned** iff its YAML frontmatter declares `owner: baseline`. Every other skill on disk — those without an `owner:` field, or those declaring `owner: user` — is user/third-party and out-of-scope of baseline audit checks. Absence-of-`owner` is the deliberate default so a project with pre-existing skills can install the baseline without annotating any of its own files. The build script `scripts/build-manifest.mjs` reads each `owner:` value and emits the canonical baseline-skill set into the shipped manifest at `obj/template/.claude/manifest.json` under `owners.skills` (a JSON object mapping slug → `"baseline"`). The recursive install copies the manifest straight to `<target>/.claude/manifest.json` (same path inside the `.claude/` subtree, no special-case). The CLI separately writes `<target>/.claude/.baseline-manifest.json` post-install as a runtime sha256 table of the target's actual on-disk contents (used by `doctor` and `upgrade`). The audit at `.claude/skills/audit-baseline/audit.sh` consumes `manifest.owners.skills` from the shipped `.claude/manifest.json` as the canonical baseline-skill enumeration — the previous hard-coded `EXPECTED_SKILLS` set is removed.
276
276
 
277
277
  You SHALL:
278
278
 
279
279
  1. **Declare baseline ownership only.** A SKILL.md that ships in the baseline SHALL declare `owner: baseline` in its frontmatter directly after `name:`. Authoring a user/third-party skill does NOT require any `owner:` annotation — absence is the default. Explicit `owner: user` is permitted but never required. The only frontmatter-related FAIL the audit emits is `invalid owner=<value>` (a present-but-malformed `owner:` field, e.g. typo). Missing-`owner:` is silently skipped.
280
- 2. **Trust the manifest.** The shipped `obj/template/manifest.json` (mirrored to `<target>/.claude/.baseline-manifest.json` on install) is the canonical record of baseline-owned skills and their content hashes. You SHALL NOT maintain a separate hard-coded list of baseline-skill slugs anywhere in the codebase.
281
- 3. **Re-derive on drift.** The audit re-derives sha256 hashes from `manifest.files` for every path under `.claude/skills/<slug>/` whose slug appears in `owners.skills`, and compares against on-disk content. Mismatches surface as `hash mismatch at <path>`. A baseline-listed slug missing from disk surfaces as `baseline skill missing`. These are hard FAIL — drift detection has no opt-out.
280
+ 2. **Trust the manifest.** The shipped manifest at `obj/template/.claude/manifest.json` (delivered to `<target>/.claude/manifest.json` by the recursive install copy) is the canonical record of baseline-owned skills and their content hashes. The runtime `<target>/.claude/.baseline-manifest.json` written by the CLI post-install is a separate file that captures the target's actual on-disk hashes for `doctor`/`upgrade` — do not conflate the two. You SHALL NOT maintain a separate hard-coded list of baseline-skill slugs anywhere in the codebase.
281
+ 3. **Re-derive on drift.** The audit reads the manifest from `<root>/.claude/manifest.json` (consumer projects) with a fallback to `<root>/obj/template/.claude/manifest.json` (the baseline dev repo). It re-derives sha256 hashes from `manifest.files` for every path under `.claude/skills/<slug>/` whose slug appears in `owners.skills`, and compares against on-disk content. Mismatches surface as `hash mismatch at <path>`. A baseline-listed slug missing from disk surfaces as `baseline skill missing`. These are hard FAIL — drift detection has no opt-out.
282
282
  4. **Preserve constitutional citation.** This Article XI SHALL remain in CLAUDE.md AND in `src/CLAUDE.template.md` (byte-equal mirror). The genesis §17 in `docs/init/seed.md` SHALL remain present, with `src/seed.template.md` mirroring it. The audit verifies both citations and reports `CLAUDE.md missing Article XI citation` or `seed.md missing §17 citation` on absence.
283
283
  5. **Out-of-scope skills don't break the audit.** Any skill on disk that doesn't declare `owner: baseline` is out-of-scope: excluded from the baseline count, the names-match check, and the hash-drift check. Installing the baseline into a project that already has its own skills is zero-friction — no per-file annotation required. Maintenance of those skills is the user's responsibility.
284
284
 
@@ -591,9 +591,9 @@ Until `/init-project` runs, this section stays empty. Once populated, every fiel
591
591
 
592
592
  ## §17 — Skill provenance and the baseline manifest
593
593
 
594
- A skill at `.claude/skills/<slug>/SKILL.md` is **baseline-owned** iff its YAML frontmatter declares `owner: baseline`. Baseline-owned skills are those that ship with the baseline; every other skill on disk — those without an `owner:` field, or those declaring `owner: user` — is user/third-party and out-of-scope of baseline audit checks. Absence-of-`owner` is the deliberate default so a project that already has its own skills can install the baseline without annotating any of those files. The build script `scripts/build-manifest.mjs` reads each `owner:` value at release time and emits the canonical baseline-skill set into `obj/template/manifest.json` under `owners.skills` (a JSON object mapping slug → `"baseline"`). The CLI mirrors this manifest verbatim to `<target>/.claude/.baseline-manifest.json` on `freshInstall`/`forceInstall`/`merge`.
594
+ A skill at `.claude/skills/<slug>/SKILL.md` is **baseline-owned** iff its YAML frontmatter declares `owner: baseline`. Baseline-owned skills are those that ship with the baseline; every other skill on disk — those without an `owner:` field, or those declaring `owner: user` — is user/third-party and out-of-scope of baseline audit checks. Absence-of-`owner` is the deliberate default so a project that already has its own skills can install the baseline without annotating any of those files. The build script `scripts/build-manifest.mjs` reads each `owner:` value at release time and emits the canonical baseline-skill set into the shipped manifest at `obj/template/.claude/manifest.json` under `owners.skills` (a JSON object mapping slug → `"baseline"`). The recursive install copies the manifest into the consumer target at `<target>/.claude/manifest.json` (same in-tree path, no special-case). The CLI separately writes `<target>/.claude/.baseline-manifest.json` post-install on `freshInstall`/`forceInstall`/`merge` — that file is the runtime snapshot of the target's actual on-disk hashes, consumed by `doctor` and `upgrade`. The two files coexist by design: the shipped manifest is frozen at release time and carries `owners.skills`; the runtime manifest is generated at install time and is hash-only.
595
595
 
596
- The audit at `.claude/skills/audit-baseline/audit.sh` consumes `manifest.owners.skills` as the canonical baseline-skill enumeration (replacing the previous hard-coded `EXPECTED_SKILLS` set). For every baseline-owned skill, the audit re-derives sha256 hashes from `manifest.files` and compares against on-disk content; a mismatch is reported as `hash mismatch at <path>` against the named slug. A baseline skill present in the manifest but absent from disk is reported as `baseline skill missing`. A SKILL.md whose `owner:` field is present but carries an invalid value (anything other than `baseline` or `user`) is reported as `invalid owner=<value>`. SKILL.md files without an `owner:` field are treated as user/third-party and silently skipped — they are excluded from the baseline count, the names-match check, and the hash-drift check, so installing the baseline into a project that already has its own skills never breaks the audit.
596
+ The audit at `.claude/skills/audit-baseline/audit.sh` consumes `manifest.owners.skills` as the canonical baseline-skill enumeration (replacing the previous hard-coded `EXPECTED_SKILLS` set). It reads the manifest from `<root>/.claude/manifest.json` first (consumer projects) and falls back to `<root>/obj/template/.claude/manifest.json` (the baseline dev repo where `npm run build` writes the manifest). For every baseline-owned skill, the audit re-derives sha256 hashes from `manifest.files` and compares against on-disk content; a mismatch is reported as `hash mismatch at <path>` against the named slug. A baseline skill present in the manifest but absent from disk is reported as `baseline skill missing`. A SKILL.md whose `owner:` field is present but carries an invalid value (anything other than `baseline` or `user`) is reported as `invalid owner=<value>`. SKILL.md files without an `owner:` field are treated as user/third-party and silently skipped — they are excluded from the baseline count, the names-match check, and the hash-drift check, so installing the baseline into a project that already has its own skills never breaks the audit.
597
597
 
598
598
  The audit also verifies constitutional citation: CLAUDE.md SHALL contain the literal string "Article XI" and a reference to the manifest, and `docs/init/seed.md` SHALL contain "§17" and a manifest reference. Missing citations trigger FAIL with `CLAUDE.md missing Article XI citation` or `seed.md missing §17 citation`.
599
599
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@friedbotstudio/create-baseline",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Node CLI scaffolder that materializes the Claude Code baseline (hooks, skills, commands, MCP servers, governance docs) into a target project, with branded interactive install / upgrade / doctor flows. Run via `npx @friedbotstudio/create-baseline <target>`.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -272,13 +272,13 @@ The vendored `impeccable` skill stays untouched (Article IX). `design-ui` is the
272
272
 
273
273
  ## Article XI — Skill provenance and the baseline manifest
274
274
 
275
- A skill at `.claude/skills/<slug>/SKILL.md` is **baseline-owned** iff its YAML frontmatter declares `owner: baseline`. Every other skill on disk — those without an `owner:` field, or those declaring `owner: user` — is user/third-party and out-of-scope of baseline audit checks. Absence-of-`owner` is the deliberate default so a project with pre-existing skills can install the baseline without annotating any of its own files. The build script `scripts/build-manifest.mjs` reads each `owner:` value and emits the canonical baseline-skill set into `obj/template/manifest.json` under `owners.skills` (a JSON object mapping slug → `"baseline"`). The CLI mirrors this manifest verbatim to `<target>/.claude/.baseline-manifest.json` on install. The audit at `.claude/skills/audit-baseline/audit.sh` consumes `manifest.owners.skills` as the canonical baseline-skill enumeration — the previous hard-coded `EXPECTED_SKILLS` set is removed.
275
+ A skill at `.claude/skills/<slug>/SKILL.md` is **baseline-owned** iff its YAML frontmatter declares `owner: baseline`. Every other skill on disk — those without an `owner:` field, or those declaring `owner: user` — is user/third-party and out-of-scope of baseline audit checks. Absence-of-`owner` is the deliberate default so a project with pre-existing skills can install the baseline without annotating any of its own files. The build script `scripts/build-manifest.mjs` reads each `owner:` value and emits the canonical baseline-skill set into the shipped manifest at `obj/template/.claude/manifest.json` under `owners.skills` (a JSON object mapping slug → `"baseline"`). The recursive install copies the manifest straight to `<target>/.claude/manifest.json` (same path inside the `.claude/` subtree, no special-case). The CLI separately writes `<target>/.claude/.baseline-manifest.json` post-install as a runtime sha256 table of the target's actual on-disk contents (used by `doctor` and `upgrade`). The audit at `.claude/skills/audit-baseline/audit.sh` consumes `manifest.owners.skills` from the shipped `.claude/manifest.json` as the canonical baseline-skill enumeration — the previous hard-coded `EXPECTED_SKILLS` set is removed.
276
276
 
277
277
  You SHALL:
278
278
 
279
279
  1. **Declare baseline ownership only.** A SKILL.md that ships in the baseline SHALL declare `owner: baseline` in its frontmatter directly after `name:`. Authoring a user/third-party skill does NOT require any `owner:` annotation — absence is the default. Explicit `owner: user` is permitted but never required. The only frontmatter-related FAIL the audit emits is `invalid owner=<value>` (a present-but-malformed `owner:` field, e.g. typo). Missing-`owner:` is silently skipped.
280
- 2. **Trust the manifest.** The shipped `obj/template/manifest.json` (mirrored to `<target>/.claude/.baseline-manifest.json` on install) is the canonical record of baseline-owned skills and their content hashes. You SHALL NOT maintain a separate hard-coded list of baseline-skill slugs anywhere in the codebase.
281
- 3. **Re-derive on drift.** The audit re-derives sha256 hashes from `manifest.files` for every path under `.claude/skills/<slug>/` whose slug appears in `owners.skills`, and compares against on-disk content. Mismatches surface as `hash mismatch at <path>`. A baseline-listed slug missing from disk surfaces as `baseline skill missing`. These are hard FAIL — drift detection has no opt-out.
280
+ 2. **Trust the manifest.** The shipped manifest at `obj/template/.claude/manifest.json` (delivered to `<target>/.claude/manifest.json` by the recursive install copy) is the canonical record of baseline-owned skills and their content hashes. The runtime `<target>/.claude/.baseline-manifest.json` written by the CLI post-install is a separate file that captures the target's actual on-disk hashes for `doctor`/`upgrade` — do not conflate the two. You SHALL NOT maintain a separate hard-coded list of baseline-skill slugs anywhere in the codebase.
281
+ 3. **Re-derive on drift.** The audit reads the manifest from `<root>/.claude/manifest.json` (consumer projects) with a fallback to `<root>/obj/template/.claude/manifest.json` (the baseline dev repo). It re-derives sha256 hashes from `manifest.files` for every path under `.claude/skills/<slug>/` whose slug appears in `owners.skills`, and compares against on-disk content. Mismatches surface as `hash mismatch at <path>`. A baseline-listed slug missing from disk surfaces as `baseline skill missing`. These are hard FAIL — drift detection has no opt-out.
282
282
  4. **Preserve constitutional citation.** This Article XI SHALL remain in CLAUDE.md AND in `src/CLAUDE.template.md` (byte-equal mirror). The genesis §17 in `docs/init/seed.md` SHALL remain present, with `src/seed.template.md` mirroring it. The audit verifies both citations and reports `CLAUDE.md missing Article XI citation` or `seed.md missing §17 citation` on absence.
283
283
  5. **Out-of-scope skills don't break the audit.** Any skill on disk that doesn't declare `owner: baseline` is out-of-scope: excluded from the baseline count, the names-match check, and the hash-drift check. Installing the baseline into a project that already has its own skills is zero-friction — no per-file annotation required. Maintenance of those skills is the user's responsibility.
284
284
 
@@ -12,15 +12,13 @@ const NPMRC_TEMPLATE_PATH = join(PACKAGE_ROOT, 'src/.npmrc.template');
12
12
 
13
13
  export const NEVER_TOUCH = Object.freeze(['.claude/project.json']);
14
14
  export const SPECIAL_MERGE = Object.freeze(['.mcp.json']);
15
- // Files present in the shipped template that must NOT be cp'd to target. These
16
- // are reference artifacts the CLI consults from templateDir (or that ship for
17
- // inspection-time provenance), never materialized at consumer project root.
18
- // `manifest.json`: the shipped sha256 table. The CLI's runtime manifest lives
19
- // at `target/.claude/.baseline-manifest.json` (written by writeBaselineManifest);
20
- // `target/manifest.json` would be a confusing duplicate. Keep the file in the
21
- // published tarball so anyone inspecting `node_modules/<pkg>/obj/template/` can
22
- // see what shipped, but exclude it from the fresh/force install copy.
23
- export const COPY_EXCLUDE = Object.freeze(['manifest.json']);
15
+ // The shipped manifest now lives at `.claude/manifest.json` (inside the
16
+ // template's .claude/ subtree), so the recursive cp drops it at the correct
17
+ // consumer path without any special-case filtering. The consumer-side audit
18
+ // (`.claude/skills/audit-baseline/audit.sh`) reads it from there for
19
+ // hash-drift detection. COPY_EXCLUDE stays as a list (currently empty) so
20
+ // future never-copy artifacts can be added without API churn at the callers.
21
+ export const COPY_EXCLUDE = Object.freeze([]);
24
22
 
25
23
  async function listFiles(root, base = root, acc = []) {
26
24
  for (const entry of await readdir(root, { withFileTypes: true })) {
@@ -6,6 +6,7 @@ import * as clackModule from '@clack/prompts';
6
6
  import { readFile } from 'node:fs/promises';
7
7
  import { freshInstall, forceInstall } from '../install.js';
8
8
  import { fetchPlantumlIfMissing, FETCH_OUTCOMES } from '../plantuml.js';
9
+ import { renderBrandStrip } from './splash.js';
9
10
 
10
11
  const SUCCESS = 0;
11
12
  const ERR_INSTALL_FAILED = 1;
@@ -20,7 +21,8 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
20
21
  }
21
22
 
22
23
  const version = await readPackageVersion();
23
- prompts.intro(`create-baseline v${version}`);
24
+ process.stdout.write(renderBrandStrip({ version, subtitle: 'install' }));
25
+ prompts.intro('create-baseline');
24
26
 
25
27
  const spinner = prompts.spinner();
26
28
  spinner.start('Copying baseline files');
@@ -1,24 +1,30 @@
1
- // Domain — branded renderers for the meta commands (--help, --version).
2
- // In a TTY, a brand banner frames the canonical body; in non-TTY the body is
3
- // emitted unchanged so that piped consumers (`$(cli --version)`, `cli --help |
4
- // grep ...`) keep working byte-clean.
1
+ // Domain — branded renderers for the meta commands (--help, --version) and
2
+ // for usage-class errors. In a TTY, the splash marquee (wordmark + brand
3
+ // strip from splash.js) frames the canonical body; in non-TTY the body is
4
+ // emitted unchanged so that piped consumers (`$(cli --version)`,
5
+ // `cli --help | grep ...`) keep working byte-clean.
5
6
 
6
- import { accent, muted, rule } from './tokens.js';
7
+ import { accent, muted, rule, error as errorPaint } from './tokens.js';
8
+ import {
9
+ renderSplash,
10
+ renderBrandStrip,
11
+ renderVersionMarquee,
12
+ wordmarkFits,
13
+ } from './splash.js';
7
14
 
8
- export function renderHelp(helpText, version) {
9
- if (!process.stdout.isTTY) {
10
- process.stdout.write(helpText.endsWith('\n') ? helpText : helpText + '\n');
15
+ const DISCOVER_URL = 'https://baseline.friedbotstudio.com/';
16
+
17
+ export function renderHelp(helpText, _version) {
18
+ const body = helpText.endsWith('\n') ? helpText : helpText + '\n';
19
+ if (!process.stdout.isTTY || !wordmarkFits()) {
20
+ process.stdout.write(body);
11
21
  return;
12
22
  }
13
- const banner = [
14
- '',
15
- ` ${accent('Baseline CLI')} ${muted(`v${version}`)}`,
16
- ` ${muted('@friedbotstudio/create-baseline')}`,
17
- ` ${rule('─'.repeat(48))}`,
18
- '',
19
- ].join('\n');
20
- process.stdout.write(banner + '\n');
21
- process.stdout.write(helpText.endsWith('\n') ? helpText : helpText + '\n');
23
+ process.stdout.write(renderSplash({
24
+ tryLine: 'npx @friedbotstudio/create-baseline ./my-project',
25
+ discoverUrl: DISCOVER_URL,
26
+ }));
27
+ process.stdout.write(body);
22
28
  }
23
29
 
24
30
  export function renderVersion(version) {
@@ -26,5 +32,32 @@ export function renderVersion(version) {
26
32
  process.stdout.write(`${version}\n`);
27
33
  return;
28
34
  }
29
- process.stdout.write(`${accent('baseline')} ${muted('v')}${version}\n`);
35
+ if (wordmarkFits()) {
36
+ process.stdout.write(renderVersionMarquee(version));
37
+ return;
38
+ }
39
+ process.stdout.write(renderBrandStrip({ version }));
40
+ }
41
+
42
+ // Usage errors always print to stderr (so a `cli ... 2>/dev/null` pipeline
43
+ // can still consume stdout cleanly). In a TTY we wrap the message and the
44
+ // HELP_TEXT body in the same brand banner used by --help, with the error
45
+ // label painted in --mac-red. In non-TTY we emit a plain `Error: <msg>`
46
+ // line followed by the canonical help body — same body, no ANSI.
47
+ export function renderUsageError(msg, helpText, version) {
48
+ const body = helpText.endsWith('\n') ? helpText : helpText + '\n';
49
+ if (!process.stderr.isTTY) {
50
+ process.stderr.write(`Error: ${msg}\n`);
51
+ process.stderr.write(body);
52
+ return;
53
+ }
54
+ const banner = [
55
+ '',
56
+ ` ${errorPaint('Error')} ${msg}`,
57
+ ` ${muted(`@friedbotstudio/create-baseline v${version}`)}`,
58
+ ` ${rule('─'.repeat(48))}`,
59
+ '',
60
+ ].join('\n');
61
+ process.stderr.write(banner + '\n');
62
+ process.stderr.write(body);
30
63
  }
@@ -0,0 +1,111 @@
1
+ // Domain — branded splash surfaces for the CLI. Renders a chunky pixel-art
2
+ // "BASELINE" wordmark in three bands of FBS orange (bevel: shadow / mid /
3
+ // highlight / mid / shadow) so the marquee surfaces (--help, --version,
4
+ // no-arg landing) share a single visual identity. Slimmer brand strip is
5
+ // reused by install / upgrade intros and inside the usage-error renderer.
6
+ //
7
+ // All renderers degrade cleanly when stdout is not a TTY or NO_COLOR is set
8
+ // (paintRGB short-circuits to plain text). When the terminal is narrower
9
+ // than the wordmark, callers should fall through to the plain banner via
10
+ // `wordmarkFits(width)` instead of letting the glyphs wrap.
11
+
12
+ import { paintRGB, PALETTE, accent, muted } from './tokens.js';
13
+
14
+ // ANSI-Shadow style block-letter wordmark for "BASELINE". 5 rows × ~60 cols.
15
+ // Kept as raw strings (not paint-wrapped) so renderWordmark can apply a
16
+ // per-row shade. Trailing spaces matter — they're part of the glyph shape.
17
+ const WORDMARK = [
18
+ '██████ █████ ███████ ███████ ██ ██ ███ ██ ███████',
19
+ '██ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ ',
20
+ '██████ ███████ ███████ █████ ██ ██ ██ ██ ██ █████ ',
21
+ '██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ',
22
+ '██████ ██ ██ ███████ ███████ ███████ ██ ██ ████ ███████',
23
+ ];
24
+
25
+ // Outline trace — mirrors the bottom row of the wordmark using the upper
26
+ // one-eighth block (▔) so it visually kisses the base of every letter,
27
+ // producing the subtle "letters are sitting on a baseline" shadow from
28
+ // the skills.sh reference. Painted in accentShadow so it reads as a
29
+ // trace, not a fifth band of the letter body.
30
+ const WORDMARK_OUTLINE = WORDMARK[4].replace(/█/g, '▔');
31
+
32
+ // Bevel banding: dim → mid → bright → mid → dim. Produces the chiseled
33
+ // pixel-art look of the skills.sh reference (substituting FBS oranges for
34
+ // the reference's grayscale palette).
35
+ const SHADES = [
36
+ PALETTE.accentShadow,
37
+ PALETTE.accent,
38
+ PALETTE.accentLight,
39
+ PALETTE.accent,
40
+ PALETTE.accentShadow,
41
+ ];
42
+
43
+ const WORDMARK_WIDTH = Math.max(...WORDMARK.map((row) => row.length));
44
+
45
+ export const SPLASH_COMMANDS = Object.freeze([
46
+ ['$ npx @friedbotstudio/create-baseline <target>', 'Install the baseline'],
47
+ ['$ npx @friedbotstudio/create-baseline upgrade', 'Three-way merge upgrade'],
48
+ ['$ npx @friedbotstudio/create-baseline doctor', 'Drift report'],
49
+ ]);
50
+
51
+ // `process.stdout.columns` is 0 (not undefined) under `script(1)` and some
52
+ // CI ptys; treat any falsy value as "unknown, assume wide enough" so the
53
+ // marquee renders rather than silently degrading to the plain banner.
54
+ export function wordmarkFits(columns) {
55
+ const cols = columns ?? process.stdout.columns;
56
+ if (!cols) return true;
57
+ return cols >= WORDMARK_WIDTH;
58
+ }
59
+
60
+ export function renderWordmark() {
61
+ const bands = WORDMARK.map((row, i) => paintRGB(SHADES[i], row));
62
+ bands.push(paintRGB(PALETTE.accentShadow, WORDMARK_OUTLINE));
63
+ return bands.join('\n');
64
+ }
65
+
66
+ // Full marquee splash. Used by --help in TTY and the no-arg landing. The
67
+ // version is intentionally NOT rendered here — `--version` already surfaces
68
+ // it via renderVersionMarquee, and embedding it in the splash would force
69
+ // docs-site screenshots to re-render every release.
70
+ export function renderSplash({ tagline, tryLine, discoverUrl } = {}) {
71
+ const lines = [
72
+ '',
73
+ `${muted('▲')} ${muted('~/')} ${muted('npx @friedbotstudio/create-baseline@latest')}`,
74
+ '',
75
+ renderWordmark(),
76
+ '',
77
+ muted(tagline ?? 'The Claude Code baseline — hooks, skills, MCP, governance.'),
78
+ '',
79
+ ];
80
+ for (const [cmd, desc] of SPLASH_COMMANDS) {
81
+ const left = ` ${cmd}`;
82
+ lines.push(`${left.padEnd(54)}${muted(desc)}`);
83
+ }
84
+ if (tryLine) {
85
+ lines.push('');
86
+ lines.push(`${muted('try:')} ${tryLine}`);
87
+ }
88
+ if (discoverUrl) {
89
+ lines.push('');
90
+ lines.push(`${muted('Discover more at')} ${discoverUrl}`);
91
+ }
92
+ lines.push('');
93
+ lines.push(`${muted('▲')} ${muted('~/')}`);
94
+ lines.push('');
95
+ return lines.join('\n');
96
+ }
97
+
98
+ // Slim two-line brand strip. Used by install / upgrade intros, --version,
99
+ // and the top of the usage-error renderer. Cheap and width-safe (~32 cols).
100
+ export function renderBrandStrip({ version, subtitle } = {}) {
101
+ const left = `${accent('▲ BASELINE')}`;
102
+ const right = version ? ` ${muted(`v${version}`)}` : '';
103
+ const sub = subtitle ? `\n ${muted(subtitle)}` : '';
104
+ return ['', `${left}${right}${sub}`, ''].join('\n');
105
+ }
106
+
107
+ // --version flourish: the wordmark + a version line. Wider than the strip;
108
+ // callers should fall back to renderBrandStrip when the terminal is narrow.
109
+ export function renderVersionMarquee(version) {
110
+ return ['', renderWordmark(), '', ` ${muted(`v${version}`)}`, ''].join('\n');
111
+ }
@@ -6,6 +6,7 @@
6
6
  const NO_COLOR = process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '';
7
7
 
8
8
  // oklch -> approximate sRGB hex used in the rendered docs site:
9
+ // --accent-shadow oklch(35% 0.15 41.5) ~ #7a2907 (dark band of the wordmark bevel)
9
10
  // --accent oklch(55.8% 0.187 41.5) ~ #c2410c (orange-700)
10
11
  // --accent-light oklch(70.3% 0.187 41.5) ~ #ea6a25 (orange-500)
11
12
  // --muted oklch(45% 0.026 257) ~ #6b7280
@@ -14,6 +15,7 @@ const NO_COLOR = process.env.NO_COLOR !== undefined && process.env.NO_COLOR !==
14
15
  // --mac-red oklch(70% 0.21 24) ~ #ef4444
15
16
  // --rule oklch(89% 0.013 257) ~ #d1d5db
16
17
  const RGB = {
18
+ accentShadow: [122, 41, 7],
17
19
  accent: [194, 65, 12],
18
20
  accentLight: [234, 106, 37],
19
21
  muted: [107, 114, 128],
@@ -23,16 +25,21 @@ const RGB = {
23
25
  rule: [209, 213, 219],
24
26
  };
25
27
 
26
- function paint(rgb, text) {
28
+ // Exposed for the splash wordmark, which paints individual rows in their own
29
+ // shade. All other UI tokens funnel through the named helpers below.
30
+ export function paintRGB(rgb, text) {
27
31
  if (NO_COLOR || !process.stdout.isTTY) return text;
28
32
  const [r, g, b] = rgb;
29
33
  return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
30
34
  }
31
35
 
32
- export const accent = (text) => paint(RGB.accent, text);
33
- export const accentLight = (text) => paint(RGB.accentLight, text);
34
- export const muted = (text) => paint(RGB.muted, text);
35
- export const success = (text) => paint(RGB.success, text);
36
- export const warn = (text) => paint(RGB.warn, text);
37
- export const error = (text) => paint(RGB.error, text);
38
- export const rule = (text) => paint(RGB.rule, text);
36
+ export const PALETTE = Object.freeze(RGB);
37
+
38
+ export const accentShadow = (text) => paintRGB(RGB.accentShadow, text);
39
+ export const accent = (text) => paintRGB(RGB.accent, text);
40
+ export const accentLight = (text) => paintRGB(RGB.accentLight, text);
41
+ export const muted = (text) => paintRGB(RGB.muted, text);
42
+ export const success = (text) => paintRGB(RGB.success, text);
43
+ export const warn = (text) => paintRGB(RGB.warn, text);
44
+ export const error = (text) => paintRGB(RGB.error, text);
45
+ export const rule = (text) => paintRGB(RGB.rule, text);
@@ -7,10 +7,12 @@
7
7
 
8
8
  import * as clackModule from '@clack/prompts';
9
9
  import { existsSync } from 'node:fs';
10
- import { readdir } from 'node:fs/promises';
10
+ import { readdir, readFile } from 'node:fs/promises';
11
11
  import { join, relative, sep } from 'node:path';
12
12
  import { threeWayMerge, ACTION_KINDS } from '../merge.js';
13
13
  import { loadManifest, buildManifestFromDir } from '../manifest.js';
14
+ import { COPY_EXCLUDE } from '../install.js';
15
+ import { renderBrandStrip } from './splash.js';
14
16
 
15
17
  const SUCCESS = 0;
16
18
  const ERR_ABORT = 1;
@@ -37,6 +39,8 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
37
39
  return ERR_NO_MANIFEST;
38
40
  }
39
41
 
42
+ const version = await readPackageVersion();
43
+ process.stdout.write(renderBrandStrip({ version, subtitle: 'upgrade' }));
40
44
  prompts.intro('create-baseline upgrade');
41
45
 
42
46
  const { oldManifest, newManifest } = await loadManifests(opts.templateDir, manifestPath);
@@ -90,11 +94,26 @@ async function loadManifests(templateDir, manifestPath) {
90
94
  return { oldManifest, newManifest };
91
95
  }
92
96
 
97
+ async function readPackageVersion() {
98
+ try {
99
+ const url = new URL('../../../package.json', import.meta.url);
100
+ const pkg = JSON.parse(await readFile(url, 'utf8'));
101
+ return pkg.version || '0.0.0';
102
+ } catch {
103
+ return '0.0.0';
104
+ }
105
+ }
106
+
93
107
  async function listShippedFiles(root, base = root, acc = []) {
94
108
  for (const entry of await readdir(root, { withFileTypes: true })) {
95
109
  const full = join(root, entry.name);
96
110
  if (entry.isDirectory()) await listShippedFiles(full, base, acc);
97
111
  else if (entry.isFile()) acc.push(relative(base, full).split(sep).join('/'));
98
112
  }
99
- return acc;
113
+ // COPY_EXCLUDE (single source of truth in install.js) now lists no paths —
114
+ // the shipped manifest moved into `.claude/manifest.json` so the recursive
115
+ // walk picks it up at the same path the consumer expects. The filter stays
116
+ // for forward-compat; if a future path needs to be kept out of the merge,
117
+ // add it to install.js → COPY_EXCLUDE in one place.
118
+ return acc.filter((p) => !COPY_EXCLUDE.includes(p));
100
119
  }
@@ -591,9 +591,9 @@ Until `/init-project` runs, this section stays empty. Once populated, every fiel
591
591
 
592
592
  ## §17 — Skill provenance and the baseline manifest
593
593
 
594
- A skill at `.claude/skills/<slug>/SKILL.md` is **baseline-owned** iff its YAML frontmatter declares `owner: baseline`. Baseline-owned skills are those that ship with the baseline; every other skill on disk — those without an `owner:` field, or those declaring `owner: user` — is user/third-party and out-of-scope of baseline audit checks. Absence-of-`owner` is the deliberate default so a project that already has its own skills can install the baseline without annotating any of those files. The build script `scripts/build-manifest.mjs` reads each `owner:` value at release time and emits the canonical baseline-skill set into `obj/template/manifest.json` under `owners.skills` (a JSON object mapping slug → `"baseline"`). The CLI mirrors this manifest verbatim to `<target>/.claude/.baseline-manifest.json` on `freshInstall`/`forceInstall`/`merge`.
594
+ A skill at `.claude/skills/<slug>/SKILL.md` is **baseline-owned** iff its YAML frontmatter declares `owner: baseline`. Baseline-owned skills are those that ship with the baseline; every other skill on disk — those without an `owner:` field, or those declaring `owner: user` — is user/third-party and out-of-scope of baseline audit checks. Absence-of-`owner` is the deliberate default so a project that already has its own skills can install the baseline without annotating any of those files. The build script `scripts/build-manifest.mjs` reads each `owner:` value at release time and emits the canonical baseline-skill set into the shipped manifest at `obj/template/.claude/manifest.json` under `owners.skills` (a JSON object mapping slug → `"baseline"`). The recursive install copies the manifest into the consumer target at `<target>/.claude/manifest.json` (same in-tree path, no special-case). The CLI separately writes `<target>/.claude/.baseline-manifest.json` post-install on `freshInstall`/`forceInstall`/`merge` — that file is the runtime snapshot of the target's actual on-disk hashes, consumed by `doctor` and `upgrade`. The two files coexist by design: the shipped manifest is frozen at release time and carries `owners.skills`; the runtime manifest is generated at install time and is hash-only.
595
595
 
596
- The audit at `.claude/skills/audit-baseline/audit.sh` consumes `manifest.owners.skills` as the canonical baseline-skill enumeration (replacing the previous hard-coded `EXPECTED_SKILLS` set). For every baseline-owned skill, the audit re-derives sha256 hashes from `manifest.files` and compares against on-disk content; a mismatch is reported as `hash mismatch at <path>` against the named slug. A baseline skill present in the manifest but absent from disk is reported as `baseline skill missing`. A SKILL.md whose `owner:` field is present but carries an invalid value (anything other than `baseline` or `user`) is reported as `invalid owner=<value>`. SKILL.md files without an `owner:` field are treated as user/third-party and silently skipped — they are excluded from the baseline count, the names-match check, and the hash-drift check, so installing the baseline into a project that already has its own skills never breaks the audit.
596
+ The audit at `.claude/skills/audit-baseline/audit.sh` consumes `manifest.owners.skills` as the canonical baseline-skill enumeration (replacing the previous hard-coded `EXPECTED_SKILLS` set). It reads the manifest from `<root>/.claude/manifest.json` first (consumer projects) and falls back to `<root>/obj/template/.claude/manifest.json` (the baseline dev repo where `npm run build` writes the manifest). For every baseline-owned skill, the audit re-derives sha256 hashes from `manifest.files` and compares against on-disk content; a mismatch is reported as `hash mismatch at <path>` against the named slug. A baseline skill present in the manifest but absent from disk is reported as `baseline skill missing`. A SKILL.md whose `owner:` field is present but carries an invalid value (anything other than `baseline` or `user`) is reported as `invalid owner=<value>`. SKILL.md files without an `owner:` field are treated as user/third-party and silently skipped — they are excluded from the baseline count, the names-match check, and the hash-drift check, so installing the baseline into a project that already has its own skills never breaks the audit.
597
597
 
598
598
  The audit also verifies constitutional citation: CLAUDE.md SHALL contain the literal string "Article XI" and a reference to the manifest, and `docs/init/seed.md` SHALL contain "§17" and a manifest reference. Missing citations trigger FAIL with `CLAUDE.md missing Article XI citation` or `seed.md missing §17 citation`.
599
599