create-agent-rig 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +207 -8
  2. package/README.md +50 -17
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +82 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +38 -14
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +21 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/templates/agent-os/init/AGENTS.md +191 -0
  14. package/templates/agent-os/init/CLAUDE.md +61 -9
  15. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  17. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
  18. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  19. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
  20. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  21. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
  22. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
  23. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  24. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  26. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
  27. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  28. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
  29. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
  30. package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
  31. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
  32. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  33. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  34. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  35. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  36. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  37. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
  38. package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
  39. package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
  40. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  41. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
  42. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  43. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  44. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  45. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
  46. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
  47. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
  48. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
  49. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  50. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
  51. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
  52. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
  53. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
  54. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  55. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  56. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
  57. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
  58. package/templates/agent-os/universal/.claude/settings.json +7 -2
  59. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
  60. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
  61. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
  62. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  63. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  64. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  65. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  66. package/templates/agent-os/universal/.codex/hooks.json +65 -0
  67. package/templates/agent-os/universal/AGENTS.md +164 -0
  68. package/templates/agent-os/universal/CLAUDE.md +47 -14
  69. package/templates/agent-os/universal/PLAN.md +7 -40
  70. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  71. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  72. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  73. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  74. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  75. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  76. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  77. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
  78. package/templates/agent-os/universal/journal/README.md +101 -0
  79. package/templates/agent-os/universal/layers.json +36 -2
  80. package/templates/hash-history.json +2 -1
  81. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  82. package/templates/skeleton/aws-serverless/README.md +91 -9
  83. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  84. package/templates/skeleton/aws-serverless/gitignore +37 -0
  85. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  86. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  87. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  88. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  89. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  90. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  91. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  92. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  93. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  94. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  95. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  96. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  97. package/templates/skeleton/node-service/README.md +11 -1
  98. package/templates/skeleton/node-service/gitignore +34 -0
  99. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  100. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  101. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  102. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  103. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  104. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  105. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -1,11 +1,12 @@
1
1
  import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { initInstallSet, projectNameFor } from './init.js';
4
+ import { hookFilesReferencedIn } from '../lib/init-settings.js';
4
5
  import { loadHashHistory, presentInEveryRelease } from '../lib/history.js';
5
6
  import { agentOsInstallSet, agentOsLayerDirs } from '../lib/install-set.js';
6
7
  import { listTree } from '../lib/copy-tree.js';
7
8
  import { readManifest, sha256, writeManifest } from '../lib/manifest.js';
8
- import { resolveInside } from '../lib/safe-path.js';
9
+ import { isSafeSubstitutionValue, resolveInside } from '../lib/safe-path.js';
9
10
  import { detokenizeContent, substituteFileName } from '../lib/substitute.js';
10
11
  import { TARGETS } from '../lib/targets.js';
11
12
  import { packageVersion } from '../lib/version.js';
@@ -13,6 +14,8 @@ import { packageVersion } from '../lib/version.js';
13
14
  export class UpgradeError extends Error {
14
15
  }
15
16
  const SETTINGS = '.claude/settings.json';
17
+ const CODEX_HOOKS = '.codex/hooks.json';
18
+ const WIRING_PATHS = new Set([SETTINGS, CODEX_HOOKS]);
16
19
  /** The universal layer's architecture group — installed by `create`, never by `init`. */
