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
package/src/project.js ADDED
@@ -0,0 +1,217 @@
1
+ // Creates a project from the template, and updates a project to the template of this package.
2
+ import { execFileSync, spawnSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { UsageError, collectAnswers, compareVersions, readAnswers, writeAnswers } from './answers.js';
8
+ import { LEGACY_ANSWERS_FILE, MIGRATIONS, SEEDED, TASKS, messageAfterCreate } from './config.js';
9
+ import { renderTemplate } from './render.js';
10
+
11
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
12
+ export const TEMPLATE_DIR = join(ROOT, 'template');
13
+ export const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
14
+
15
+ const OWNED_LIST = '.template/owned.txt';
16
+ const MANIFEST = '.template/owned.sha256';
17
+ const START = '<!-- project:start -->';
18
+ const END = '<!-- project:end -->';
19
+
20
+ function writeFile(dir, rel, { content, mode }) {
21
+ const target = join(dir, rel);
22
+ mkdirSync(dirname(target), { recursive: true });
23
+ writeFileSync(target, content);
24
+ chmodSync(target, mode & 0o777);
25
+ }
26
+
27
+ function run(dir, command, quiet) {
28
+ const r = spawnSync('bash', ['-c', command], {
29
+ cwd: dir,
30
+ stdio: ['ignore', quiet ? 'pipe' : 'inherit', quiet ? 'pipe' : 'inherit'],
31
+ encoding: 'utf8',
32
+ });
33
+ if (r.status !== 0) {
34
+ const output = quiet ? `\n${r.stdout ?? ''}${r.stderr ?? ''}` : '';
35
+ throw new Error(`\`${command}\` failed with exit code ${r.status}${output}`);
36
+ }
37
+ }
38
+
39
+ function runTasks(dir, quiet) {
40
+ for (const task of TASKS) run(dir, task, quiet);
41
+ }
42
+
43
+ function listed(text) {
44
+ return text
45
+ .split('\n')
46
+ .map((l) => l.trim())
47
+ .filter((l) => l && !l.startsWith('#'));
48
+ }
49
+
50
+ /** Lines of AGENTS.md inside its project block; throws unless there is exactly one. */
51
+ function projectBlock(text) {
52
+ const lines = text.split('\n');
53
+ const s = lines.filter((l) => l === START).length;
54
+ const e = lines.filter((l) => l === END).length;
55
+ const si = lines.indexOf(START);
56
+ const ei = lines.indexOf(END);
57
+ if (s !== 1 || e !== 1 || si > ei) {
58
+ throw new UsageError(`AGENTS.md needs exactly one '${START}' line followed by one '${END}' line`);
59
+ }
60
+ return lines.slice(si + 1, ei);
61
+ }
62
+
63
+ function withBlock(rendered, block) {
64
+ const lines = rendered.split('\n');
65
+ const si = lines.indexOf(START);
66
+ const ei = lines.indexOf(END);
67
+ return [...lines.slice(0, si + 1), ...block, ...lines.slice(ei)].join('\n');
68
+ }
69
+
70
+ /** The content that the manifest hash covers, as scripts/check-template.sh computes it. */
71
+ function hashed(rel, text) {
72
+ if (rel !== 'AGENTS.md') return text;
73
+ let skip = false;
74
+ const kept = [];
75
+ for (const line of text.split('\n')) {
76
+ if (line === START) {
77
+ kept.push(line);
78
+ skip = true;
79
+ } else if (line === END) {
80
+ skip = false;
81
+ kept.push(line);
82
+ } else if (!skip) kept.push(line);
83
+ }
84
+ return kept.join('\n');
85
+ }
86
+
87
+ const sha256 = (text) => createHash('sha256').update(text).digest('hex');
88
+
89
+ /** Template-owned files whose content no longer matches the recorded manifest. */
90
+ function editedOwnedFiles(dir) {
91
+ const manifest = join(dir, MANIFEST);
92
+ if (!existsSync(manifest)) return [];
93
+ const edited = [];
94
+ for (const line of listed(readFileSync(manifest, 'utf8'))) {
95
+ const [hash, rel] = line.split(/\s+/, 2);
96
+ const file = join(dir, rel);
97
+ if (existsSync(file) && sha256(hashed(rel, readFileSync(file, 'utf8'))) !== hash) edited.push(rel);
98
+ }
99
+ return edited;
100
+ }
101
+
102
+ function isDirty(dir) {
103
+ try {
104
+ return execFileSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() !== '';
105
+ } catch {
106
+ return false; // not a Git repository
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Creates a project in `dir`.
112
+ * @param {string} dir
113
+ * @param {{ given?: Record<string, unknown>, useDefaults?: boolean, interactive?: boolean,
114
+ * force?: boolean, quiet?: boolean, log?: (msg: string) => void }} options
115
+ * @returns {Promise<{ answers: Record<string, unknown>, message: string }>}
116
+ */
117
+ export async function create(dir, { given, useDefaults, interactive, force = false, quiet = false, git = true, log = () => {} }) {
118
+ const empty = !existsSync(dir) || readdirSync(dir).every((n) => n === '.git');
119
+ if (!empty && !force) {
120
+ throw new UsageError(`${dir} is not empty; choose an empty directory, or pass --force to write into it`);
121
+ }
122
+ const { answers, context } = await collectAnswers({ given, useDefaults, interactive });
123
+ mkdirSync(dir, { recursive: true });
124
+ for (const [rel, file] of renderTemplate(TEMPLATE_DIR, context)) writeFile(dir, rel, file);
125
+ writeAnswers(dir, VERSION, answers);
126
+ log('Writing generated files');
127
+ runTasks(dir, quiet);
128
+ let commit = { done: false, reason: 'skipped with --no-git' };
129
+ if (git) {
130
+ log('Making the initial commit');
131
+ commit = empty ? initialCommit(dir) : { done: false, reason: 'the directory was not empty' };
132
+ }
133
+ return { answers, commit, message: messageAfterCreate(answers, commit) };
134
+ }
135
+
136
+ /**
137
+ * Commits the generated files as the first commit on `main`: the one direct commit to `main` that
138
+ * docs/contributing.md, section 1, allows. Runs only in a new repository, or in one without
139
+ * commits (an empty clone); never throws.
140
+ * @param {string} dir
141
+ * @returns {{ done: boolean, reason?: string }}
142
+ */
143
+ export function initialCommit(dir) {
144
+ const git = (...args) => spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
145
+ const failed = (r) => r.error || r.status !== 0;
146
+ const why = (r) => (r.error ? r.error.message : (r.stderr || r.stdout || '').trim().split('\n')[0]);
147
+ if (existsSync(join(dir, '.git'))) {
148
+ if (!failed(git('rev-parse', '--verify', '--quiet', 'HEAD'))) return { done: false, reason: 'the repository already has commits' };
149
+ const r = git('symbolic-ref', 'HEAD', 'refs/heads/main');
150
+ if (failed(r)) return { done: false, reason: why(r) };
151
+ } else {
152
+ const r = git('init', '--quiet', '--initial-branch=main');
153
+ if (failed(r)) return { done: false, reason: `git init failed: ${why(r)}` };
154
+ }
155
+ for (const args of [['add', '--all'], ['commit', '--quiet', '-m', `Template: create the project with create-afe-project ${VERSION}`]]) {
156
+ const r = git(...args);
157
+ if (failed(r)) return { done: false, reason: `git ${args[0]} failed: ${why(r)}` };
158
+ }
159
+ return { done: true };
160
+ }
161
+
162
+ /**
163
+ * Updates the project in `dir` to this package's template. Template-owned files are replaced, the
164
+ * project block of AGENTS.md is kept, files the template no longer owns are removed, missing
165
+ * seeded files are created and existing ones never changed, the migrations between the two
166
+ * versions run, and the generated files are rebuilt.
167
+ * @param {string} dir
168
+ * @param {{ given?: Record<string, unknown>, useDefaults?: boolean, interactive?: boolean,
169
+ * allowDirty?: boolean, quiet?: boolean }} options
170
+ */
171
+ export async function update(dir, { given, useDefaults, interactive, allowDirty = false, quiet = false }) {
172
+ const recorded = readAnswers(dir);
173
+ if (!recorded) throw new UsageError(`${dir} has no answers file; is it a project created by create-afe-project?`);
174
+ if (compareVersions(recorded.version, VERSION) > 0) {
175
+ throw new UsageError(`the project follows template ${recorded.version}, newer than this package (${VERSION}); run npx create-afe-project@latest update`);
176
+ }
177
+ if (!allowDirty && isDirty(dir)) {
178
+ throw new UsageError('the working tree has uncommitted changes; commit or stash them, or pass --allow-dirty');
179
+ }
180
+ const agentsFile = join(dir, 'AGENTS.md');
181
+ const block = existsSync(agentsFile) ? projectBlock(readFileSync(agentsFile, 'utf8')) : null;
182
+
183
+ const { answers, context } = await collectAnswers({ given, previous: recorded.answers, useDefaults, interactive });
184
+ const rendered = renderTemplate(TEMPLATE_DIR, context);
185
+ const newOwned = listed(String(rendered.get(OWNED_LIST).content));
186
+ const oldOwnedFile = join(dir, OWNED_LIST);
187
+ const oldOwned = existsSync(oldOwnedFile) ? listed(readFileSync(oldOwnedFile, 'utf8')) : [];
188
+ const edited = editedOwnedFiles(dir);
189
+ const pending = MIGRATIONS.filter(
190
+ (m) => compareVersions(m.version, recorded.version) > 0 && compareVersions(m.version, VERSION) <= 0,
191
+ );
192
+
193
+ for (const m of pending) if (m.before) run(dir, m.before, quiet);
194
+
195
+ const removed = oldOwned.filter((rel) => !newOwned.includes(rel) && existsSync(join(dir, rel)));
196
+ for (const rel of removed) rmSync(join(dir, rel));
197
+
198
+ const seeded = [];
199
+ const unowned = [];
200
+ for (const [rel, file] of rendered) {
201
+ const exists = existsSync(join(dir, rel));
202
+ if (newOwned.includes(rel)) {
203
+ const content = rel === 'AGENTS.md' && block ? withBlock(String(file.content), block) : file.content;
204
+ writeFile(dir, rel, { ...file, content });
205
+ } else if (!exists) {
206
+ writeFile(dir, rel, file);
207
+ (SEEDED.includes(rel) ? seeded : unowned).push(rel);
208
+ }
209
+ }
210
+ writeAnswers(dir, VERSION, answers);
211
+ if (recorded.legacy) rmSync(join(dir, LEGACY_ANSWERS_FILE));
212
+
213
+ for (const m of pending) if (m.after) run(dir, m.after, quiet);
214
+ runTasks(dir, quiet);
215
+
216
+ return { from: recorded.version, to: VERSION, removed, edited, seeded, unowned, migrations: pending.map((m) => m.version), legacy: recorded.legacy };
217
+ }
package/src/render.js ADDED
@@ -0,0 +1,51 @@
1
+ // Renders template/ with the answers: Jinja syntax through Nunjucks, as Copier did. A file whose
2
+ // name ends in .jinja is rendered and loses the suffix; any other file is copied as it is. Every
3
+ // path segment is rendered too, and a segment that renders empty drops the file or directory, so
4
+ // a module's files use a conditional name such as `{% if code_review %}review-code{% endif %}`.
5
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import nunjucks from 'nunjucks';
8
+ import { RENAMES } from './config.js';
9
+
10
+ const SUFFIX = '.jinja';
11
+ const env = new nunjucks.Environment(null, { autoescape: false, throwOnUndefined: true });
12
+
13
+ /**
14
+ * @param {string} text
15
+ * @param {Record<string, unknown>} context
16
+ * @returns {string}
17
+ */
18
+ export function renderString(text, context) {
19
+ return env.renderString(text, context);
20
+ }
21
+
22
+ /**
23
+ * Renders every file of the template directory.
24
+ * @param {string} templateDir
25
+ * @param {Record<string, unknown>} context
26
+ * @returns {Map<string, { content: Buffer | string, mode: number }>} by relative output path
27
+ */
28
+ export function renderTemplate(templateDir, context) {
29
+ const out = new Map();
30
+ const walk = (srcDir, outPrefix) => {
31
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
32
+ const src = join(srcDir, entry.name);
33
+ const templated = entry.isFile() && entry.name.endsWith(SUFFIX);
34
+ let name = renderString(templated ? entry.name.slice(0, -SUFFIX.length) : entry.name, context);
35
+ if (!name) continue;
36
+ if (entry.isDirectory()) {
37
+ walk(src, outPrefix ? `${outPrefix}/${name}` : name);
38
+ continue;
39
+ }
40
+ const { mode } = statSync(src);
41
+ let content = readFileSync(src);
42
+ if (templated) {
43
+ content = renderString(content.toString('utf8'), context);
44
+ }
45
+ name = RENAMES[name] ?? name;
46
+ out.set(outPrefix ? `${outPrefix}/${name}` : name, { content, mode });
47
+ }
48
+ };
49
+ walk(templateDir, '');
50
+ return out;
51
+ }
@@ -0,0 +1,50 @@
1
+ # Harness settings of this project (docs/contributing.md, section 3; docs/testing-rules.md,
2
+ # section 3): one KEY=value per line. Scripts read the values and never run this file; an empty
3
+ # value leaves a setting unset. This file belongs to the project; a change to it is class gate.
4
+
5
+ # Environment commands, run by scripts/env.sh setup|fast|e2e|reset (check is scripts/check.sh):
6
+ # set up a working environment, a fast check, end-to-end tests, return to a known-good state.
7
+ # Once set to something other than scripts/check-docs.sh, CHECK_FAST also runs at each Stop of
8
+ # Claude Code that follows a change to a file other than Markdown (.claude/hooks/stop-check-fast.sh);
9
+ # Claude Code stops a hook after 300 seconds without blocking, so keep it well within that.
10
+ SETUP=
11
+ CHECK_FAST=scripts/check-docs.sh
12
+ E2E=
13
+ RESET=
14
+
15
+ # The check after every edit (.claude/hooks/post-edit-check.sh): a command that gets the changed
16
+ # file as its last argument and takes seconds, such as a formatter check and the linter on that
17
+ # file; and the files it applies to, as shell globs separated by spaces (empty: every file).
18
+ # Empty CHECK_EDIT: no check after edits.
19
+ CHECK_EDIT=
20
+ CHECK_EDIT_GLOBS=
21
+
22
+ # Ralph loop: the most runs of scripts/check.sh before the agent stops and reports the failure.
23
+ RALPH_MAX_ITERATIONS=5
24
+
25
+ # Santa loop (the dual-review skill): the most review rounds for a change of class high before
26
+ # the open blockers go to the owner; 3 to 5.
27
+ REVIEW_MAX_ROUNDS=3
28
+
29
+ # A warning, not a failure, when a pull request changes more lines than this, generated files
30
+ # left out (docs/contributing.md, section 1). Empty: no warning.
31
+ DIFF_WARN_LINES=400
32
+
33
+ # Identifiers that requirements, criteria, and the specifications of checks define, as an
34
+ # extended regular expression. The "Acceptance and test plan" section and the tests carry them.
35
+ ID_PATTERN=(REQ|AC|INV)-[0-9]+
36
+
37
+ # Test files, as shell globs separated by spaces, for example `tests/* src/*_test.go`. Empty: the
38
+ # Acceptance check and the fail-without-change driver skip.
39
+ TEST_GLOBS=
40
+
41
+ # fail-without-change: the files besides the tests to copy onto the base, such as test helpers
42
+ # and runner configuration (shell globs); the command that installs dependencies there (empty:
43
+ # SETUP); the test
44
+ # command, which the driver runs with one test file as its last argument; and an optional
45
+ # command that receives the log file and the test file and exits 0 when the base failed for the
46
+ # expected reason.
47
+ FWC_TOOLCHAIN_GLOBS=
48
+ FWC_INSTALL=
49
+ FWC_TEST=
50
+ FWC_CLASSIFIER=
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ # PostToolUse hook (Edit, Write, MultiEdit): the check after every edit (docs/testing-rules.md,
3
+ # section 4). It runs the project's fast per-file check, CHECK_EDIT of .claude/harness.conf, with
4
+ # the changed file as its last argument, and sends a failure back to the agent in the same
5
+ # session (exit 2), before the Stop hooks and CI. CHECK_EDIT_GLOBS (shell globs separated by
6
+ # spaces, * also matches /) limits it to matching files; empty: every file. A relative path is
7
+ # resolved against the input's cwd. An empty CHECK_EDIT, a file outside the project, or a deleted
8
+ # file runs nothing. Keep the command to seconds: it runs after every edit.
9
+ set -uo pipefail
10
+ root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
11
+ [ -n "$root" ] || exit 0
12
+ cd "$root" || exit 0
13
+ . scripts/lib/harness-conf.sh 2>/dev/null || exit 0
14
+ cmd=$(conf_get CHECK_EDIT)
15
+ [ -n "$cmd" ] || exit 0
16
+ { IFS= read -r -d '' file; IFS= read -r -d '' cwd; } < <(node scripts/lib/json.mjs fields tool_input.file_path cwd 2>/dev/null)
17
+ [ -n "${file:-}" ] || exit 0
18
+ # A relative path is relative to the session's working directory, which the input names.
19
+ case "$file" in /*) ;; *) file="${cwd:-$root}/$file" ;; esac
20
+ [ -f "$file" ] || exit 0
21
+ root=$(pwd -P)
22
+ abs=$(cd "$(dirname "$file")" 2>/dev/null && printf '%s/%s' "$(pwd -P)" "$(basename "$file")") || exit 0
23
+ case "$abs" in "$root"/*) rel=${abs#"$root"/} ;; *) exit 0 ;; esac
24
+
25
+ globs=$(conf_get CHECK_EDIT_GLOBS)
26
+ if [ -n "$globs" ]; then
27
+ match=
28
+ set -f
29
+ for g in $globs; do
30
+ # shellcheck disable=SC2254
31
+ case "$rel" in $g) match=1; break ;; esac
32
+ done
33
+ set +f
34
+ [ -n "$match" ] || exit 0
35
+ fi
36
+
37
+ out=$(bash -c "$cmd \"\$1\"" check-edit "$rel" 2>&1)
38
+ code=$?
39
+ [ "$code" -eq 0 ] && exit 0
40
+ printf 'CHECK_EDIT failed on %s (exit %s); fix it before you go on:\n%s\n' "$rel" "$code" "$(printf '%s\n' "$out" | tail -n 40)" >&2
41
+ exit 2