create-agent-rig 0.4.0 → 0.6.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 (118) hide show
  1. package/CHANGELOG.md +316 -11
  2. package/README.md +58 -19
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +85 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +81 -19
  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 +31 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/scripts/prepare.mjs +1 -1
  14. package/templates/agent-os/init/AGENTS.md +199 -0
  15. package/templates/agent-os/init/CLAUDE.md +69 -9
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
  17. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  18. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
  19. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  20. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
  21. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  22. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  23. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
  24. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  26. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  27. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  28. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
  29. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  30. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
  31. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
  32. package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
  33. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
  34. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  35. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  36. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  37. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  38. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  39. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  40. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
  41. package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
  42. package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
  43. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  44. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
  45. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  46. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  47. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  48. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  49. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  50. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  51. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
  52. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
  53. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  54. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
  55. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
  56. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  57. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
  58. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
  59. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
  60. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
  61. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  62. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  63. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  64. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  65. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
  66. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  67. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  68. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
  69. package/templates/agent-os/universal/.claude/settings.json +11 -2
  70. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
  71. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
  72. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
  73. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  74. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  75. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  76. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  77. package/templates/agent-os/universal/.codex/hooks.json +70 -0
  78. package/templates/agent-os/universal/AGENTS.md +166 -0
  79. package/templates/agent-os/universal/CLAUDE.md +49 -14
  80. package/templates/agent-os/universal/PLAN.md +7 -40
  81. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  82. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  83. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  84. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  85. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  86. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  87. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  88. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  89. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
  90. package/templates/agent-os/universal/journal/README.md +101 -0
  91. package/templates/agent-os/universal/layers.json +45 -2
  92. package/templates/hash-history.json +310 -49
  93. package/templates/release-ledger.json +9 -0
  94. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  95. package/templates/skeleton/aws-serverless/README.md +91 -9
  96. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  97. package/templates/skeleton/aws-serverless/gitignore +37 -0
  98. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  99. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  100. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  101. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  102. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  103. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  104. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  105. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  106. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  107. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  108. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  109. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  110. package/templates/skeleton/node-service/README.md +11 -1
  111. package/templates/skeleton/node-service/gitignore +34 -0
  112. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  113. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  114. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  115. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  116. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  117. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  118. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -1,6 +1,7 @@
1
1
  import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { settingsForInstalledHooks } from '../lib/init-settings.js';
4
+ import { mapConcurrent } from '../lib/copy-tree.js';
4
5
  import { readManifest, sha256, writeManifest } from '../lib/manifest.js';
5
6
  import { substituteContent } from '../lib/substitute.js';
6
7
  import { agentOsInitDir, agentOsUniversalDir } from '../templates.js';
@@ -8,7 +9,11 @@ import { packageVersion } from '../lib/version.js';
8
9
  /** A user-facing failure: message is printed as-is, no stack trace. */
9
10
  export class InitError extends Error {
10
11
  }
12
+ /** What `--force` answers with now that `upgrade` owns the case it stood in for. */
13
+ export const FORCE_DEPRECATED = 'deprecated — init --force replaced only CLAUDE.md; run create-agent-rig upgrade instead';
11
14
  const SETTINGS = '.claude/settings.json';