17
20
  const ARCHITECTURE_ONLY = [
18
21
  '.claude/rules/architecture.md',
@@ -130,6 +133,30 @@ function isReleasedVersion(history, rel, content, ctx) {
130
133
  const candidates = new Set([sha256(content), sha256(detokenizeContent(content, ctx))]);
131
134
  return known.hashes.some((hash) => candidates.has(hash));
132
135
  }
136
+ /**
137
+ * Would writing `next` over `current` stop calling a hook that is still there?
138
+ *
139
+ * The one question the hash arms cannot answer. They prove the bytes belong to
140
+ * the rig; they do not prove the replacement wires the same hooks, and
141
+ * Wiring files have flavours that differ in exactly that. A hook file
142
+ * still on disk with nothing wired to it is the quiet failure
143
+ * `lib/init-settings.ts` names: the rules claim it is enforced and nothing ever
144
+ * calls it.
145
+ *
146
+ * Only hooks whose FILE is still present count. One the user deleted on purpose
147
+ * is not being silenced by this write — it was already gone.
148
+ */
149
+ async function unwiresAnInstalledHook(repoDir, current, next) {
150
+ const nextHooks = hookFilesReferencedIn(next);
151
+ for (const hook of hookFilesReferencedIn(current)) {
152
+ if (nextHooks.has(hook))
153
+ continue;
154
+ const onDisk = resolveInside(repoDir, hook);
155
+ if (onDisk !== null && (await exists(onDisk)))
156
+ return true;
157
+ }
158
+ return false;
159
+ }
133
160
  /**
134
161
  * What an upgrade would do, decided per file, writing nothing.
135
162
  *
@@ -146,11 +173,37 @@ export async function planUpgrade(repoDir, options = {}) {
146
173
  ? await detectInstall(repoDir)
147
174
  : { kind: manifest.kind, stacks: manifest.stacks, region: manifest.project.region };
148
175
  const kind = manifest?.kind ?? detected.kind;
149
- const name = path.basename(path.resolve(repoDir));
150
- // `init` slugs the directory name into something an operator can type (it
151
- // ends up in the kill-switch filename); `create` validated it as an npm name
152
- // at generation time, so there the basename is already the project name.
153
- const bootstrapName = kind === 'init' ? projectNameFor(repoDir) : name;
176
+ // With no manifest to read, guess the name the rig's own files were written
177
+ // with and each command wrote them differently, so the guess branches the
178
+ // same way:
179
+ //
180
+ // - `init` substitutes the **slug** and records the slug (`init.ts`,
181
+ // `projectNameFor` in both places), so for an init rig the slug is not an
182
+ // approximation, it is the value;
183
+ // - `create` substitutes the name it was **given**, having validated it — and
184
+ // that validation accepts a trailing `-` or `.`, which `projectNameFor`
185
+ // strips. So slugging a create rig renames it: `my-app.` became `my-app`,
186
+ // stopped matching its own installed files, and returned four of them as
187
+ // conflicts.
188
+ //
189
+ // The one case where the raw name cannot be kept is a directory the manifest
190
+ // reader would refuse — `My App` produced `{"name":"My App"}`, which
191
+ // `parseManifest` voids, so the manifest this command exists to write was
192
+ // written and immediately unreadable and every later run reported "no
193
+ // manifest here (a pre-0.4.0 rig)". The condition is that reader's own
194
+ // exported predicate, not a second copy of its rule.
195
+ //
196
+ // 🔴 All three branches were bought by a defect, and two of those defects
197
+ // were introduced by fixing the other — the mirror is easy to miss, because
198
+ // each fix looks total until the other kind is tried. Change nothing here
199
+ // without running the three sibling cases in `upgrade.test.ts`.
200
+ //
201
+ // ⚠ It is still the *directory's* name, so a renamed or cloned rig with no
202
+ // manifest bootstraps the new name and its substituted files come back as
203
+ // conflicts — kept and reported, never overwritten. Committing the manifest
204
+ // is what removes the guess, and that is unchanged from 0.4.0.
205
+ const rawName = path.basename(path.resolve(repoDir));
206
+ const bootstrapName = kind === 'init' || !isSafeSubstitutionValue(rawName) ? projectNameFor(repoDir) : rawName;
154
207
  const project = manifest?.project ?? {
155
208
  name: bootstrapName,
156
209
  scope: bootstrapName,
@@ -172,6 +225,7 @@ export async function planUpgrade(repoDir, options = {}) {
172
225
  const contents = new Map();
173
226
  const nextFiles = {};
174
227
  let wiring = null;
228
+ const wiringByPath = new Map();
175
229
  for (const file of files) {
176
230
  const current = await readIfPresent(repoDir, file.rel);
177
231
  const recorded = manifest?.files[file.rel];
@@ -202,36 +256,63 @@ export async function planUpgrade(repoDir, options = {}) {
202
256
  }
203
257
  continue;
204
258
  }
205
- if (file.rel === SETTINGS) {
206
- if (current === file.content) {
207
- actions.push({ rel: file.rel, verdict: 'unchanged' });
208
- nextFiles[file.rel] = sha256(file.content);
209
- }
210
- else {
211
- // Same special case `init` makes: this file is a merge target, not a
212
- // payload replacing it can unwire hooks the user added themselves.
213
- wiring = file.content;
214
- actions.push({
215
- rel: file.rel,
216
- verdict: 'wiring',
217
- reason: 'never replaced merge the entries below by hand',
218
- });
219
- if (recorded !== undefined)
220
- nextFiles[file.rel] = recorded;
221
- }
222
- continue;
223
- }
259
+ // The two limits that keep wiring files' new replaceability from
260
+ // disarming the rig, both measured rather than reasoned about.
261
+ //
262
+ // 1. The released-hash fallback is not enough for THIS file. Every other
263
+ // file has one flavour; this one has two — `create` wires all the hooks,
264
+ // `init` wires only the ones it installs — and they share a history
265
+ // entry. A manifest-less rig that ran `init` is recorded `kind: 'init'`,
266
+ // so matching a released hash would write the narrow wiring over the
267
+ // full one. The item asks for the manifest arm and says the rest is
268
+ // reported, which is also the reading with no regression behind it.
269
+ // 2. Even the manifest arm is not enough on its own, because `kind` is
270
+ // trusted from a file that travels in pull requests. So the decision is
271
+ // gated on the wiring itself: if the replacement would stop calling a
272
+ // hook still sitting in `.claude/hooks/`, it is handed over. That check
273
+ // does not care which flavour anything claims to be.
274
+ const isWiring = WIRING_PATHS.has(file.rel);
275
+ const wouldUnwireAnInstalledHook = isWiring && (await unwiresAnInstalledHook(repoDir, current, file.content));
276
+ const vouched = isWiring
277
+ ? recorded !== undefined && sha256(current) === recorded
278
+ : (recorded !== undefined && sha256(current) === recorded) ||
279
+ isReleasedVersion(history, file.rel, current, ctx);
224
280
  if (current === file.content) {
225
281
  actions.push({ rel: file.rel, verdict: 'unchanged' });
226
282
  nextFiles[file.rel] = sha256(file.content);
227
283
  }
228
- else if (recorded !== undefined && sha256(current) === recorded) {
229
- actions.push({ rel: file.rel, verdict: 'update', templatePath: file.source });
284
+ else if (vouched && !wouldUnwireAnInstalledHook) {
285
+ actions.push({
286
+ rel: file.rel,
287
+ verdict: 'update',
288
+ templatePath: file.source,
289
+ // Every other replacement is routine; this one rewrites what calls the
290
+ // guards, so it says so rather than arriving as one more `~` line.
291
+ ...(isWiring ? { reason: 'the hook wiring, replaced — you never edited it' } : {}),
292
+ });
230
293
  nextFiles[file.rel] = sha256(file.content);
231
294
  }
232
- else if (isReleasedVersion(history, file.rel, current, ctx)) {
233
- actions.push({ rel: file.rel, verdict: 'update', templatePath: file.source });
234
- nextFiles[file.rel] = sha256(file.content);
295
+ else if (isWiring) {
296
+ // Nothing vouches for these bytes, or replacing them would silence a hook
297
+ // that is still installed. Either way this is the one file whose conflict
298
+ // is a merge rather than a choice, so the released file is handed over.
299
+ wiring = file.content;
300
+ wiringByPath.set(file.rel, file.content);
301
+ actions.push({
302
+ rel: file.rel,
303
+ verdict: 'wiring',
304
+ // Say what was actually checked. This file no longer consults the
305
+ // released-hash table (see the limits above), so it cannot claim these
306
+ // bytes are not a release — only that the manifest does not vouch for
307
+ // them, which is the check that ran.
308
+ reason: wouldUnwireAnInstalledHook
309
+ ? 'replacing it would stop calling a hook that is still installed — merge the entries below by hand'
310
+ : recorded === undefined
311
+ ? 'the manifest does not vouch for it — treated as yours, merge the entries below by hand'
312
+ : 'edited since it was installed — merge the entries below by hand',
313
+ });
314
+ if (recorded !== undefined)
315
+ nextFiles[file.rel] = recorded;
235
316
  }
236
317
  else {
237
318
  actions.push({
@@ -263,6 +344,7 @@ export async function planUpgrade(repoDir, options = {}) {
263
344
  bootstrapped: manifest === null,
264
345
  actions,
265
346
  wiring,
347
+ wiringByPath,
266
348
  contents,
267
349
  manifest: {
268
350
  version: await packageVersion(),
@@ -4,13 +4,14 @@ import { CreateError, createProject } from './commands/create.js';
4
4
  import { InitError, initFileContents, initProject, planInit } from './commands/init.js';
5
5
  import { UpgradeError, applyUpgrade, planUpgrade } from './commands/upgrade.js';
6
6
  import { makePalette } from './lib/colors.js';
7
+ import { readManifest } from './lib/manifest.js';
7
8
  import { promptConfirm, promptTarget } from './lib/prompts.js';
8
9
  import { collectGovernance, renderSummary } from './lib/summary.js';
9
10
  import { DEFAULT_TARGET, TARGET_NAMES } from './lib/targets.js';
10
11
  import { packageVersion } from './lib/version.js';
11
12
  const USAGE = `Usage: create-agent-rig <dir> [options]
12
13
 
13
- Scaffolds a new project into <dir>: agent operating system (.claude/, CLAUDE.md)
14
+ Scaffolds a new project into <dir>: a Claude Code + Codex agent operating system
14
15
  plus a runnable code skeleton. Refuses to write into a non-empty directory.
15
16
 
16
17
  Options
@@ -22,9 +23,12 @@ Options
22
23
  --version print the version
23
24
  -h, --help this text
24
25
 
25
- Also: create-agent-rig init [--dry-run] [--force]
26
+ Also: create-agent-rig init [--dry-run]
26
27
  Install the process layer (rules, gates, stop rules — no architecture
27
- assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md.
28
+ assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md
29
+ or AGENTS.md.
30
+ --force is deprecated: it refuses and points at upgrade, which refreshes a
31
+ rig file by file. It is removed in 0.6.
28
32
 
29
33
  Also: create-agent-rig upgrade [--dry-run] [--yes]
30
34
  Bring the rig in the CURRENT repo up to this version. Replaces the files it
@@ -44,10 +48,23 @@ async function runInit(rawArgs) {
44
48
  }
45
49
  const cwd = process.cwd();
46
50
  const dryRun = values['dry-run'] === true;
51
+ // `init` adopts a repo the rig knows nothing about. Run inside a rig `create`
52
+ // generated — reachable when its CLAUDE.md was deleted — it is the wrong
53
+ // command: it installs the process layer alone and never refreshes the stack
54
+ // overlays. Say so before anything is written, so it is visible on --dry-run
55
+ // too.
56
+ //
57
+ // It is a manifest read, so a pre-0.4.0 rig with no manifest gets no advisory
58
+ // even when it came from `create` — the same limit `recordInstall` carries,
59
+ // and stated in both places because either one alone reads as wider.
60
+ const existing = await readManifest(cwd);
47
61
  const plan = await planInit(cwd);
48
62
  process.stdout.write(`agent-rig init — process layer into ${cwd}\n\n` +
49
63
  plan.files.map((f) => ` + ${f.path}`).join('\n') +
50
64
  '\n');
65
+ if (existing?.kind === 'create') {
66
+ process.stdout.write(`\n! This rig was created by create-agent-rig — \`init\` only fills gaps here; use \`upgrade\` to refresh.\n`);
67
+ }
51
68
  if (plan.conflicts.length > 0) {
52
69
  process.stdout.write(`\nAlready present (kept, not overwritten):\n` +
53
70
  plan.conflicts.map((c) => ` · ${c}`).join('\n') +
@@ -61,12 +78,15 @@ async function runInit(rawArgs) {
61
78
  process.stdout.write(`\nInstalled ${result.written.length} files` +
62
79
  (result.skipped.length ? `, kept ${result.skipped.length} existing` : '') +
63
80
  '.\n');
64
- // The one kept file that silently disables everything else: without this
65
- // wiring the hooks sit on disk and are never called, while the rules claim
66
- // they are enforced. Say so loudly, and hand over the exact entries.
67
- if (result.skipped.includes('.claude/settings.json')) {
68
- const wiring = (await initFileContents(cwd)).get('.claude/settings.json') ?? '';
69
- process.stdout.write(`\n! .claude/settings.json already exists — it was kept, so the rig's hooks are NOT wired.\n` +
81
+ // A kept harness config silently disables that harness's enforcement: the
82
+ // hooks sit on disk and are never called, while the rules claim they are.
83
+ // Say so loudly, and hand over the exact entries for each affected harness.
84
+ const generated = await initFileContents(cwd);
85
+ for (const wiringPath of ['.claude/settings.json', '.codex/hooks.json']) {
86
+ if (!result.skipped.includes(wiringPath))
87
+ continue;
88
+ const wiring = generated.get(wiringPath) ?? '';
89
+ process.stdout.write(`\n! ${wiringPath} already exists — it was kept, so the rig's hooks are NOT wired there.\n` +
70
90
  ` Until you merge these entries into it, nothing enforces the rules:\n\n` +
71
91
  wiring.replace(/^/gm, ' ') +
72
92
  '\n');
@@ -124,11 +144,15 @@ async function runUpgrade(rawArgs) {
124
144
  // The one thing this command will not do for you — printed with the plan,
125
145
  // because the dry run is where a reader decides whether there is work here,
126
146
  // and a report that mentions entries it never shows is not a plan.
127
- if (plan.wiring !== null) {
128
- process.stdout.write(`\n! .claude/settings.json is never replaced it is where your own hooks live.\n` +
129
- ` This version wires them like this; merge in what is missing:\n\n` +
130
- plan.wiring.replace(/^/gm, ' ') +
131
- '\n');
147
+ if (plan.wiringByPath.size > 0) {
148
+ for (const [wiringPath, wiring] of plan.wiringByPath) {
149
+ process.stdout.write(`\n! ${wiringPath} was handed over rather than replaced the reason is\n` +
150
+ ` on its line above. It is hook wiring, so it is never overwritten\n` +
151
+ ` without proof the rig wrote those exact bytes.\n` +
152
+ ` This version wires it like this; merge in what is missing:\n\n` +
153
+ wiring.replace(/^/gm, ' ') +
154
+ '\n');
155
+ }
132
156
  }
133
157
  if (values['dry-run'] === true) {
134
158
  process.stdout.write('\nDry run — nothing written.\n');
@@ -25,8 +25,38 @@ function isBinary(buffer) {
25
25
  }
26
26
  export async function copyTree(srcDir, destDir, options = {}) {
27
27
  const ignore = new Set(options.ignore ?? DEFAULT_IGNORE);
28
+ const files = [];
28
29
  await mkdir(destDir, { recursive: true });
29
- await copyDir(srcDir, destDir, '', { ...options, ignore });
30
+ await collectCopyTasks(srcDir, destDir, '', { ...options, ignore }, files);
31
+ await mapConcurrent(files, 16, ({ srcPath, destPath, relPath }) => copyFileEntry(srcPath, destPath, relPath, { ...options, ignore }));
32
+ }
33
+ /** Run independent async work through a bounded pool while preserving result order. */
34
+ export async function mapConcurrent(items, limit, worker) {
35
+ if (!Number.isInteger(limit) || limit < 1)
36
+ throw new RangeError('concurrency limit must be positive');
37
+ const results = new Array(items.length);
38
+ let next = 0;
39
+ let failed = false;
40
+ let firstError;
41
+ const run = async () => {
42
+ while (!failed && next < items.length) {
43
+ const index = next;
44
+ next += 1;
45
+ try {
46
+ results[index] = await worker(items[index]);
47
+ }
48
+ catch (error) {
49
+ if (!failed) {
50
+ failed = true;
51
+ firstError = error;
52
+ }
53
+ }
54
+ }
55
+ };
56
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run()));
57
+ if (failed)
58
+ throw firstError;
59
+ return results;
30
60
  }
