@friedbotstudio/create-baseline 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/bin/cli.js +197 -119
- package/obj/template/.claude/skills/changelog/SKILL.md +69 -0
- package/obj/template/.claude/skills/changelog/changelog.mjs +163 -0
- package/obj/template/.claude/skills/changelog/classifier.mjs +49 -0
- package/obj/template/.claude/skills/changelog/state-writer.mjs +19 -0
- package/obj/template/.claude/skills/changelog/tests/consent-expired_test.sh +126 -0
- package/obj/template/.claude/skills/changelog/tests/golden-path_test.sh +191 -0
- package/obj/template/.claude/skills/changelog/tests/idempotent-reentry_test.sh +121 -0
- package/obj/template/.claude/skills/changelog/tests/keepachangelog-unreleased-preserved_test.mjs +149 -0
- package/obj/template/.claude/skills/changelog/tests/non-git-shortcircuit_test.sh +98 -0
- package/obj/template/.claude/skills/changelog/tests/preview-only_test.sh +96 -0
- package/obj/template/.claude/skills/changelog/tests/run.sh +28 -0
- package/obj/template/.claude/skills/changelog/unreleased-writer.mjs +155 -0
- package/obj/template/.claude/skills/changelog/version-preview.mjs +124 -0
- package/obj/template/.claude/skills/commit/SKILL.md +1 -1
- package/obj/template/.claude/skills/harness/SKILL.md +3 -1
- package/obj/template/.claude/skills/triage/SKILL.md +6 -5
- package/obj/template/CLAUDE.md +2 -2
- package/obj/template/docs/init/seed.md +4 -4
- package/obj/template/manifest.json +21 -7
- package/package.json +5 -2
- package/src/CLAUDE.template.md +2 -2
- package/src/cli/merge.js +15 -10
- package/src/cli/tui/doctor.js +56 -0
- package/src/cli/tui/install.js +79 -0
- package/src/cli/tui/meta.js +30 -0
- package/src/cli/tui/tokens.js +38 -0
- package/src/cli/tui/upgrade.js +100 -0
- package/src/seed.template.md +4 -4
package/README.md
CHANGED
|
@@ -37,11 +37,11 @@ A discipline layer for Claude Code. Hooks at every tool boundary, a workflow tha
|
|
|
37
37
|
> [!IMPORTANT]
|
|
38
38
|
> **Install in one line:** `npx @friedbotstudio/create-baseline ./your-project`
|
|
39
39
|
>
|
|
40
|
-
> The CLI fetches the published package, runs the install, and leaves your project with `.claude/`, `CLAUDE.md`, `docs/init/seed.md`, and `.mcp.json`. Re-run with
|
|
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, **
|
|
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
|
-
#
|
|
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
|
|
104
|
+
npx @friedbotstudio/create-baseline upgrade ./your-project
|
|
103
105
|
|
|
104
106
|
# Preview without writing anything
|
|
105
107
|
npx @friedbotstudio/create-baseline ./your-project --dry-run
|
|
@@ -124,6 +126,10 @@ npx @friedbotstudio/create-baseline doctor ./your-project
|
|
|
124
126
|
# Strict mode — print TAMPERED: shipped vs observed sha256 for every
|
|
125
127
|
# customised file and exit 1 on any drift.
|
|
126
128
|
npx @friedbotstudio/create-baseline doctor ./your-project --strict
|
|
129
|
+
|
|
130
|
+
# JSON mode — emit the structured report on stdout for CI parsers.
|
|
131
|
+
# Same exit codes; honours --strict.
|
|
132
|
+
npx @friedbotstudio/create-baseline doctor ./your-project --json
|
|
127
133
|
```
|
|
128
134
|
|
|
129
135
|
## Quickstart
|
package/bin/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
|
-
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
|
|
8
8
|
import * as io from '../src/cli/io.js';
|
|
@@ -17,18 +17,24 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
17
17
|
const PKG_ROOT = resolve(__dirname, '..');
|
|
18
18
|
|
|
19
19
|
const HELP_TEXT = `Usage:
|
|
20
|
-
create-baseline <target> [options] install
|
|
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
|
-
|
|
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
|
-
--
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
58
|
-
2 argv error, non-TTY where TTY required,
|
|
59
|
-
3
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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
|
-
|
|
174
|
-
if (!process.stdin.isTTY) {
|
|
175
|
-
io.error('--force requires an interactive TTY for the confirmation prompt');
|
|
176
|
-
return 2;
|
|
177
|
-
}
|
|
178
|
-
if (!dryRun) {
|
|
179
|
-
const answer = await io.ask("type 'overwrite' to proceed: ");
|
|
180
|
-
if (answer.toLowerCase() !== 'overwrite') {
|
|
181
|
-
io.error('confirmation declined');
|
|
182
|
-
return 1;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (values.merge) {
|
|
188
|
-
if (!process.stdin.isTTY && !dryRun) {
|
|
189
|
-
io.error('--merge requires an interactive TTY for the confirmation prompt');
|
|
190
|
-
return 2;
|
|
191
|
-
}
|
|
192
|
-
if (!dryRun) {
|
|
193
|
-
const answer = await io.ask("type 'merge' to proceed: ");
|
|
194
|
-
if (answer.toLowerCase() !== 'merge') {
|
|
195
|
-
io.error('confirmation declined');
|
|
196
|
-
return 1;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
let exitCode = 0;
|
|
202
|
-
try {
|
|
203
|
-
if (values.merge) {
|
|
204
|
-
const oldManifest = await loadManifest(join(target, '.claude/.baseline-manifest.json'));
|
|
205
|
-
const tplFiles = listShippedFiles(templateDir);
|
|
206
|
-
const newManifest = await buildManifestFromDir(templateDir, tplFiles);
|
|
207
|
-
if (dryRun) {
|
|
208
|
-
io.log(`Would merge ${tplFiles.length} files into ${target}`);
|
|
209
|
-
} else {
|
|
210
|
-
const report = await threeWayMerge(templateDir, target, oldManifest, newManifest);
|
|
211
|
-
for (const a of report.actions) {
|
|
212
|
-
io.log(`${a.kind.padEnd(24)} ${a.path}`);
|
|
213
|
-
}
|
|
214
|
-
exitCode = report.exitCode;
|
|
215
|
-
}
|
|
216
|
-
} else if (values.force) {
|
|
217
|
-
if (dryRun) io.log(`Would force-install into ${target}`);
|
|
218
|
-
else await forceInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
|
|
219
|
-
} else {
|
|
220
|
-
if (dryRun) io.log(`Would fresh-install into ${target}`);
|
|
221
|
-
else await freshInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
|
|
222
|
-
}
|
|
223
|
-
} catch (err) {
|
|
224
|
-
io.error(`install failed: ${err.message}`);
|
|
225
|
-
return 1;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (!dryRun) {
|
|
229
|
-
const fetchResult = await fetchPlantumlIfMissing(target, {
|
|
230
|
-
noPlantuml: values['no-plantuml'],
|
|
231
|
-
requirePlantuml: values['require-plantuml'],
|
|
232
|
-
});
|
|
233
|
-
if (fetchResult.outcome === FETCH_OUTCOMES.WARNED_NETWORK_FAILURE
|
|
234
|
-
|| fetchResult.outcome === FETCH_OUTCOMES.WARNED_HASH_MISMATCH) {
|
|
235
|
-
io.warn(`PlantUML jar fetch failed (${fetchResult.reason}); install continued. Retry with --require-plantuml or set system plantuml on PATH.`);
|
|
236
|
-
} else if (fetchResult.outcome === FETCH_OUTCOMES.ERRORED_REQUIRE_PLANTUML) {
|
|
237
|
-
io.error(`--require-plantuml: ${fetchResult.reason}`);
|
|
238
|
-
return 4;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (!dryRun && exitCode === 0) {
|
|
243
|
-
io.log(`Installed manifest version 1 to ${target}.`);
|
|
244
|
-
io.log(`Pin via "@friedbotstudio/create-baseline@<exact-version>" in your bootstrap docs.`);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
return exitCode;
|
|
325
|
+
return await dispatchInstall(target, values, templateDir);
|
|
248
326
|
}
|
|
249
327
|
|
|
250
328
|
main(process.argv).then((code) => { process.exit(code); }).catch((err) => {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: changelog
|
|
3
|
+
owner: baseline
|
|
4
|
+
description: Workflow Phase 11.5 — Pre-commit changelog curation. Reads the staged commit's diff + conventional-type, classifies entries into keepachangelog 1.0.0 sections, appends them under `## [Unreleased]` in CHANGELOG.md, and writes ChangelogState to `.claude/state/changelog/<slug>.json`. Runs between `/grant-commit` (gate C) and `/commit`. Authorized by the same `commit_consent` token that authorizes `/commit` — no new gate. Also supports `--preview-only` for ad-hoc projected-version lookups outside a workflow.
|
|
5
|
+
argument-hint: "[--preview-only]"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# changelog — Phase 11.5
|
|
9
|
+
|
|
10
|
+
Curates the `## [Unreleased]` section of `CHANGELOG.md` per [keepachangelog.com 1.0.0](https://keepachangelog.com/en/1.0.0/) before `/commit` stages the diff. Pure local curation — `@semantic-release/changelog` continues to own release-time version-block insertion.
|
|
11
|
+
|
|
12
|
+
## Prereq
|
|
13
|
+
|
|
14
|
+
ALL of `archive` AND `memory-flush` AND (implicitly) a fresh `commit_consent` token MUST be in place. Verified by the actuator at runtime, NOT by a separate guard hook.
|
|
15
|
+
|
|
16
|
+
## Applicability
|
|
17
|
+
|
|
18
|
+
Git projects only. Non-git projects auto-except this phase at `/triage` time alongside `commit` and the swarm phases (CLAUDE.md Article IV).
|
|
19
|
+
|
|
20
|
+
## Steps
|
|
21
|
+
|
|
22
|
+
1. **Prereq check.** Read `.claude/state/workflow.json`. Confirm `archive` and `memory-flush` are in `completed`. If not, exit 1 with a clear error.
|
|
23
|
+
2. **Invoke the actuator.** `node .claude/skills/changelog/changelog.mjs --slug <slug> --project-root <root>`. The actuator does all the work: reads the consent token, classifies commits, appends to `## [Unreleased]`, writes state.
|
|
24
|
+
3. **On actuator success.** The harness marks this task `completed`, appends `"changelog"` to `workflow.json → completed`, and continues to `/commit`.
|
|
25
|
+
4. **On actuator failure (exit 1).** Surface the stderr to the user. Most likely cause: `commit_consent` expired. User re-runs `/grant-commit`.
|
|
26
|
+
|
|
27
|
+
## Ad-hoc preview mode
|
|
28
|
+
|
|
29
|
+
The skill is also invokable outside an active workflow via `--preview-only`. The actuator calls `semantic-release` as a JS API with `dryRun: true` and prints the projected next version + a draft fragment to stdout. No files are written; no consent gesture is required. Useful for answering "what version would my next push deploy?" without running the full workflow.
|
|
30
|
+
|
|
31
|
+
## Companion files
|
|
32
|
+
|
|
33
|
+
- `changelog.mjs` — CLI actuator. The decision logic.
|
|
34
|
+
- `classifier.mjs` — conventional-commit type → keepachangelog section.
|
|
35
|
+
- `version-preview.mjs` — semantic-release JS API call for projected version.
|
|
36
|
+
- `state-writer.mjs` — idempotent writes to `.claude/state/changelog/<slug>.json`.
|
|
37
|
+
- `unreleased-writer.mjs` — `CHANGELOG.md` RMW under `## [Unreleased]`; also exports `reinsertUnreleasedHeading` for the release-time fallback (`@semantic-release/changelog` destroys the heading position; this restores it).
|
|
38
|
+
|
|
39
|
+
## Constraints
|
|
40
|
+
|
|
41
|
+
- **Idempotent.** Re-invocation on the same slug + same HEAD SHA does NOT duplicate entries. The actuator computes a digest from `(slug, source_commit_sha, entries)` and skips writes if the digest matches the prior state file.
|
|
42
|
+
- **No internal mocks.** The actuator imports `semantic-release` (devDep) directly; no mock layer. The system clock IS mocked in `consent-expired` tests with `touch -d`.
|
|
43
|
+
- **TTL fit.** The skill is designed to complete inside the 300 s `commit_consent` window. Typical runtime: under 5 s. If the token expires mid-run, the actuator exits 1 BEFORE writing — partial writes are not allowed.
|
|
44
|
+
- **CHANGELOG.md migration is in scope of the workflow that introduces this skill.** Subsequent workflows assume the file already has the keepachangelog `## [Unreleased]` heading.
|
|
45
|
+
|
|
46
|
+
## Spec traceability
|
|
47
|
+
|
|
48
|
+
The acceptance criteria from `docs/specs/changelog-skill-and-responsive-svgs.md` map to the skill's components as follows:
|
|
49
|
+
|
|
50
|
+
- **AC-001** (harness invokes changelog between gate C and commit; Unreleased section grows) — `changelog.mjs` `runActiveMode` + `unreleased-writer.mjs` `appendUnderUnreleased`.
|
|
51
|
+
- **AC-002** (CHANGELOG.md included in commit stage list) — `commit/SKILL.md` Step 3 named-path enumeration grows `CHANGELOG.md` via the actuator's write; verified in `golden-path_test.sh`.
|
|
52
|
+
- **AC-003** (non-git short-circuit) — `triage/SKILL.md` step 2 non-git auto-except list grew to include `"changelog"` alongside `"commit"`; verified in `non-git-shortcircuit_test.sh`.
|
|
53
|
+
- **AC-004** (audit-baseline byte-mirror invariants after Article IV amendment) — handled in `CLAUDE.md` ↔ `src/CLAUDE.template.md` mirror + `docs/init/seed.md` ↔ `src/seed.template.md` mirror; verified by `audit.sh` PASS.
|
|
54
|
+
- **AC-005** (site-src narrative names new phase; Article X.1 em-dash discipline) — handled in `/document` Phase 10 per design-ui row 1 misroute terminal at `.claude/state/design/changelog-skill-and-responsive-svgs-row1.json`.
|
|
55
|
+
- **AC-006** (SVG legible at 320 px) — handled in `site-src/assets/site.css` bento `@media (max-width: 768px)` block per design-ui row 0 audit at `docs/design/changelog-skill-and-responsive-svgs.audit.md`.
|
|
56
|
+
- **AC-007** (bento composition at 1920 px) — same design-ui row 0 deliverable; audit verdict 20/20 PASS.
|
|
57
|
+
- **AC-008** (workflow.json completed sequence ends with `[..., "changelog", "commit"]`) — `harness/SKILL.md` phase-ordering fence + `commit/SKILL.md` prereq line.
|
|
58
|
+
- **AC-009** (source_backlog_keys stamp-closure on commit) — `commit/SKILL.md` Step 6 invokes `sweep.py --mode stamp-closure`; no change to that flow needed in this workflow.
|
|
59
|
+
- **AC-010** (consent-expired denial) — `changelog.mjs` `checkConsent` reads epoch from line 1 of `commit_consent`; exits 1 on stale; verified in `consent-expired_test.sh`.
|
|
60
|
+
- **AC-011** (TaskList re-seed across session boundary) — `triage/SKILL.md` four task-seeding templates updated to insert `Run /changelog` between `Wait for /grant-commit` and `Run /commit`; `harness/SKILL.md` state-machine table grew a row for the new gap.
|
|
61
|
+
- **AC-012** (ad-hoc `--preview-only` mode) — `changelog.mjs` `runPreviewMode` calls semantic-release JS API with `dryRun:true`; no consent required; verified in `preview-only_test.sh`.
|
|
62
|
+
- **AC-013** (`@semantic-release/changelog` preserves Unreleased OR fallback re-inserts) — `unreleased-writer.mjs` `reinsertUnreleasedHeading` export; verified in `keepachangelog-unreleased-preserved_test.mjs` (test 1 documents the plugin behavior empirically; test 2 confirms the fallback restores canonical structure).
|
|
63
|
+
|
|
64
|
+
Design call rows from the spec:
|
|
65
|
+
|
|
66
|
+
- **`architecture-svg-bento-grid-responsive`** (design lane) — completed at design-ui row 0; site-src/index.njk + site-src/assets/site.css written; audit 20/20 PASS.
|
|
67
|
+
- **`site-narrative-new-phase-mention`** (copy lane) — Stage 0 misroute to `/document` Phase 10; state checkpoint at row1.json; `/document` reads the misroute terminal and routes the three target files through `Skill(prose)` with mandatory humanizer pass.
|
|
68
|
+
|
|
69
|
+
Phase 11.5 introduces gate-adjacent automation between gate C `/grant-commit` (Article IV phase 11 gate) and the `/commit` skill body. No new gate (no gate D); the existing `commit_consent` token authorizes both.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phase 11.5 Changelog actuator.
|
|
3
|
+
//
|
|
4
|
+
// CLI:
|
|
5
|
+
// node changelog.mjs --slug <slug> [--project-root <path>]
|
|
6
|
+
// node changelog.mjs --preview-only --slug <slug> [--project-root <path>]
|
|
7
|
+
//
|
|
8
|
+
// Active mode: verifies commit_consent freshness, classifies new commits,
|
|
9
|
+
// appends keepachangelog entries under ## [Unreleased] in CHANGELOG.md,
|
|
10
|
+
// writes ChangelogState to .claude/state/changelog/<slug>.json.
|
|
11
|
+
//
|
|
12
|
+
// Preview mode: prints projected next version + draft fragment; no writes.
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { parseArgs } from 'node:util';
|
|
18
|
+
import { classify } from './classifier.mjs';
|
|
19
|
+
import { previewProjectedVersion } from './version-preview.mjs';
|
|
20
|
+
import { writeState } from './state-writer.mjs';
|
|
21
|
+
import { appendUnderUnreleased } from './unreleased-writer.mjs';
|
|
22
|
+
|
|
23
|
+
const TTL_SECONDS = 300;
|
|
24
|
+
|
|
25
|
+
function parseCli() {
|
|
26
|
+
const { values } = parseArgs({
|
|
27
|
+
options: {
|
|
28
|
+
slug: { type: 'string' },
|
|
29
|
+
'preview-only': { type: 'boolean', default: false },
|
|
30
|
+
'project-root': { type: 'string', default: '.' },
|
|
31
|
+
},
|
|
32
|
+
strict: true,
|
|
33
|
+
});
|
|
34
|
+
if (!values.slug) {
|
|
35
|
+
process.stderr.write('error: --slug required\n');
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
slug: values.slug,
|
|
40
|
+
previewOnly: values['preview-only'],
|
|
41
|
+
projectRoot: resolve(values['project-root']),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function checkConsent(projectRoot) {
|
|
46
|
+
const path = join(projectRoot, '.claude/state/commit_consent');
|
|
47
|
+
if (!existsSync(path)) {
|
|
48
|
+
return { ok: false, reason: 'consent absent (no commit_consent token)' };
|
|
49
|
+
}
|
|
50
|
+
// Token contract: line 1 is the unix epoch when /grant-commit was issued.
|
|
51
|
+
// Reading the epoch (not filesystem mtime) keeps the freshness check
|
|
52
|
+
// consistent with how `/grant-commit` writes the file and with how tests
|
|
53
|
+
// stale the consent via `echo "<old-epoch>" > commit_consent`.
|
|
54
|
+
const firstLine = readFileSync(path, 'utf8').split('\n', 1)[0].trim();
|
|
55
|
+
const tokenEpoch = parseInt(firstLine, 10);
|
|
56
|
+
if (!Number.isFinite(tokenEpoch)) {
|
|
57
|
+
return { ok: false, reason: 'consent malformed (line 1 not an epoch)' };
|
|
58
|
+
}
|
|
59
|
+
const ageSeconds = Math.floor(Date.now() / 1000) - tokenEpoch;
|
|
60
|
+
if (ageSeconds > TTL_SECONDS) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
reason: `consent expired (${ageSeconds}s > ${TTL_SECONDS}s)`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getHeadSha(projectRoot) {
|
|
70
|
+
try {
|
|
71
|
+
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
72
|
+
cwd: projectRoot, encoding: 'utf8',
|
|
73
|
+
}).trim();
|
|
74
|
+
} catch {
|
|
75
|
+
return 'unknown';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildEntry(commit) {
|
|
80
|
+
const cls = classify(commit);
|
|
81
|
+
if (!cls) return null;
|
|
82
|
+
return {
|
|
83
|
+
section: cls.section,
|
|
84
|
+
body: commit.subject,
|
|
85
|
+
conventional_type: commit.type,
|
|
86
|
+
conventional_scope: commit.scope,
|
|
87
|
+
breaking: cls.breaking,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderPreviewFragment(projection) {
|
|
92
|
+
const lines = [];
|
|
93
|
+
lines.push(`Projected: ${projection.version} (${projection.type || 'no release'})`);
|
|
94
|
+
lines.push(`Commits analyzed: ${projection.commits.length}`);
|
|
95
|
+
if (projection.commits.length > 0) {
|
|
96
|
+
lines.push('');
|
|
97
|
+
lines.push('Draft fragment under ## [Unreleased]:');
|
|
98
|
+
const entries = projection.commits.map(buildEntry).filter(Boolean);
|
|
99
|
+
if (entries.length === 0) {
|
|
100
|
+
lines.push('(no commits map to keepachangelog sections)');
|
|
101
|
+
} else {
|
|
102
|
+
const grouped = new Map();
|
|
103
|
+
for (const e of entries) {
|
|
104
|
+
if (!grouped.has(e.section)) grouped.set(e.section, []);
|
|
105
|
+
grouped.get(e.section).push(e);
|
|
106
|
+
}
|
|
107
|
+
for (const [section, items] of grouped) {
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push(`### ${section}`);
|
|
110
|
+
for (const item of items) {
|
|
111
|
+
const prefix = item.breaking ? '**BREAKING:** ' : '';
|
|
112
|
+
lines.push(`- ${prefix}${item.body}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return lines.join('\n') + '\n';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function runPreviewMode({ projectRoot }) {
|
|
121
|
+
const projection = await previewProjectedVersion(projectRoot);
|
|
122
|
+
process.stdout.write(renderPreviewFragment(projection));
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runActiveMode({ slug, projectRoot }) {
|
|
127
|
+
const consent = checkConsent(projectRoot);
|
|
128
|
+
if (!consent.ok) {
|
|
129
|
+
process.stderr.write(`error: ${consent.reason}\n`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
const projection = await previewProjectedVersion(projectRoot);
|
|
133
|
+
const entries = projection.commits.map(buildEntry).filter(Boolean);
|
|
134
|
+
const changelogPath = join(projectRoot, 'CHANGELOG.md');
|
|
135
|
+
await appendUnderUnreleased(changelogPath, entries);
|
|
136
|
+
const state = {
|
|
137
|
+
slug,
|
|
138
|
+
source_commit_sha: getHeadSha(projectRoot),
|
|
139
|
+
projected_version: projection.version,
|
|
140
|
+
projected_type: projection.type,
|
|
141
|
+
entries,
|
|
142
|
+
generated_at: new Date().toISOString(),
|
|
143
|
+
unreleased_inserted_at: new Date().toISOString(),
|
|
144
|
+
};
|
|
145
|
+
await writeState(projectRoot, slug, state);
|
|
146
|
+
process.stdout.write(
|
|
147
|
+
`changelog: wrote ${entries.length} entries to ${changelogPath} (projected ${projection.version})\n`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function main() {
|
|
152
|
+
const cli = parseCli();
|
|
153
|
+
if (cli.previewOnly) {
|
|
154
|
+
await runPreviewMode(cli);
|
|
155
|
+
} else {
|
|
156
|
+
await runActiveMode(cli);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
main().catch((err) => {
|
|
161
|
+
process.stderr.write(`error: ${err.message}\n`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
});
|