@friedbotstudio/create-baseline 0.2.1 → 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 (87) hide show
  1. package/README.md +17 -7
  2. package/bin/cli.js +197 -119
  3. package/obj/template/.claude/commands/grant-push.md +19 -0
  4. package/obj/template/.claude/commands/init-project.md +26 -4
  5. package/obj/template/.claude/hooks/consent_gate_grant.mjs +107 -0
  6. package/obj/template/.claude/hooks/git_commit_guard.mjs +224 -0
  7. package/obj/template/.claude/hooks/harness_continuation.sh +101 -34
  8. package/obj/template/.claude/hooks/lib/common.mjs +283 -0
  9. package/obj/template/.claude/hooks/lib/common.sh +1 -1
  10. package/obj/template/.claude/hooks/memory_session_start.sh +20 -6
  11. package/obj/template/.claude/hooks/memory_stop.sh +161 -2
  12. package/obj/template/.claude/hooks/spec_approval_guard.sh +1 -1
  13. package/obj/template/.claude/hooks/swarm_approval_guard.sh +1 -1
  14. package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +7 -7
  15. package/obj/template/.claude/hooks/tests/fixtures/memory_stop_landmark_baseline.txt +21 -0
  16. package/obj/template/.claude/hooks/tests/fixtures/regenerate-ac008.sh +47 -0
  17. package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +7 -3
  18. package/obj/template/.claude/hooks/tests/memory_stop_intent_test.sh +329 -0
  19. package/obj/template/.claude/hooks/tests/regenerate_ac008_test.sh +99 -0
  20. package/obj/template/.claude/memory/README.md +8 -3
  21. package/obj/template/.claude/memory/backlog.md +12 -0
  22. package/obj/template/.claude/project.json +6 -1
  23. package/obj/template/.claude/settings.json +3 -4
  24. package/obj/template/.claude/skills/audit-baseline/audit.sh +28 -16
  25. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/_pending_opener_only.md +3 -0
  26. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_empty_body.md +4 -0
  27. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_with_entries.md +9 -0
  28. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_no_opener.md +3 -0
  29. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_opener_only.md +3 -0
  30. package/obj/template/.claude/skills/audit-baseline/tests/preamble_check_test.sh +147 -0
  31. package/obj/template/.claude/skills/changelog/SKILL.md +69 -0
  32. package/obj/template/.claude/skills/changelog/changelog.mjs +163 -0
  33. package/obj/template/.claude/skills/changelog/classifier.mjs +49 -0
  34. package/obj/template/.claude/skills/changelog/state-writer.mjs +19 -0
  35. package/obj/template/.claude/skills/changelog/tests/consent-expired_test.sh +126 -0
  36. package/obj/template/.claude/skills/changelog/tests/golden-path_test.sh +191 -0
  37. package/obj/template/.claude/skills/changelog/tests/idempotent-reentry_test.sh +121 -0
  38. package/obj/template/.claude/skills/changelog/tests/keepachangelog-unreleased-preserved_test.mjs +149 -0
  39. package/obj/template/.claude/skills/changelog/tests/non-git-shortcircuit_test.sh +98 -0
  40. package/obj/template/.claude/skills/changelog/tests/preview-only_test.sh +96 -0
  41. package/obj/template/.claude/skills/changelog/tests/run.sh +28 -0
  42. package/obj/template/.claude/skills/changelog/unreleased-writer.mjs +155 -0
  43. package/obj/template/.claude/skills/changelog/version-preview.mjs +124 -0
  44. package/obj/template/.claude/skills/chore/SKILL.md +5 -3
  45. package/obj/template/.claude/skills/commit/SKILL.md +5 -4
  46. package/obj/template/.claude/skills/copywriting/LICENSE +21 -0
  47. package/obj/template/.claude/skills/copywriting/NOTICE +23 -0
  48. package/obj/template/.claude/skills/copywriting/SKILL.md +1 -1
  49. package/obj/template/.claude/skills/design-ui/SKILL.md +23 -5
  50. package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +26 -5
  51. package/obj/template/.claude/skills/design-ui/references/orchestration.md +1 -0
  52. package/obj/template/.claude/skills/design-ui/references/state-machine.md +5 -3
  53. package/obj/template/.claude/skills/documentation/LICENSE +202 -0
  54. package/obj/template/.claude/skills/documentation/NOTICE +22 -0
  55. package/obj/template/.claude/skills/harness/SKILL.md +5 -1
  56. package/obj/template/.claude/skills/humanizer/LICENSE +21 -0
  57. package/obj/template/.claude/skills/humanizer/NOTICE +21 -0
  58. package/obj/template/.claude/skills/impeccable/LICENSE +202 -0
  59. package/obj/template/.claude/skills/impeccable/NOTICE +24 -0
  60. package/obj/template/.claude/skills/memory-flush/SKILL.md +20 -4
  61. package/obj/template/.claude/skills/memory-flush/sweep.py +74 -6
  62. package/obj/template/.claude/skills/memory-flush/tests/run.sh +300 -1
  63. package/obj/template/.claude/skills/tdd/SKILL.md +2 -1
  64. package/obj/template/.claude/skills/tdd/drift_check.py +180 -0
  65. package/obj/template/.claude/skills/tdd/tests/drift_check_test.sh +190 -0
  66. package/obj/template/.claude/skills/tdd/tests/run.sh +21 -0
  67. package/obj/template/.claude/skills/technical-tutorials/LICENSE +21 -0
  68. package/obj/template/.claude/skills/technical-tutorials/NOTICE +23 -0
  69. package/obj/template/.claude/skills/technical-tutorials/SKILL.md +1 -1
  70. package/obj/template/.claude/skills/triage/SKILL.md +11 -5
  71. package/obj/template/CLAUDE.md +36 -25
  72. package/obj/template/docs/init/seed.md +39 -24
  73. package/obj/template/manifest.json +73 -33
  74. package/package.json +5 -2
  75. package/src/CLAUDE.template.md +36 -25
  76. package/src/cli/merge.js +15 -10
  77. package/src/cli/tui/doctor.js +56 -0
  78. package/src/cli/tui/install.js +79 -0
  79. package/src/cli/tui/meta.js +30 -0
  80. package/src/cli/tui/tokens.js +38 -0
  81. package/src/cli/tui/upgrade.js +100 -0
  82. package/src/memory/backlog.template.md +12 -0
  83. package/src/project.template.json +6 -1
  84. package/src/seed.template.md +39 -24
  85. package/src/settings.template.json +3 -4
  86. package/obj/template/.claude/hooks/consent_gate_grant.sh +0 -89
  87. package/obj/template/.claude/hooks/git_commit_guard.sh +0 -93
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
 