31
61
  /**
32
62
  * The destination-relative file paths {@link copyTree} would produce — same
@@ -62,7 +92,7 @@ export async function listTreeEntries(srcDir, options = {}) {
62
92
  await walk(srcDir, '');
63
93
  return files;
64
94
  }
65
- async function copyDir(srcDir, destDir, relDir, options) {
95
+ async function collectCopyTasks(srcDir, destDir, relDir, options, files) {
66
96
  const entries = await readdir(srcDir, { withFileTypes: true });
67
97
  for (const entry of entries) {
68
98
  if (options.ignore.has(entry.name))
@@ -73,10 +103,10 @@ async function copyDir(srcDir, destDir, relDir, options) {
73
103
  const relPath = path.join(relDir, entry.name);
74
104
  if (entry.isDirectory()) {
75
105
  await mkdir(destPath, { recursive: true });
76
- await copyDir(srcPath, destPath, relPath, options);
106
+ await collectCopyTasks(srcPath, destPath, relPath, options, files);
77
107
  }
78
108
  else if (entry.isFile()) {
79
- await copyFileEntry(srcPath, destPath, relPath, options);
109
+ files.push({ srcPath, destPath, relPath });
80
110
  }
81
111
  // Symlinks and other special entries are intentionally not copied:
82
112
  // templates are plain trees.
@@ -87,7 +117,7 @@ async function copyFileEntry(srcPath, destPath, relPath, options) {
87
117
  await copyFile(srcPath, destPath); // copyFile preserves the mode by itself
88
118
  return;
89
119
  }
90
- const buffer = await readFile(srcPath);
120
+ const [buffer, { mode }] = await Promise.all([readFile(srcPath), stat(srcPath)]);
91
121
  if (isBinary(buffer)) {
92
122
  await writeFile(destPath, buffer);
93
123
  }
@@ -96,6 +126,5 @@ async function copyFileEntry(srcPath, destPath, relPath, options) {
96
126
  }
97
127
  // writeFile does NOT preserve permissions — restore them (chmod ignores umask),
98
128
  // otherwise executable template files (scripts, hooks) arrive non-executable.
99
- const { mode } = await stat(srcPath);
100
129
  await chmod(destPath, mode & 0o777);
101
130
  }
@@ -13,6 +13,18 @@
13
13
  */
