create-afe-project 0.0.0-stage → 2.0.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 (61) hide show
  1. package/CHANGELOG.md +458 -0
  2. package/LICENSE +21 -0
  3. package/README.md +77 -2
  4. package/bin/create-afe-project.js +123 -0
  5. package/package.json +43 -4
  6. package/src/answers.js +147 -0
  7. package/src/config.js +151 -0
  8. package/src/project.js +217 -0
  9. package/src/render.js +51 -0
  10. package/template/.claude/harness.conf +50 -0
  11. package/template/.claude/hooks/post-edit-check.sh +41 -0
  12. package/template/.claude/hooks/protect-docs.sh +315 -0
  13. package/template/.claude/hooks/session-start.sh +81 -0
  14. package/template/.claude/hooks/stop-check-decisions.sh +25 -0
  15. package/template/.claude/hooks/stop-check-docs.sh +29 -0
  16. package/template/.claude/hooks/stop-check-fast.sh +33 -0
  17. package/template/.claude/hooks/stop-check-template.sh +23 -0
  18. package/template/.claude/hooks/stop-run-project-hooks.sh +27 -0
  19. package/template/.claude/path-classes +102 -0
  20. package/template/.claude/settings.project.json +5 -0
  21. package/template/.claude/settings.template.json +92 -0
  22. package/template/.claude/skills/afe-init/SKILL.md +104 -0
  23. package/template/.claude/skills/create-pr/SKILL.md.jinja +110 -0
  24. package/template/.claude/skills/update-template/SKILL.md +40 -0
  25. package/template/.claude/skills/{% if code_review %}review-code{% endif %}/SKILL.md.jinja +50 -0
  26. package/template/.claude/skills/{% if docs_review %}docs-review{% endif %}/SKILL.md +60 -0
  27. package/template/.claude/skills/{% if dual_review %}dual-review{% endif %}/SKILL.md +58 -0
  28. package/template/.github/workflows/decisions.yml.jinja +57 -0
  29. package/template/.github/workflows/docs.yml.jinja +40 -0
  30. package/template/.github/workflows/{% if code_review %}code-review.yml{% endif %}.jinja +54 -0
  31. package/template/.github/workflows/{% if docs_review %}docs-review.yml{% endif %}.jinja +52 -0
  32. package/template/.github/workflows/{% if fail_without_change %}fail-without-change.yml{% endif %}.jinja +42 -0
  33. package/template/.github/workflows/{% if secret_scan %}secret-scan.yml{% endif %}.jinja +47 -0
  34. package/template/.markdownlint-cli2.jsonc +27 -0
  35. package/template/.template/owned.txt.jinja +65 -0
  36. package/template/AGENTS.md.jinja +91 -0
  37. package/template/CLAUDE.md +3 -0
  38. package/template/README.md.jinja +53 -0
  39. package/template/_gitignore +14 -0
  40. package/template/docs/README.md.jinja +44 -0
  41. package/template/docs/contributing.md.jinja +206 -0
  42. package/template/docs/decisions/adr-template.md +38 -0
  43. package/template/docs/documentation-rules.md.jinja +200 -0
  44. package/template/docs/testing-rules.md +80 -0
  45. package/template/scripts/check-decisions.sh +382 -0
  46. package/template/scripts/check-docs.sh +20 -0
  47. package/template/scripts/check-links.mjs +142 -0
  48. package/template/scripts/check-project.sh +27 -0
  49. package/template/scripts/check-template.sh +88 -0
  50. package/template/scripts/check.sh +21 -0
  51. package/template/scripts/env.sh +29 -0
  52. package/template/scripts/gen-decisions-index.sh +57 -0
  53. package/template/scripts/gen-settings.sh +14 -0
  54. package/template/scripts/lib/classes.sh +89 -0
  55. package/template/scripts/lib/git-base.sh +25 -0
  56. package/template/scripts/lib/harness-conf.sh +48 -0
  57. package/template/scripts/lib/json.mjs +160 -0
  58. package/template/scripts/pr-metrics.sh +80 -0
  59. package/template/scripts/protect-branch.sh +101 -0
  60. package/template/scripts/{% if acceptance_check %}check-acceptance.sh{% endif %} +47 -0
  61. package/template/scripts/{% if fail_without_change %}fail-without-change.sh{% endif %} +88 -0
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ // Command line of create-afe-project: create a project, or update one to this package's template.
3
+ import { readFileSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { parseArgs } from 'node:util';
6
+ import * as p from '@clack/prompts';
7
+ import { UsageError } from '../src/answers.js';
8
+ import { VERSION, create, update } from '../src/project.js';
9
+
10
+ const HELP = `create-afe-project ${VERSION}
11
+
12
+ Usage:
13
+ npm create afe-project@latest [directory] [-- options]
14
+ npx create-afe-project@latest [directory] [options] create a project
15
+ npx create-afe-project@latest update [options] update the project in the current directory
16
+
17
+ Options:
18
+ --defaults do not ask: take --data, then the recorded answers, then the defaults
19
+ --data key=value answer a question; repeatable
20
+ --data-file <file> answers from a JSON file; --data wins over it
21
+ --force create: write into a directory that is not empty
22
+ --no-git create: do not make the initial Git commit on main
23
+ --allow-dirty update: run with uncommitted changes
24
+ --quiet print only errors and the result
25
+ -h, --help print this help
26
+ -v, --version print the version
27
+ `;
28
+
29
+ function given(values) {
30
+ const data = values['data-file'] ? JSON.parse(readFileSync(values['data-file'], 'utf8')) : {};
31
+ for (const pair of values.data ?? []) {
32
+ const i = pair.indexOf('=');
33
+ if (i < 1) throw new UsageError(`--data expects key=value, got ${pair}`);
34
+ data[pair.slice(0, i)] = pair.slice(i + 1);
35
+ }
36
+ return Object.fromEntries(Object.entries(data).filter(([k]) => !k.startsWith('_')));
37
+ }
38
+
39
+ async function main() {
40
+ const { values, positionals } = parseArgs({
41
+ allowPositionals: true,
42
+ options: {
43
+ defaults: { type: 'boolean' },
44
+ data: { type: 'string', multiple: true },
45
+ 'data-file': { type: 'string' },
46
+ force: { type: 'boolean' },
47
+ 'no-git': { type: 'boolean' },
48
+ 'allow-dirty': { type: 'boolean' },
49
+ quiet: { type: 'boolean' },
50
+ help: { type: 'boolean', short: 'h' },
51
+ version: { type: 'boolean', short: 'v' },
52
+ },
53
+ });
54
+ if (values.help) return void process.stdout.write(HELP);
55
+ if (values.version) return void process.stdout.write(`${VERSION}\n`);
56
+
57
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !values.defaults;
58
+ const quiet = Boolean(values.quiet);
59
+ const common = { given: given(values), useDefaults: Boolean(values.defaults), interactive, quiet };
60
+ if (interactive) p.intro(`create-afe-project ${VERSION}`);
61
+
62
+ if (positionals[0] === 'update') {
63
+ if (positionals.length > 1) throw new UsageError('update takes no directory; run it in the project');
64
+ const r = await update(process.cwd(), { ...common, allowDirty: Boolean(values['allow-dirty']) });
65
+ const lines = [`Updated the template from ${r.from ?? 'an unrecorded version'} to ${r.to}.`];
66
+ if (r.legacy) lines.push('Converted .copier-answers.yml to .afe-answers.json.');
67
+ if (r.migrations.length) lines.push(`Migrations run: ${r.migrations.join(', ')}.`);
68
+ if (r.removed.length) lines.push(`Removed, no longer template-owned: ${r.removed.join(', ')}.`);
69
+ if (r.seeded.length) lines.push(`Created missing seeded files: ${r.seeded.join(', ')}.`);
70
+ if (r.unowned.length) lines.push(`Created files without an owner class (a template defect): ${r.unowned.join(', ')}.`);
71
+ if (r.edited.length) {
72
+ lines.push(`Replaced local edits of template-owned files; review them in git diff: ${r.edited.join(', ')}.`);
73
+ }
74
+ lines.push('Next: scripts/check.sh, then a pull request (docs/contributing.md, section 5).');
75
+ return void (interactive ? p.outro(lines.join('\n')) : console.log(lines.join('\n')));
76
+ }
77
+
78
+ if (positionals.length > 1) throw new UsageError('expected at most one directory');
79
+ let dir = positionals[0];
80
+ if (!dir) {
81
+ if (!interactive) throw new UsageError('name the directory to create the project in');
82
+ dir = await p.text({ message: 'Where should the project go? (a new or empty directory)', placeholder: 'my-project', validate: (v) => (v?.trim() ? undefined : 'Required') });
83
+ if (p.isCancel(dir)) {
84
+ p.cancel('Cancelled; nothing was written.');
85
+ process.exit(130);
86
+ }
87
+ }
88
+ if (interactive) {
89
+ p.note(
90
+ 'A few questions about the project, then yes/no questions that turn optional checks on.\n' +
91
+ 'Press Enter to keep a default. To change an answer later, run\n' +
92
+ 'npx create-afe-project@latest update in the project.',
93
+ );
94
+ }
95
+ const spinner = interactive && !quiet ? p.spinner() : null;
96
+ let spinning = false;
97
+ const { message } = await create(resolve(dir), {
98
+ ...common,
99
+ force: Boolean(values.force),
100
+ git: !values['no-git'],
101
+ quiet: quiet || interactive,
102
+ // Start the spinner once: a second start() leaves its first timer running, and the process
103
+ // never exits.
104
+ log: (msg) => {
105
+ if (!spinner) return;
106
+ if (spinning) spinner.message(msg);
107
+ else spinner.start(msg);
108
+ spinning = true;
109
+ },
110
+ });
111
+ spinner?.stop('Generated files written');
112
+ if (interactive) p.outro(message);
113
+ else console.log(message);
114
+ }
115
+
116
+ main().catch((error) => {
117
+ if (error instanceof UsageError || error?.code?.startsWith?.('ERR_PARSE_ARGS')) {
118
+ console.error(`create-afe-project: ${error.message}\nRun create-afe-project --help for usage.`);
119
+ process.exit(2);
120
+ }
121
+ console.error(`create-afe-project: ${error.message}`);
122
+ process.exit(1);
123
+ });
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "create-afe-project",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0",
4
+ "description": "Create and update agent-friendly repositories: documentation rules, decision records, checks, and Claude Code hooks and skills.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "create-afe-project": "bin/create-afe-project.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "template/",
14
+ "CHANGELOG.md"
15
+ ],
16
+ "scripts": {
17
+ "test": "node --test test/*.test.js",
18
+ "changeset": "changeset",
19
+ "version-packages": "changeset version && npm install --package-lock-only --ignore-scripts && scripts/sync-self.sh"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/dev-oleksandrv/create-afe-project.git"
27
+ },
28
+ "keywords": [
29
+ "create",
30
+ "scaffold",
31
+ "template",
32
+ "agents",
33
+ "claude-code",
34
+ "afe"
35
+ ],
36
+ "dependencies": {
37
+ "@clack/prompts": "^1.8.1",
38
+ "nunjucks": "^3.2.4",
39
+ "yaml": "^2.9.1"
40
+ },
41
+ "devDependencies": {
42
+ "@changesets/changelog-github": "1.0.1",
43
+ "@changesets/cli": "3.0.3"
44
+ }
45
+ }
package/src/answers.js ADDED
@@ -0,0 +1,147 @@
1
+ // Collects the answers to the template's questions, and reads and writes the answers file that
2
+ // records them with the template version.
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import * as p from '@clack/prompts';
6
+ import { parse as parseYaml } from 'yaml';
7
+ import { ANSWERS_FILE, LEGACY_ANSWERS_FILE, QUESTIONS, computed } from './config.js';
8
+
9
+ export class UsageError extends Error {}
10
+
11
+ /**
12
+ * Turns a `--data` string into the type of its question.
13
+ * @param {string} name
14
+ * @param {unknown} value
15
+ * @returns {unknown}
16
+ */
17
+ export function coerce(name, value) {
18
+ const q = QUESTIONS.find((x) => x.name === name);
19
+ if (!q) throw new UsageError(`unknown question: ${name}`);
20
+ if (q.type === 'bool' && typeof value === 'string') {
21
+ if (/^(true|yes|y|1)$/i.test(value)) return true;
22
+ if (/^(false|no|n|0)$/i.test(value)) return false;
23
+ throw new UsageError(`${name}: expected true or false, got ${value}`);
24
+ }
25
+ if (q.type === 'choice' && !q.choices.some((c) => c.value === value)) {
26
+ throw new UsageError(`${name}: expected one of ${q.choices.map((c) => c.value).join(', ')}`);
27
+ }
28
+ return value;
29
+ }
30
+
31
+ function check(q, value) {
32
+ const error = q.validate?.(String(value));
33
+ if (error) throw new UsageError(`${q.name}: ${error}`);
34
+ }
35
+
36
+ async function ask(q, initial) {
37
+ let value;
38
+ if (q.type === 'bool') {
39
+ value = await p.confirm({ message: q.help, initialValue: initial ?? q.default });
40
+ } else if (q.type === 'choice') {
41
+ value = await p.select({
42
+ message: q.help,
43
+ options: q.choices.map((c) => ({ value: c.value, label: c.label })),
44
+ initialValue: initial ?? q.default,
45
+ });
46
+ } else {
47
+ const start = initial ?? q.default;
48
+ value = await p.text({
49
+ message: q.help,
50
+ initialValue: start,
51
+ placeholder: start,
52
+ validate: (v) => q.validate?.(v ?? ''),
53
+ });
54
+ }
55
+ if (p.isCancel(value)) {
56
+ p.cancel('Cancelled; nothing was written.');
57
+ process.exit(130);
58
+ }
59
+ return value;
60
+ }
61
+
62
+ /**
63
+ * Answers every question in order. A value from `given` is taken without asking; then, with
64
+ * `useDefaults`, the previous answer or the question's default; a question with neither is asked,
65
+ * or fails when `interactive` is false. A question whose `when` is false is not answered.
66
+ * @param {{ given?: Record<string, unknown>, previous?: Record<string, unknown>,
67
+ * useDefaults?: boolean, interactive?: boolean }} options
68
+ * @returns {Promise<{ answers: Record<string, unknown>, context: Record<string, unknown> }>}
69
+ * the answers to record, and the rendering context with every question and computed value
70
+ */
71
+ export async function collectAnswers({ given = {}, previous = {}, useDefaults = false, interactive = true }) {
72
+ for (const name of Object.keys(given)) coerce(name, given[name]);
73
+ const answers = {};
74
+ const context = {};
75
+ for (const q of QUESTIONS) {
76
+ if (q.when && !q.when(context)) {
77
+ context[q.name] = q.default ?? '';
78
+ continue;
79
+ }
80
+ let value;
81
+ if (q.name in given) {
82
+ value = coerce(q.name, given[q.name]);
83
+ } else if (useDefaults && (q.name in previous || 'default' in q)) {
84
+ value = q.name in previous ? previous[q.name] : q.default;
85
+ } else if (interactive) {
86
+ value = await ask(q, previous[q.name]);
87
+ } else {
88
+ throw new UsageError(`${q.name} has no answer; pass --data ${q.name}=<value> or run in a terminal`);
89
+ }
90
+ check(q, value);
91
+ answers[q.name] = value;
92
+ context[q.name] = value;
93
+ }
94
+ return { answers, context: { ...context, ...computed(context) } };
95
+ }
96
+
97
+ /**
98
+ * Reads the recorded answers of a project: the answers file, or a Copier answers file of a project
99
+ * generated before the template moved to npm.
100
+ * @param {string} dir
101
+ * @returns {{ version: string | null, answers: Record<string, unknown>, legacy: boolean } | null}
102
+ */
103
+ export function readAnswers(dir) {
104
+ const file = join(dir, ANSWERS_FILE);
105
+ if (existsSync(file)) {
106
+ const { _version = null, ...rest } = JSON.parse(readFileSync(file, 'utf8'));
107
+ return { version: _version, answers: strip(rest), legacy: false };
108
+ }
109
+ const legacy = join(dir, LEGACY_ANSWERS_FILE);
110
+ if (existsSync(legacy)) {
111
+ const data = parseYaml(readFileSync(legacy, 'utf8')) ?? {};
112
+ const commit = typeof data._commit === 'string' ? data._commit.replace(/^v/, '').split('-')[0] : null;
113
+ return { version: commit, answers: strip(data), legacy: true };
114
+ }
115
+ return null;
116
+ }
117
+
118
+ function strip(data) {
119
+ return Object.fromEntries(Object.entries(data).filter(([k]) => !k.startsWith('_')));
120
+ }
121
+
122
+ /**
123
+ * @param {string} dir
124
+ * @param {string} version
125
+ * @param {Record<string, unknown>} answers
126
+ */
127
+ export function writeAnswers(dir, version, answers) {
128
+ const data = {
129
+ _note: 'Written by create-afe-project; never edit by hand (docs/documentation-rules.md, section 9).',
130
+ _version: version,
131
+ ...answers,
132
+ };
133
+ writeFileSync(join(dir, ANSWERS_FILE), `${JSON.stringify(data, null, 2)}\n`);
134
+ }
135
+
136
+ /**
137
+ * Compares two versions MAJOR.MINOR.PATCH; a missing version is the lowest.
138
+ * @param {string | null} a
139
+ * @param {string | null} b
140
+ * @returns {number}
141
+ */
142
+ export function compareVersions(a, b) {
143
+ const parts = (v) => (v ?? '0.0.0').split('.').map((n) => Number.parseInt(n, 10) || 0);
144
+ const [x, y] = [parts(a), parts(b)];
145
+ for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
146
+ return 0;
147
+ }
package/src/config.js ADDED
@@ -0,0 +1,151 @@
1
+ // The template's questions, file classes, tasks, and migrations: what copier.yml held before the
2
+ // template moved to npm. Maintainers: docs/maintaining.md.
3
+
4
+ export const ANSWERS_FILE = '.afe-answers.json';
5
+ export const LEGACY_ANSWERS_FILE = '.copier-answers.yml';
6
+ export const TEMPLATE_URL = 'https://github.com/dev-oleksandrv/create-afe-project';
7
+
8
+ // Template files whose name differs from the generated one, because npm never publishes the
9
+ // generated name.
10
+ export const RENAMES = { _gitignore: '.gitignore' };
11
+
12
+ // Seeded files: created once, owned by the project afterwards (docs/documentation-rules.md,
13
+ // section 9, in a generated project). An update creates a missing one and never changes one.
14
+ export const SEEDED = [
15
+ 'README.md',
16
+ 'CLAUDE.md',
17
+ '.gitignore',
18
+ 'docs/README.md',
19
+ '.claude/path-classes',
20
+ '.claude/harness.conf',
21
+ '.claude/settings.project.json',
22
+ ];
23
+
24
+ // Generated files: the Claude Code settings, the ownership manifest, and the decision index are
25
+ // rebuilt after every create and update, in this order.
26
+ export const TASKS = [
27
+ 'scripts/gen-settings.sh',
28
+ 'scripts/check-template.sh --write',
29
+ 'scripts/gen-decisions-index.sh > docs/decisions/README.md',
30
+ ];
31
+
32
+ const LABELS = /^\[\s*"[A-Za-z0-9_.:-]+"(\s*,\s*"[A-Za-z0-9_.:-]+")*\s*\]$/;
33
+
34
+ // The wizard, in order. `when` decides whether a question is asked; an unasked question takes no
35
+ // answer. `validate` returns an error message or nothing.
36
+ export const QUESTIONS = [
37
+ {
38
+ name: 'project_name',
39
+ type: 'str',
40
+ help: 'Project name (for example: acme)',
41
+ validate: (v) => (v.trim() ? undefined : 'Required'),
42
+ },
43
+ {
44
+ name: 'project_description',
45
+ type: 'str',
46
+ help: 'What is the project? One sentence, ending with a period',
47
+ validate: (v) => (v.trim().endsWith('.') ? undefined : 'End the sentence with a period'),
48
+ },
49
+ {
50
+ name: 'github_repo',
51
+ type: 'str',
52
+ help: 'GitHub repository (owner/name)',
53
+ validate: (v) => (/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(v) ? undefined : 'Use the form owner/name'),
54
+ },
55
+ {
56
+ name: 'ci_runner',
57
+ type: 'choice',
58
+ help: 'Where should CI run? (the repository variable CI_RUNS_ON can change it later)',
59
+ choices: [
60
+ { label: "On GitHub's servers (ubuntu-latest)", value: 'github-hosted' },
61
+ { label: 'On your own Linux machine (self-hosted runner)', value: 'self-hosted' },
62
+ ],
63
+ default: 'github-hosted',
64
+ },
65
+ {
66
+ name: 'self_hosted_labels',
67
+ type: 'str',
68
+ help: 'Labels of your runner, as a JSON array (for example: ["self-hosted", "linux"])',
69
+ default: '["self-hosted"]',
70
+ when: (a) => a.ci_runner === 'self-hosted',
71
+ validate: (v) =>
72
+ LABELS.test(v) ? undefined : 'Enter a JSON array of quoted labels, such as ["self-hosted", "linux"]',
73
+ },
74
+ {
75
+ name: 'docs_review',
76
+ type: 'bool',
77
+ help: 'Let Claude check every pull request for outdated or broken docs? (needs the Claude GitHub App and the CLAUDE_CODE_OAUTH_TOKEN secret)',
78
+ default: true,
79
+ },
80
+ {
81
+ name: 'code_review',
82
+ type: 'bool',
83
+ help: 'Let Claude review pull requests that change code? (needs the same GitHub App and secret)',
84
+ default: true,
85
+ },
86
+ {
87
+ name: 'dual_review',
88
+ type: 'bool',
89
+ help: 'Have Claude Code review each branch before its pull request opens? (uses the Codex CLI if installed, otherwise Claude)',
90
+ default: false,
91
+ },
92
+ {
93
+ name: 'secret_scan',
94
+ type: 'bool',
95
+ help: 'Scan pull requests and pushes to main for leaked secrets? (gitleaks in CI; nothing to install)',
96
+ default: true,
97
+ },
98
+ {
99
+ name: 'acceptance_check',
100
+ type: 'bool',
101
+ help: 'Require a test for every requirement ID a pull request lists under Acceptance? (you set TEST_GLOBS in .claude/harness.conf later)',
102
+ default: false,
103
+ },
104
+ {
105
+ name: 'fail_without_change',
106
+ type: 'bool',
107
+ help: 'Require every new or changed test to fail without the change? (you set FWC_TEST in .claude/harness.conf later)',
108
+ default: false,
109
+ },
110
+ ];
111
+
112
+ // Values the templates use that are derived from the answers, never asked.
113
+ export function computed(answers) {
114
+ return {
115
+ ci_runs_on_default:
116
+ answers.ci_runner === 'self-hosted' ? answers.self_hosted_labels : '["ubuntu-latest"]',
117
+ template_url: TEMPLATE_URL,
118
+ };
119
+ }
120
+
121
+ export function messageAfterCreate(a, commit = { done: false }) {
122
+ const steps = [];
123
+ steps.push(
124
+ commit.done
125
+ ? 'the generated files are committed on main'
126
+ : `commit the generated files on main (the one direct commit that docs/contributing.md allows; not made here: ${commit.reason ?? 'not asked'})`,
127
+ );
128
+ steps.push('add the remote origin, push, and run git remote set-head origin -a');
129
+ steps.push('protect main and apply the repository settings with scripts/protect-branch.sh (merge commits only, head branches deleted, secret scanning where the plan offers it)');
130
+ steps.push('set up the CI runner');
131
+ if (a.docs_review || a.code_review) steps.push('add the CLAUDE_CODE_OAUTH_TOKEN secret and install the Claude GitHub App');
132
+ if (a.dual_review) steps.push('optionally install and sign in to the Codex CLI');
133
+ steps.push('run scripts/check.sh');
134
+ steps.push('run /afe-init in Claude Code to set up the code and its checks');
135
+ return `${a.project_name} is ready. Next:\n${steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}`;
136
+ }
137
+
138
+ // Migrations of an update, by the version that introduces them. `before` runs before the template
139
+ // files are written, `after` after them; both run in the project directory, as bash commands.
140
+ // A migration runs when its version is above the project's recorded version and at most the
141
+ // package's.
142
+ export const MIGRATIONS = [
143
+ // 0.5.0 replaces the seeded .claude/protected-paths, which the update deletes, with
144
+ // .claude/path-classes: its glob lines are kept as class approval, and the file is removed.
145
+ {
146
+ version: '0.5.0',
147
+ before: '[ ! -f .claude/protected-paths ] || cp .claude/protected-paths .claude/protected-paths.pre-0.5',
148
+ after:
149
+ "[ ! -f .claude/protected-paths.pre-0.5 ] || { grep -v -E '^[[:space:]]*(#|$)' .claude/protected-paths.pre-0.5 | sed 's/^/approval /' >> .claude/path-classes; rm -f .claude/protected-paths.pre-0.5 .claude/protected-paths; }",
150
+ },
151
+ ];