@@ -64,7 +64,7 @@ A team that installs the baseline stops typing *"don't push, don't `--amend`, do
64
64
  | **Hooks** at PreToolUse / PostToolUse / SessionStart / Stop / PreCompact / UserPromptSubmit | 22 | `.claude/hooks/` |
65
65
  | **Skills** across artifact drafting, workflow phases, phase workers, spec helpers, orchestration, memory, audit, alternate tracks, and shared globals | 36 | `.claude/skills/` |
66
66
  | **Subagent** — `swarm-worker`, executes pre-decided recipes inside isolated git worktrees | 1 | `.claude/agents/` |
67
- | **Workflow phases** — intake → scout → research → spec → tdd → simplify → security → integrate → document → archive → commit | 11 | enforced by `track_guard` |
67
+ | **Workflow phases** — intake → scout → research → spec → tdd → simplify → security → integrate → document → archive → memory-flush → commit | 11 | enforced by `track_guard` |
68
68
  | **Consent gates** — `/approve-spec`, `/approve-swarm`, `/grant-commit`. User-typed; structurally un-invokable by Claude | 3 | `consent_gate_grant` UserPromptSubmit hook |
69
69
  | **MCP servers** declared in `.mcp.json` — `context7` (third-party API docs), `plantuml` (diagram render), `playwright` (cross-engine smoke) | 3 | `.mcp.json` |
70
70
 
@@ -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
@@ -148,13 +154,17 @@ cd ./your-project
148
154
  /harness