14
14
  /** Matches the hook file a wired command runs, e.g. `.claude/hooks/guard-bash.mjs`. */
15
15
  const HOOK_REFERENCE = /\.claude\/hooks\/[A-Za-z0-9._-]+\.mjs/;
16
+ /**
17
+ * Every hook file some command in this settings text would run.
18
+ *
19
+ * Deliberately a scan of the raw text rather than a walk of the parsed shape:
20
+ * the caller compares two settings files to ask whether one stops calling a
21
+ * hook the other called, and a shape this module does not understand must not
22
+ * make that question answer "nothing was wired". The same expression decides
23
+ * both, so the filter and the comparison cannot drift apart.
24
+ */
25
+ export function hookFilesReferencedIn(settingsText) {
26
+ return new Set(settingsText.match(new RegExp(HOOK_REFERENCE, 'g')) ?? []);
27
+ }
16
28
  function isRecord(value) {
17
29
  return typeof value === 'object' && value !== null && !Array.isArray(value);
18
30
  }
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { listTreeEntries } from './copy-tree.js';
2
+ import { listTreeEntries, mapConcurrent } from './copy-tree.js';
3
3
  import { substituteContent, substituteFileName } from './substitute.js';
4
4
  import { agentOsStackDir, agentOsUniversalDir } from '../templates.js';