15
+ const CODEX_HOOKS = '.codex/hooks.json';
16
+ const MAPS = ['CLAUDE.md', 'AGENTS.md'];
12
17
  async function loadManifest() {
13
18
  const raw = await readFile(path.join(agentOsUniversalDir(), 'layers.json'), 'utf8');
14
19
  return JSON.parse(raw);
@@ -53,15 +58,15 @@ export async function initManifest() {
53
58
  const manifest = await loadManifest();
54
59
  const universal = agentOsUniversalDir();
55
60
  const override = agentOsInitDir();
56
- const files = [];
57
- for (const rel of [...manifest.process, 'CLAUDE.md']) {
61
+ const files = await mapConcurrent([...manifest.process, ...MAPS], 16, async (rel) => {
58
62
  const overridden = path.join(override, rel);
59
- files.push({
63
+ return {
60
64
  rel,
61
65
  source: (await exists(overridden)) ? overridden : path.join(universal, rel),
62
- });
63
- }
66
+ };
67
+ });
64
68
  files.push({ rel: SETTINGS, source: null });
69
+ files.push({ rel: CODEX_HOOKS, source: null });
65
70
  return files;
66
71
  }
67
72
  /**
@@ -82,14 +87,23 @@ export async function initFileContents(repoDir, project) {
82
87
  };
83
88
  const files = await initManifest();
84
89
  const contents = new Map();
85
- for (const { rel, source } of files) {
86
- if (source === null)
87
- continue;
88
- contents.set(rel, substituteContent(await readFile(source, 'utf8'), ctx));
90
+ const sourceFiles = files.filter((file) => file.source !== null);
91
+ const rendered = await mapConcurrent(sourceFiles, 16, async ({ rel, source }) => ({
92
+ rel,
93
+ content: substituteContent(await readFile(source, 'utf8'), ctx),
94
+ }));
95
+ for (const { rel, content } of rendered) {
96
+ contents.set(rel, content);
89
97
  }
90
98
  const installedHooks = new Set(files.map((f) => f.rel).filter((rel) => rel.startsWith('.claude/hooks/')));
91
- const shipped = JSON.parse(await readFile(path.join(agentOsUniversalDir(), SETTINGS), 'utf8'));
99
+ const [shippedSettings, shippedCodexHooks] = await Promise.all([
100
+ readFile(path.join(agentOsUniversalDir(), SETTINGS), 'utf8'),
101
+ readFile(path.join(agentOsUniversalDir(), CODEX_HOOKS), 'utf8'),
102
+ ]);
103
+ const shipped = JSON.parse(shippedSettings);
92
104
  contents.set(SETTINGS, `${JSON.stringify(settingsForInstalledHooks(shipped, installedHooks), null, 2)}\n`);
105
+ const shippedCodex = JSON.parse(shippedCodexHooks);
106
+ contents.set(CODEX_HOOKS, `${JSON.stringify(settingsForInstalledHooks(shippedCodex, installedHooks), null, 2)}\n`);
93
107
  return contents;
94
108
  }
95
109
  /** The process layer as a set of {@link InstalledFile}s — what `upgrade` reads. */
@@ -100,41 +114,49 @@ export async function initInstallSet(repoDir, project) {
100
114
  }
101
115
  export async function planInit(repoDir) {
102
116
  const files = (await initManifest()).map((f) => f.rel);
103
- const conflicts = [];
104
- for (const rel of files) {
105
- if (await exists(path.join(repoDir, rel)))
106
- conflicts.push(rel);
107
- }
117
+ const conflicts = (await mapConcurrent(files, 16, async (rel) => (await exists(path.join(repoDir, rel))) ? rel : null)).filter((rel) => rel !== null);
108
118
  return { files: files.map((p) => ({ path: p })), conflicts };
109
119
  }
110
120
  export async function initProject(repoDir, options) {
121
+ // Refused before anything is read or written, so a deprecated flag cannot
122
+ // half-install: `upgrade` covers what this stood in for, and it decides per
123
+ // file from the manifest instead of overriding one refusal wholesale.
124
+ if (options.force)
125
+ throw new InitError(FORCE_DEPRECATED);
111
126
  const files = (await initManifest()).map((f) => f.rel);
112
- // Refuse to clobber an existing CLAUDE.md unless forced — init edits
113
- // someone's working repository (brief §4, non-negotiable).
114
- if (!options.force && files.includes('CLAUDE.md')) {
115
- if (await exists(path.join(repoDir, 'CLAUDE.md'))) {
116
- throw new InitError('This repo already has a CLAUDE.md. Refusing to overwrite it. ' +
117
- 'Merge the agent-os map in by hand, or re-run with --force to replace it.');
127
+ const previous = await readManifest(repoDir);
128
+ // Refuse to clobber an existing CLAUDE.md — init edits someone's working
129
+ // repository (brief §4, non-negotiable).
130
+ for (const map of MAPS) {
131
+ if (files.includes(map) && (await exists(path.join(repoDir, map)))) {
132
+ // A create rig whose CLAUDE.md was deleted is the legacy route into init.
133
+ // Its generated AGENTS.md must not newly close that route, but only the
134
+ // manifest can distinguish that file from a user's own Codex guidance.
135
+ if (map === 'AGENTS.md' &&
136
+ previous?.files[map] !== undefined &&
137
+ sha256(await readFile(path.join(repoDir, map), 'utf8')) === previous.files[map]) {
138
+ continue;
139
+ }
140
+ throw new InitError(`This repo already has an ${map}. Refusing to overwrite it. ` +
141
+ 'Merge the agent-os map in by hand, or run create-agent-rig upgrade to refresh a rig.');
118
142
  }
119
143
  }
120
144
  const contents = await initFileContents(repoDir);
121
- const written = [];
122
- const skipped = [];
123
145
  const plannedCount = files.length;
124
- for (const rel of files) {
146
+ const actions = await mapConcurrent(files, 16, async (rel) => {
125
147
  const dest = path.join(repoDir, rel);
126
- const isForceableMeta = rel === 'CLAUDE.md';
127
- if ((await exists(dest)) && !(isForceableMeta && options.force)) {
148
+ if (await exists(dest)) {
128
149
  // never overwrite a file init did not write (a user's own copy)
129
- skipped.push(rel);
130
- continue;
150
+ return { rel, verdict: 'skipped' };
131
151
  }
132
152
  if (options.dryRun)
133
- continue;
153
+ return { rel, verdict: 'planned' };
134
154
  await mkdir(path.dirname(dest), { recursive: true });
135
155
  await writeFile(dest, contents.get(rel) ?? '');
136
- written.push(rel);
137
- }
156
+ return { rel, verdict: 'written' };
157
+ });
158
+ const written = actions.filter(({ verdict }) => verdict === 'written').map(({ rel }) => rel);
159
+ const skipped = actions.filter(({ verdict }) => verdict === 'skipped').map(({ rel }) => rel);
138
160
  if (!options.dryRun)
139
161
  await recordInstall(repoDir, written, contents);
140
162
  return { written, skipped, plannedCount };
@@ -147,6 +169,32 @@ export async function initProject(repoDir, options) {
147
169
  * somebody else's — claiming it here would let the next upgrade replace a
148
170
  * user's own document with the rig's. Earlier entries are preserved: a re-run
149
171
  * writes nothing and must not therefore un-remember everything.
172
+ *
173
+ * 🔴 **`kind`, `project` and `stacks` are preserved, not rewritten.** Reached
174
+ * inside a rig `create` produced, this used to stamp `kind: 'init'`,
175
+ * `stacks: []` and an empty `region` over the truth — and `planUpgrade` trusts
176
+ * a manifest wholesale (it never re-detects), so the next upgrade routed to the
177
+ * `init` install set and the stack overlays left the plan entirely: not
178
+ * reported as deleted, not as a conflict, simply absent. `init` describes what
179
+ * it wrote; it does not get to re-describe how the rig was installed.
180
+ *
181
+ * ⚠ **The limit, stated because the fix reads as wider than it is:** this
182
+ * preserves a manifest, so a rig without a READABLE one still gets
183
+ * `kind: 'init'`, no stacks and an empty region, and the advisory in `runInit`
184
+ * stays silent for the same reason. That is three populations, not one: a rig
185
+ * from before 0.4.0 never had a manifest, a deleted manifest is a documented
186
+ * recovery step, and one on disk that `parseManifest` voids reads as absent to
187
+ * `readManifest` alike. `upgrade`'s `detectInstall`
188
+ * recovers all three from the files on disk, so those values are not
189
+ * unavailable, only unavailable *here*: reaching for it would point
190
+ * `commands/init` at `commands/upgrade`, which already imports this module.
191
+ * The fallback below is the honest floor, not the best available answer.
192
+ *
193
+ * The item that asked for this also floated refusing `init` outright on a
194
+ * `create` manifest. It is already refused a step earlier and for a different
195
+ * reason — {@link initProject} throws on the existing `CLAUDE.md`. The gap that
196
+ * leaves is a `create` rig whose `CLAUDE.md` was deleted, and this function is
197
+ * what makes that case safe.
150
198
  */
151
199
  async function recordInstall(repoDir, written, contents) {
152
200
  const previous = await readManifest(repoDir);
@@ -156,9 +204,12 @@ async function recordInstall(repoDir, written, contents) {
156
204
  files[rel] = sha256(contents.get(rel) ?? '');
157
205
  const manifest = {
158
206
  version: await packageVersion(),
159
- kind: 'init',
160
- project: { name, scope: name, region: '' },
161
- stacks: [],
207
+ kind: previous?.kind ?? 'init',
208
+ // No manifest: fall back to the directory name, which is all this module
209
+ // reads. See the limit above — `upgrade` can do better from the files
210
+ // themselves, and `init` deliberately does not reach for it.
211
+ project: previous?.project ?? { name, scope: name, region: '' },
212
+ stacks: previous?.stacks ?? [],
162
213
  files,
163
214
  };
164
215
  await writeManifest(repoDir, manifest);
@@ -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 fell back to
193
+ // matching against released versions. 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
@@ -34,7 +38,13 @@ async function runInit(rawArgs) {
34
38
  try {
35
39
  ({ values } = parseArgs({
36
40
  args: rawArgs,
37
- options: { 'dry-run': { type: 'boolean' }, force: { type: 'boolean' } },
41
+ // `--no-color` for the same reason it is accepted on `upgrade`: USAGE
42
+ // offers it without scoping it to one command.
43
+ options: {
44
+ 'dry-run': { type: 'boolean' },
45
+ force: { type: 'boolean' },
46
+ 'no-color': { type: 'boolean' },
47
+ },
38
48
  allowPositionals: false,
39
49
  }));
40
50
  }
@@ -44,10 +54,25 @@ async function runInit(rawArgs) {
44
54
  }
45
55
  const cwd = process.cwd();
46
56
  const dryRun = values['dry-run'] === true;
57
+ // `init` adopts a repo the rig knows nothing about. Run inside a rig `create`
58
+ // generated — reachable when its CLAUDE.md was deleted — it is the wrong
59
+ // command: it installs the process layer alone and never refreshes the stack
60
+ // overlays. Say so before anything is written, so it is visible on --dry-run
61
+ // too.
62
+ //
63
+ // It is a manifest read, so any rig without a READABLE manifest gets no
64
+ // advisory even when it came from `create` — one never written, one deleted,
65
+ // and one present but voided by `parseManifest` all reach here alike. The
66
+ // same limit `recordInstall` carries, stated in both places because either
67
+ // one alone reads as wider.
68
+ const existing = await readManifest(cwd);
47
69
  const plan = await planInit(cwd);
48
70
  process.stdout.write(`agent-rig init — process layer into ${cwd}\n\n` +
49
71
  plan.files.map((f) => ` + ${f.path}`).join('\n') +
50
72
  '\n');
73
+ if (existing?.kind === 'create') {
74
+ process.stdout.write(`\n! This rig was created by create-agent-rig — \`init\` only fills gaps here; use \`upgrade\` to refresh.\n`);
75
+ }
51
76
  if (plan.conflicts.length > 0) {
52
77
  process.stdout.write(`\nAlready present (kept, not overwritten):\n` +
53
78
  plan.conflicts.map((c) => ` · ${c}`).join('\n') +
@@ -61,12 +86,15 @@ async function runInit(rawArgs) {
61
86
  process.stdout.write(`\nInstalled ${result.written.length} files` +
62
87
  (result.skipped.length ? `, kept ${result.skipped.length} existing` : '') +
63
88
  '.\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` +
89
+ // A kept harness config silently disables that harness's enforcement: the
90
+ // hooks sit on disk and are never called, while the rules claim they are.
91
+ // Say so loudly, and hand over the exact entries for each affected harness.
92
+ const generated = await initFileContents(cwd);
93
+ for (const wiringPath of ['.claude/settings.json', '.codex/hooks.json']) {
94
+ if (!result.skipped.includes(wiringPath))
95
+ continue;
96
+ const wiring = generated.get(wiringPath) ?? '';
97
+ process.stdout.write(`\n! ${wiringPath} already exists — it was kept, so the rig's hooks are NOT wired there.\n` +
70
98
  ` Until you merge these entries into it, nothing enforces the rules:\n\n` +
71
99
  wiring.replace(/^/gm, ' ') +
72
100
  '\n');
@@ -86,7 +114,7 @@ function renderUpgradePlan(repoDir, plan) {
86
114
  const lines = [
87
115
  `agent-rig upgrade — ${plan.kind} rig in ${repoDir}`,
88
116
  plan.bootstrapped
89
- ? ` no manifest here (a pre-0.4.0 rig) — matching files against released versions`
117
+ ? ` no readable manifest here (deleted, never written, or unparseable) — matching files against released versions`
90
118
  : ` installed by ${plan.fromVersion}`,
91
119
  ` upgrading to ${plan.toVersion}`,
92
120
  '',
@@ -100,9 +128,29 @@ function renderUpgradePlan(repoDir, plan) {
100
128
  }
101
129
  }
102
130
  }
103
- const unchanged = of('unchanged').length;
131
+ // Every one of `UpgradeVerdict`'s six members is accounted for here.
132
+ // `wiring` and `deleted` each print their own line and were in none of the
133
+ // buckets, so a reader counted lines and was told a smaller number.
134
+ // (`unchanged` is counted and prints nothing — the sum is over actions, not
135
+ // over printed lines.) The two appear only when they occurred, so a plan
136
+ // without them renders exactly as it always has. Pinned by, in cli-report.test.ts,
137
+ // "renders a plan with no wiring action exactly as it does today".
138
+ // `deleted` before `wiring`, the relative order the plan prints them in.
139
+ // ⚠ Only their order relative to EACH OTHER matches: the plan prints
140
+ // `deleted` before `conflict` and the summary prints it after, so this is not
141
+ // a plan-ordered line. Pinned by, in cli-report.test.ts,
142
+ // "lists the two occasional buckets in the order the plan prints them".
143
+ const occasional = [
144
+ ['deleted', (n) => `${n} you removed (left removed)`],
145
+ ['wiring', (n) => `${n} wiring handed over`],
146
+ ];
147
+ const extra = occasional
148
+ .map(([verdict, phrase]) => [of(verdict).length, phrase])
149
+ .filter(([count]) => count > 0)
150
+ .map(([count, phrase]) => phrase(count));
104
151
  lines.push('', ` ${of('update').length} to replace, ${of('new').length} new, ` +
105
- `${of('conflict').length} yours (kept), ${unchanged} already current`);
152
+ `${of('conflict').length} yours (kept), ` +
153
+ [...extra, `${of('unchanged').length} already current`].join(', '));
106
154
  return `${lines.join('\n')}\n`;
107
155
  }
108
156
  async function runUpgrade(rawArgs) {
@@ -110,7 +158,17 @@ async function runUpgrade(rawArgs) {
110
158
  try {
111
159
  ({ values } = parseArgs({
112
160
  args: rawArgs,
113
- options: { 'dry-run': { type: 'boolean' }, yes: { type: 'boolean' } },
161
+ // `--no-color` is advertised in USAGE without scoping it to one command, so
162
+ // every command accepts it. Refusing a flag the help offers costs the
163
+ // reader more than honouring it costs us — and honouring it is only a
164
+ // parse here, because the sole palette lives on the `create` path below.
165
+ // Pinned by, in cli-report.test.ts,
166
+ // "upgrade accepts --no-color and prints plain output".
167
+ options: {
168
+ 'dry-run': { type: 'boolean' },
169
+ yes: { type: 'boolean' },
170
+ 'no-color': { type: 'boolean' },
171
+ },
114
172
  allowPositionals: false,
115
173
  }));
116
174
  }
@@ -124,11 +182,15 @@ async function runUpgrade(rawArgs) {
124
182
  // The one thing this command will not do for you — printed with the plan,
125
183
  // because the dry run is where a reader decides whether there is work here,
126
184
  // 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');
185
+ if (plan.wiringByPath.size > 0) {
186
+ for (const [wiringPath, wiring] of plan.wiringByPath) {
187
+ process.stdout.write(`\n! ${wiringPath} was handed over rather than replaced the reason is\n` +
188
+ ` on its line above. It is hook wiring, so it is never overwritten\n` +
189
+ ` without proof the rig wrote those exact bytes.\n` +
190
+ ` This version wires it like this; merge in what is missing:\n\n` +
191
+ wiring.replace(/^/gm, ' ') +
192
+ '\n');
193
+ }
132
194
  }
133
195
  if (values['dry-run'] === true) {
134
196
  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
  }