149
155
  ```
150
156
 
151
- The three consent gates pause the workflow until you type the corresponding command:
157
+ The three workflow-phase consent gates pause the workflow until you type the corresponding command:
152
158
 
153
159
  - **`/approve-spec <slug>`** — after the spec phase, before any code is written
154
160
  - **`/approve-swarm <slug>`** — after `/swarm-plan`, before parallel dispatch
155
161
  - **`/grant-commit`** — after `/archive`, before the commit lands
156
162
 
157
- Each gate writes a short-lived consent marker via a UserPromptSubmit hook that runs *before* Claude is invoked on the body. Claude cannot forge the marker; the corresponding write-boundary guard validates it on disk before allowing the approval token through.
163
+ A fourth consent gate sits outside the phase pipeline:
164
+
165
+ - **`/grant-push`** — opens a 5-minute window for `git push` on a protected branch (per `project.json → git.protected_branches`). Pushes on non-protected branches need no consent.
166
+
167
+ Each gate writes a short-lived consent marker via a UserPromptSubmit hook that runs *before* Claude is invoked on the body. Claude cannot forge the marker; the write-boundary guard validates it on disk before allowing the approval token through.
158
168
 
159
169
  ## How the enforcement works
160
170
 
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,19 @@
1
+ ---
2
+ description: Grant consent for Claude to run `git push`. Valid for 5 minutes. Required by the Git Commit Guard hook on protected branches.
3
+ argument-hint: "[optional note]"
4
+ allowed-tools: Bash(mkdir:*), Bash(date:*), Bash(tee:*), Bash(git:*), Write
5
+ disable-model-invocation: true
6
+ ---
7
+
8
+ Write a consent token to `.claude/state/push_consent` so the Git Commit Guard hook allows the next `git push` on a protected branch. The token is the current UNIX epoch timestamp on line 1; any optional note goes on line 2.
9
+
10
+ How this works structurally: when the user typed `/grant-push`, the `consent_gate_grant` UserPromptSubmit hook ran *before* this body was passed to Claude and wrote a short-lived consent marker at `.claude/state/.push_consent_grant`. The `git_commit_guard` PreToolUse hook (Write matcher) reads that marker and allows Claude to write the consent file because the marker is fresh. Claude cannot forge the marker — that's what makes the gate structural. The Bash-matcher leg of the same guard then enforces the consent token on the actual `git push` invocation, but only when the current branch matches `project.json → git.protected_branches`.
11
+
12
+ Steps:
13
+
14
+ 1. **Git-repo precheck.** Run `git rev-parse --is-inside-work-tree 2>/dev/null`. If the exit status is non-zero, this project is not a git repository: refuse to write the consent token and tell the user "Not a git repository — `/grant-push` is inapplicable. Push has no meaning outside a git repo." Stop here.
15
+ 2. Run `date +%s` to get the current epoch.
16
+ 3. Write the epoch (and the optional note `$ARGUMENTS` on line 2 if non-empty) to `.claude/state/push_consent`, overwriting any prior token.
17
+ 4. Confirm to the user: "Push consent granted at <epoch>, valid for 300s (until <HH:MM:SS local>). The next `git push` on a protected branch will be allowed. Pushes on branches NOT in `project.json → git.protected_branches` do not require this consent."
18
+
19
+ Do not run `git push` yourself in this command. The user asks explicitly when they want a push; this command only opens the window.
@@ -63,7 +63,9 @@ The recommender's SKILL.md instructs it to:
63
63
 
64
64
  Capture both the narrative and the JSON. Save the JSON to `.claude/state/init/<timestamp>.recommender.json`.
65
65
 
66
- ## Step 5 — Aggregate + present
66
+ ## Step 5 — Aggregate + present (REVIEW ONLY — NOTHING WRITTEN YET)
67
+
68
+ **This step is a proposal, not configuration.** Nothing has been written to disk yet: `.claude/project.json` still reads `configured: false`, no new skills/hooks/MCPs are wired, and the `swarm-worker` agent file has not been re-rendered. The user is reading a *proposal* that takes effect only when they explicitly approve in this step.
67
69
 
68
70
  Show the user one review surface before writing anything:
69
71
 
@@ -77,13 +79,32 @@ Show the user one review surface before writing anything:
77
79
  3. **Recommender additions** (from JSON `additions`): MCP servers, skills, hooks, and any `swarm_worker_skills` to preload — name + reason for each.
78
80
  4. **Gaps flagged** (from JSON `gaps`): things the baseline doesn't cover but might warrant a future spec.
79
81
 
80
- Use `AskUserQuestion` to confirm: "Apply these changes?" Options: `apply`, `apply with edits`, `cancel`.
82
+ After presenting the four blocks, **explicitly tell the user the project is NOT yet configured**. Print this exact block above the confirmation prompt:
83
+
84
+ ```
85
+ ⚠ The baseline is still in PROJECT-AGNOSTIC MODE.
81
86
 