5
5
  /**
@@ -34,13 +34,11 @@ export async function agentOsInstallSet(stacks, ctx) {
34
34
  const entries = await listTreeEntries(layer.dir, {
35
35
  transformName: (name) => substituteFileName(name, ctx),
36
36
  });
37
- for (const entry of entries) {
38
- files.push({
39
- rel: entry.rel,
40
- source: entry.source,
41
- content: substituteContent(await readFile(entry.source, 'utf8'), ctx),
42
- });
43
- }
37
+ files.push(...(await mapConcurrent(entries, 16, async (entry) => ({
38
+ rel: entry.rel,
39
+ source: entry.source,
40
+ content: substituteContent(await readFile(entry.source, 'utf8'), ctx),
41
+ }))));
44
42
  }
45
43
  return files;
46
44
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { isSafeSegment } from './safe-path.js';
4
+ import { isSafeSubstitutionValue } from './safe-path.js';
5
5
  /**
6
6
  * The install manifest: what this rig installed, at which version, and the
7
7
  * hash each file had when it was written.
@@ -54,18 +54,30 @@ export function parseManifest(raw) {
54
54
  typeof project.region !== 'string') {
55
55
  return null;
56
56
  }
57
- // Values, not just types. These are substituted into file names and joined
58
- // into paths, and this file is committed it reaches a maintainer's disk
59
- // through a pull request. A name of `../..` would send every write out of
60
- // the repository, so an unsafe value invalidates the whole manifest rather
61
- // than being quietly corrected into something plausible.
62
- if (!isSafeSegment(project.name) || !isSafeSegment(project.scope))
57
+ // Values, not just types. This file is committed, so it reaches a
58
+ // maintainer's disk through a pull request. A name of `../..` would send a
59
+ // write out of the repository and `name` and `region` go further than
60
+ // paths: they are substituted into installed **files**, where a quote closes
61
+ // the string literal `stop-flag.mjs` embeds the name in, which `guard-bash`
62
+ // imports on every Bash call. (`scope` reaches no template today and a
63
+ // `stacks` entry names an overlay *directory*.) An unsafe value invalidates
64
+ // the whole manifest rather than being quietly corrected into something
65
+ // plausible.
66
+ //
67
+ // One check, not two: `isSafeSubstitutionValue` is strictly stronger than
68
+ // `isSafeSegment` here — its first character excludes `.`, and its class
69
+ // admits neither `/` nor `\` nor `\0` — so pairing them would leave a second
70
+ // predicate that can never fire, read as cover, and quietly stop being true
71
+ // if either one moves. `isSafeSegment` still guards every path segment at
72
+ // write time, in `resolveInside`.
73
+ if (!isSafeSubstitutionValue(project.name) || !isSafeSubstitutionValue(project.scope)) {
63
74
  return null;
64
- if (project.region !== '' && !isSafeSegment(project.region))
75
+ }
76
+ if (project.region !== '' && !isSafeSubstitutionValue(project.region))
65
77
  return null;
66
78
  if (!Array.isArray(m.stacks) || m.stacks.some((s) => typeof s !== 'string'))
67
79
  return null;
68
- if (m.stacks.some((s) => !isSafeSegment(s)))
80
+ if (m.stacks.some((s) => !isSafeSubstitutionValue(s)))
69
81
  return null;
70
82
  if (!isStringRecord(m.files))
71
83
  return null;
@@ -16,6 +16,36 @@ export function isSafeSegment(value) {
16
16
  !value.includes('\\') &&
17
17
  !value.includes('\0'));
18
18
  }
19
+ /**
20
+ * A value that can be substituted into an **installed file** without changing
21
+ * what that file means.
22
+ *
23
+ * 🔴 **Why this is not {@link isSafeSegment}, and why widening that one instead
24
+ * would be wrong.** `isSafeSegment` answers "can this steer a path", and it is
25
+ * what {@link resolveInside} holds every path segment to when an upgrade
26
+ * writes — `CLAUDE.md` and `.claude/rules/workflow.md` have to keep passing it,
27
+ * so it cannot become this whitelist. But `project.name` is substituted into
28
+ * `.claude/scripts/stop-flag.mjs` **inside a single-quoted JavaScript string
29
+ * literal**, and `guard-bash` imports that module on every Bash call. A value
30
+ * that steers no path at all still closes that quote: it reaches code
31
+ * execution in the hook process, and it silently disarms the kill switch,
32
+ * because the paths it computes stop pointing at `~/.claude/<name>-loop-STOP`.
33
+ * A brake that looks installed and is not is the worst of the two.
34
+ *
35
+ * So the rule is a whitelist, not a blacklist of the payloads anyone thought
36
+ * of. It costs nothing real: `create` already refuses anything outside this
37
+ * shape, and `projectNameFor` only ever emits `[a-z0-9._-]` — plus a leading
38
+ * `_`, which is why the first character allows it.
39
+ *
40
+ * One legitimate value it does reject on purpose: the **empty** region, which
41
+ * `init` writes to mean "no region". That carve-out belongs to the caller
42
+ * (`parseManifest`), not here — an empty string is exactly what a whitelist of
43
+ * substitutable characters should refuse, and folding "or empty" into this
44
+ * predicate would hand it to `name` and `scope` as well.
45
+ */
46
+ export function isSafeSubstitutionValue(value) {
47
+ return /^[a-z0-9_][a-z0-9._-]*$/.test(value);
48
+ }
19
49
  /**
20
50
  * `rel` resolved under `root`, or `null` when it would land anywhere else —
21
51
  * including an absolute path, an empty path, and the classic sibling