82
- If `apply with edits`: take the user's adjustments inline, re-show the surface, ask again.
87
+ None of the proposal above has been applied. `project.json configured`
88
+ is still `false`. test_runner / lint_runner are still in guide mode.
89
+ Closing this session now leaves the project unconfigured.
90
+
91
+ The next prompt is an action gate. You must explicitly approve to proceed
92
+ to Step 6 (apply) — otherwise nothing changes.
93
+ ```
94
+
95
+ Use `AskUserQuestion` to confirm. The question SHALL be a full sentence that names the un-configured state explicitly — not "Apply these changes?" alone, but: **"The project is NOT yet configured. Proceed to apply this proposal and finish setup?"** Options:
96
+
97
+ - `Proceed and apply` — advances to Step 6.
98
+ - `Edit before applying` — take the user's adjustments inline, re-show the surface, ask again.
99
+ - `Cancel — leave project unconfigured` — exit without writing; `configured` stays `false`; surface that the project remains in project-agnostic mode and `/init-project` can be re-run later.
100
+
101
+ If `Edit before applying`: take the user's adjustments inline, re-show the surface, **ask the same gate again**. Do not silently apply — the gate fires after every edit cycle until the user picks `Proceed and apply` or `Cancel`.
83
102
 
84
103
  ## Step 6 — Apply
85
104
 
86
- Write to disk now. Do each sub-step in order; if any fails, stop and surface the error before continuing:
105
+ Write to disk now. **This is the first step in the protocol that mutates files in the user's project** — until this step runs, `.claude/project.json` still reads `configured: false` and the baseline stays in project-agnostic mode. Reaching this step means the user explicitly picked `Proceed and apply` at Step 5's gate.
106
+
107
+ Do each sub-step in order; if any fails, stop and surface the error before continuing:
87
108
 
88
109
  1. **Pre-create lazy directories**:
89
110
  ```bash
@@ -186,6 +207,7 @@ Print a final summary:
186
207
  ## Constraints
187
208
 
188
209
  - **Steps 6 + 7 + 8 are atomic for the user.** If Step 8 fails, do not declare success at Step 9.
210
+ - **Step 5 is review, not setup.** Until the user explicitly picks `Proceed and apply` at Step 5's gate, the project remains in project-agnostic mode. Surfacing recommendations is not configuration; the gate prompt SHALL name the un-configured state in a full sentence so a skimming reader cannot mistake the proposal for a completion notice.
189
211
  - **Never write `configured: true` before Step 8 passes.** A FAIL at Step 8 means the project is in a broken state; leaving `configured: true` would lie to `setup_guard` and the welcome hook in CLAUDE.md.
190
212
  - **No silent decisions.** Every project-specific change appears in seed.md §16 so the next reader can see what diverged from baseline.
191
213
  - **Idempotent.** Re-running on the same project produces the same §16 (modulo timestamp + run number) and passes `/audit-baseline` cleanly.
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ // Consent Gate Grant — UserPromptSubmit
3
+ //
4
+ // JS port of consent_gate_grant.sh, adding a fourth arm for /grant-push.
5
+ //
6
+ // When the user types one of /approve-spec, /approve-swarm, /grant-commit,
7
+ // /grant-push, this hook fires BEFORE Claude is invoked. It writes a
8
+ // short-lived consent marker at .claude/state/.<gate>_grant.
9
+ //
10
+ // The marker is what makes the corresponding approval-token write succeed:
11
+ // the gate-specific PreToolUse guard (spec_approval_guard, swarm_approval_guard,
12
+ // git_commit_guard) reads the marker and allows Claude's write only if a
13
+ // fresh, slug-matched marker is on disk.
14
+ //
15
+ // Why the marker is unforgeable by Claude:
16
+ // - This hook runs on UserPromptSubmit, OUTSIDE Claude's tool boundary.
17
+ // - The PreToolUse guards block Claude from writing the marker file.
18
+ // - Markers expire after consent.gate_marker_ttl_seconds (default 120).
19
+ //
20
+ // Marker shapes:
21
+ // .spec_approval_grant line 1: slug · line 2: epoch · line 3: abs spec path
22
+ // .swarm_approval_grant line 1: slug · line 2: epoch
23
+ // .commit_consent_grant line 1: epoch · line 2: optional note
24
+ // .push_consent_grant line 1: epoch · line 2: optional note (NEW)
25
+
26
+ import { join } from 'node:path';
27
+ import {
28
+ readPayload,
29
+ payloadGet,
30
+ canonicalSlug,
31
+ writeMarkerAtomic,
32
+ logLine,
33
+ CLAUDE_PROJECT_ROOT,
34
+ CONSENT_MARKER_SPEC,
35
+ CONSENT_MARKER_SWARM,
36
+ CONSENT_MARKER_COMMIT,
37
+ CONSENT_MARKER_PUSH,
38
+ } from './lib/common.mjs';
39
+
40
+ const HOOK = 'consent_gate_grant';
41
+
42
+ async function main() {
43
+ // Fast-path: rule out 99% of prompts before any regex parsing.
44
+ const payload = await readPayload();
45
+ const prompt = payloadGet(payload, '.prompt');
46
+ if (typeof prompt !== 'string' || prompt.length === 0) return;
47
+ if (!/\/(approve-spec|approve-swarm|grant-commit|grant-push)/.test(prompt)) return;
48
+
49
+ const firstLine = prompt.split(/\r?\n/)[0].trim();
50
+ const now = Math.floor(Date.now() / 1000);
51
+
52
+ let m;
53
+
54
+ m = firstLine.match(/^\/approve-spec\s+(\S+)/);
55
+ if (m) {
56
+ const arg = m[1];
57
+ const slug = canonicalSlug(arg);
58
+ let absPath;
59
+ if (arg.startsWith('/')) absPath = arg;
60
+ else if (arg.includes('/')) absPath = join(CLAUDE_PROJECT_ROOT, arg);
61
+ else absPath = join(CLAUDE_PROJECT_ROOT, 'docs', 'specs', `${slug}.md`);
62
+ if (writeMarkerAtomic(CONSENT_MARKER_SPEC, slug, String(now), absPath)) {
63
+ logLine(HOOK, `wrote spec_approval_grant slug=${slug} path=${absPath}`);
64
+ } else {
65
+ logLine(HOOK, `FAILED write spec_approval_grant slug=${slug}`);
66
+ }
67
+ return;
68
+ }
69
+
70
+ m = firstLine.match(/^\/approve-swarm\s+(\S+)/);
71
+ if (m) {
72
+ const slug = canonicalSlug(m[1]);
73
+ if (writeMarkerAtomic(CONSENT_MARKER_SWARM, slug, String(now))) {
74
+ logLine(HOOK, `wrote swarm_approval_grant slug=${slug}`);
75
+ } else {
76
+ logLine(HOOK, `FAILED write swarm_approval_grant slug=${slug}`);
77
+ }
78
+ return;
79
+ }
80
+
81
+ m = firstLine.match(/^\/grant-commit(\s.*)?$/);
82
+ if (m) {
83
+ const note = (m[1] || '').trim();
84
+ if (writeMarkerAtomic(CONSENT_MARKER_COMMIT, String(now), note)) {
85
+ logLine(HOOK, `wrote commit_consent_grant note=${note}`);
86
+ } else {
87
+ logLine(HOOK, `FAILED write commit_consent_grant`);
88
+ }
89
+ return;
90
+ }
91
+
92
+ m = firstLine.match(/^\/grant-push(\s.*)?$/);
93
+ if (m) {
94
+ const note = (m[1] || '').trim();
95
+ if (writeMarkerAtomic(CONSENT_MARKER_PUSH, String(now), note)) {
96
+ logLine(HOOK, `wrote push_consent_grant note=${note}`);
97
+ } else {
98
+ logLine(HOOK, `FAILED write push_consent_grant`);
99
+ }
100
+ return;
101
+ }
102
+ }
103
+
104
+ main().catch(() => {
105
+ // UserPromptSubmit hook must never fail loudly — silent exit on any error.
106
+ process.exit(0);
107
+ });