create-agent-rig 0.9.1 → 0.10.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 (234) hide show
  1. package/CHANGELOG.md +419 -3
  2. package/README.md +267 -305
  3. package/package.json +8 -14
  4. package/packages/cli/dist/commands/create.js +56 -88
  5. package/packages/cli/dist/commands/doctor.js +213 -0
  6. package/packages/cli/dist/commands/init.js +111 -50
  7. package/packages/cli/dist/commands/integrations.js +468 -0
  8. package/packages/cli/dist/commands/setup-wizard.js +61 -0
  9. package/packages/cli/dist/commands/uninstall.js +1223 -0
  10. package/packages/cli/dist/commands/upgrade.js +472 -90
  11. package/packages/cli/dist/index.js +651 -46
  12. package/packages/cli/dist/integrations/declaration.js +158 -0
  13. package/packages/cli/dist/integrations/doctor-guards.js +126 -0
  14. package/packages/cli/dist/integrations/doctor-workflow.js +25 -0
  15. package/packages/cli/dist/integrations/mcp-json.js +107 -0
  16. package/packages/cli/dist/integrations/memory-doctor.js +124 -0
  17. package/packages/cli/dist/integrations/registry.js +22 -0
  18. package/packages/cli/dist/integrations/spawn.js +228 -0
  19. package/packages/cli/dist/integrations/spec-kit.js +280 -0
  20. package/packages/cli/dist/integrations/verify.js +193 -0
  21. package/packages/cli/dist/integrations/windows-job.js +275 -0
  22. package/packages/cli/dist/lib/elevated-paths.js +74 -0
  23. package/packages/cli/dist/lib/install-set.js +1 -44
  24. package/packages/cli/dist/lib/manifest.js +64 -5
  25. package/packages/cli/dist/lib/prompts.js +0 -23
  26. package/packages/cli/dist/lib/safe-path.js +193 -2
  27. package/packages/cli/dist/lib/safe-text.js +78 -0
  28. package/packages/cli/dist/lib/substitute.js +1 -52
  29. package/packages/cli/dist/lib/summary.js +5 -5
  30. package/packages/cli/dist/templates.js +4 -14
  31. package/scripts/prepare.mjs +28 -12
  32. package/templates/agent-os/subagent-routing.json +4 -4
  33. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +13 -9
  34. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +31 -6
  35. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +5 -5
  36. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
  37. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +3 -3
  38. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -6
  39. package/templates/agent-os/universal/.claude/agents/implementation-agent.md +41 -0
  40. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -4
  41. package/templates/agent-os/universal/.claude/agents/security-scanner.md +4 -2
  42. package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -2
  43. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +83 -4
  44. package/templates/agent-os/universal/.claude/rules/autonomy.md +55 -27
  45. package/templates/agent-os/universal/.claude/rules/invariants.md +9 -8
  46. package/templates/agent-os/universal/.claude/rules/workflow.md +47 -22
  47. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -1
  48. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +12 -9
  49. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +100 -2
  50. package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +236 -6
  51. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +2 -2
  52. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +4 -3
  53. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +16 -0
  54. package/templates/agent-os/universal/.claude/settings.json +0 -8
  55. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +13 -9
  56. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +31 -6
  57. package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +5 -5
  58. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
  59. package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +3 -3
  60. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -2
  61. package/templates/agent-os/universal/.codex/agents/implementation-agent.toml +6 -0
  62. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -2
  63. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  64. package/templates/agent-os/universal/.codex/agents/test-writer.toml +1 -1
  65. package/templates/agent-os/universal/.codex/hooks.json +0 -10
  66. package/templates/agent-os/universal/AGENTS.md +208 -107
  67. package/templates/agent-os/universal/CLAUDE.md +15 -162
  68. package/templates/agent-os/universal/PLAN.md +17 -10
  69. package/templates/agent-os/universal/docs/decisions/agents-md-canonical.md +313 -0
  70. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +15 -10
  71. package/templates/agent-os/universal/docs/decisions/review-lanes.md +12 -8
  72. package/templates/agent-os/universal/docs/decisions/session-start-wire-format.md +206 -0
  73. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +6 -3
  74. package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +235 -0
  75. package/templates/agent-os/universal/layers.json +25 -30
  76. package/templates/hash-history.json +8 -4
  77. package/templates/release-ledger.json +2 -1
  78. package/packages/cli/dist/lib/composition.js +0 -20
  79. package/packages/cli/dist/lib/targets.js +0 -28
  80. package/packages/cli/dist/policy/benchmark/corpus.js +0 -165
  81. package/packages/cli/dist/policy/core/adapter.js +0 -18
  82. package/packages/cli/dist/policy/core/coverage.js +0 -253
  83. package/packages/cli/dist/policy/core/decision-record.js +0 -287
  84. package/packages/cli/dist/policy/core/declaration.js +0 -127
  85. package/packages/cli/dist/policy/core/evidence-matrix.js +0 -94
  86. package/packages/cli/dist/policy/core/probe.js +0 -442
  87. package/packages/cli/dist/policy/core/registry.js +0 -115
  88. package/packages/cli/dist/policy/core/validation.js +0 -275
  89. package/packages/cli/dist/policy/core/vocabulary.js +0 -123
  90. package/packages/cli/dist/policy/harness/claude.js +0 -47
  91. package/packages/cli/dist/policy/harness/codex.js +0 -87
  92. package/packages/cli/dist/policy/harness/index.js +0 -15
  93. package/packages/cli/dist/policy/harness/shared-hooks.js +0 -28
  94. package/packages/cli/dist/policy/index.js +0 -17
  95. package/templates/agent-os/init/AGENTS.md +0 -201
  96. package/templates/agent-os/init/CLAUDE.md +0 -201
  97. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +0 -105
  98. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +0 -117
  99. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +0 -89
  100. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +0 -105
  101. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +0 -105
  102. package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +0 -117
  103. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +0 -6
  104. package/templates/agent-os/stack/node-ts/.claude/hooks/dod-checks.json +0 -1
  105. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +0 -85
  106. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +0 -89
  107. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +0 -68
  108. package/templates/agent-os/universal/.claude/rules/architecture.md +0 -81
  109. package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +0 -28
  110. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +0 -90
  111. package/templates/skeleton/aws-serverless/README.md +0 -181
  112. package/templates/skeleton/aws-serverless/apps/web/next.config.mjs +0 -17
  113. package/templates/skeleton/aws-serverless/apps/web/package.json +0 -19
  114. package/templates/skeleton/aws-serverless/apps/web/src/app/layout.tsx +0 -17
  115. package/templates/skeleton/aws-serverless/apps/web/src/app/page.tsx +0 -96
  116. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +0 -36
  117. package/templates/skeleton/aws-serverless/apps/web/src/lib/validate.ts +0 -23
  118. package/templates/skeleton/aws-serverless/apps/web/test/shared-validation.test.ts +0 -38
  119. package/templates/skeleton/aws-serverless/apps/web/tsconfig.json +0 -14
  120. package/templates/skeleton/aws-serverless/eslint.config.mjs +0 -20
  121. package/templates/skeleton/aws-serverless/gitignore +0 -56
  122. package/templates/skeleton/aws-serverless/infra/bin/app.ts +0 -79
  123. package/templates/skeleton/aws-serverless/infra/cdk.json +0 -3
  124. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +0 -248
  125. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +0 -45
  126. package/templates/skeleton/aws-serverless/infra/package.json +0 -18
  127. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +0 -301
  128. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +0 -137
  129. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +0 -120
  130. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +0 -44
  131. package/templates/skeleton/aws-serverless/package.json +0 -30
  132. package/templates/skeleton/aws-serverless/packages/core/package.json +0 -11
  133. package/templates/skeleton/aws-serverless/packages/core/src/events.ts +0 -14
  134. package/templates/skeleton/aws-serverless/packages/core/src/index.ts +0 -15
  135. package/templates/skeleton/aws-serverless/packages/core/src/note.ts +0 -69
  136. package/templates/skeleton/aws-serverless/packages/core/test/events.test.ts +0 -23
  137. package/templates/skeleton/aws-serverless/packages/core/test/note.test.ts +0 -101
  138. package/templates/skeleton/aws-serverless/packages/db/package.json +0 -14
  139. package/templates/skeleton/aws-serverless/packages/db/src/client.ts +0 -17
  140. package/templates/skeleton/aws-serverless/packages/db/src/index.ts +0 -2
  141. package/templates/skeleton/aws-serverless/packages/db/src/note-model.ts +0 -52
  142. package/templates/skeleton/aws-serverless/packages/db/test/note-model.test.ts +0 -91
  143. package/templates/skeleton/aws-serverless/packages/shared/package.json +0 -11
  144. package/templates/skeleton/aws-serverless/packages/shared/src/env.ts +0 -17
  145. package/templates/skeleton/aws-serverless/packages/shared/src/errors.ts +0 -33
  146. package/templates/skeleton/aws-serverless/packages/shared/src/index.ts +0 -3
  147. package/templates/skeleton/aws-serverless/packages/shared/src/logger.ts +0 -20
  148. package/templates/skeleton/aws-serverless/packages/shared/test/env.test.ts +0 -26
  149. package/templates/skeleton/aws-serverless/packages/shared/test/errors.test.ts +0 -28
  150. package/templates/skeleton/aws-serverless/packages/shared/test/logger.test.ts +0 -19
  151. package/templates/skeleton/aws-serverless/pnpm-lock.yaml +0 -2855
  152. package/templates/skeleton/aws-serverless/pnpm-workspace.yaml +0 -14
  153. package/templates/skeleton/aws-serverless/services/api/package.json +0 -15
  154. package/templates/skeleton/aws-serverless/services/api/src/adapters/sqs-publisher.ts +0 -26
  155. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +0 -69
  156. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +0 -37
  157. package/templates/skeleton/aws-serverless/services/api/src/list-main.ts +0 -12
  158. package/templates/skeleton/aws-serverless/services/api/src/main.ts +0 -21
  159. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +0 -41
  160. package/templates/skeleton/aws-serverless/services/api/src/usecases/list-notes.ts +0 -14
  161. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +0 -211
  162. package/templates/skeleton/aws-serverless/services/api/test/create-note.usecase.test.ts +0 -45
  163. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +0 -122
  164. package/templates/skeleton/aws-serverless/services/api/test/sqs-publisher.test.ts +0 -22
  165. package/templates/skeleton/aws-serverless/services/worker/package.json +0 -12
  166. package/templates/skeleton/aws-serverless/services/worker/src/handlers/note-created.ts +0 -15
  167. package/templates/skeleton/aws-serverless/services/worker/src/main.ts +0 -7
  168. package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts +0 -37
  169. package/templates/skeleton/aws-serverless/services/worker/test/note-created.test.ts +0 -61
  170. package/templates/skeleton/aws-serverless/tsconfig.base.json +0 -15
  171. package/templates/skeleton/aws-serverless/tsconfig.json +0 -16
  172. package/templates/skeleton/aws-serverless/vitest.config.ts +0 -14
  173. package/templates/skeleton/node-service/.github/workflows/ci.yml +0 -27
  174. package/templates/skeleton/node-service/.github/workflows/deploy.yml +0 -29
  175. package/templates/skeleton/node-service/README.md +0 -104
  176. package/templates/skeleton/node-service/apps/web/next.config.mjs +0 -17
  177. package/templates/skeleton/node-service/apps/web/package.json +0 -19
  178. package/templates/skeleton/node-service/apps/web/src/app/layout.tsx +0 -17
  179. package/templates/skeleton/node-service/apps/web/src/app/page.tsx +0 -96
  180. package/templates/skeleton/node-service/apps/web/src/lib/api.ts +0 -29
  181. package/templates/skeleton/node-service/apps/web/src/lib/validate.ts +0 -23
  182. package/templates/skeleton/node-service/apps/web/test/shared-validation.test.ts +0 -38
  183. package/templates/skeleton/node-service/apps/web/tsconfig.json +0 -14
  184. package/templates/skeleton/node-service/eslint.config.mjs +0 -20
  185. package/templates/skeleton/node-service/gitignore +0 -54
  186. package/templates/skeleton/node-service/package.json +0 -30
  187. package/templates/skeleton/node-service/packages/core/package.json +0 -11
  188. package/templates/skeleton/node-service/packages/core/src/events.ts +0 -14
  189. package/templates/skeleton/node-service/packages/core/src/index.ts +0 -15
  190. package/templates/skeleton/node-service/packages/core/src/note.ts +0 -69
  191. package/templates/skeleton/node-service/packages/core/test/events.test.ts +0 -23
  192. package/templates/skeleton/node-service/packages/core/test/note.test.ts +0 -101
  193. package/templates/skeleton/node-service/packages/db/package.json +0 -12
  194. package/templates/skeleton/node-service/packages/db/src/index.ts +0 -1
  195. package/templates/skeleton/node-service/packages/db/src/note-store.ts +0 -100
  196. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +0 -100
  197. package/templates/skeleton/node-service/packages/shared/package.json +0 -11
  198. package/templates/skeleton/node-service/packages/shared/src/env.ts +0 -17
  199. package/templates/skeleton/node-service/packages/shared/src/errors.ts +0 -33
  200. package/templates/skeleton/node-service/packages/shared/src/index.ts +0 -3
  201. package/templates/skeleton/node-service/packages/shared/src/logger.ts +0 -20
  202. package/templates/skeleton/node-service/packages/shared/test/env.test.ts +0 -26
  203. package/templates/skeleton/node-service/packages/shared/test/errors.test.ts +0 -28
  204. package/templates/skeleton/node-service/packages/shared/test/logger.test.ts +0 -19
  205. package/templates/skeleton/node-service/pnpm-lock.yaml +0 -2402
  206. package/templates/skeleton/node-service/pnpm-workspace.yaml +0 -13
  207. package/templates/skeleton/node-service/scripts/build-artifact.mjs +0 -34
  208. package/templates/skeleton/node-service/services/api/package.json +0 -17
  209. package/templates/skeleton/node-service/services/api/src/adapters/spool-publisher.ts +0 -23
  210. package/templates/skeleton/node-service/services/api/src/handlers/create-note.ts +0 -40
  211. package/templates/skeleton/node-service/services/api/src/handlers/list-notes.ts +0 -23
  212. package/templates/skeleton/node-service/services/api/src/main.ts +0 -40
  213. package/templates/skeleton/node-service/services/api/src/server.ts +0 -172
  214. package/templates/skeleton/node-service/services/api/src/static-dir.ts +0 -20
  215. package/templates/skeleton/node-service/services/api/src/usecases/create-note.ts +0 -30
  216. package/templates/skeleton/node-service/services/api/src/usecases/list-notes.ts +0 -14
  217. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +0 -92
  218. package/templates/skeleton/node-service/services/api/test/create-note.handler.test.ts +0 -64
  219. package/templates/skeleton/node-service/services/api/test/create-note.usecase.test.ts +0 -43
  220. package/templates/skeleton/node-service/services/api/test/list-notes.test.ts +0 -48
  221. package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +0 -40
  222. package/templates/skeleton/node-service/services/api/test/package-manager.ts +0 -51
  223. package/templates/skeleton/node-service/services/api/test/server.test.ts +0 -208
  224. package/templates/skeleton/node-service/services/api/test/spool-publisher.test.ts +0 -32
  225. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +0 -29
  226. package/templates/skeleton/node-service/services/worker/package.json +0 -16
  227. package/templates/skeleton/node-service/services/worker/src/main.ts +0 -28
  228. package/templates/skeleton/node-service/services/worker/src/spool.ts +0 -60
  229. package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts +0 -38
  230. package/templates/skeleton/node-service/services/worker/test/process-note-created.test.ts +0 -34
  231. package/templates/skeleton/node-service/services/worker/test/spool.test.ts +0 -76
  232. package/templates/skeleton/node-service/tsconfig.base.json +0 -15
  233. package/templates/skeleton/node-service/tsconfig.json +0 -13
  234. package/templates/skeleton/node-service/vitest.config.ts +0 -12
@@ -1,7 +1,8 @@
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 { isSafeSubstitutionValue } from './safe-path.js';
4
+ import { isSafeSegment, isSafeSubstitutionValue, resolveWritableInside } from './safe-path.js';
5
+ import { hasControlCharacter } from './safe-text.js';
5
6
  /**
6
7
  * The install manifest: what this rig installed, at which version, and the
7
8
  * hash each file had when it was written.
@@ -15,6 +16,28 @@ import { isSafeSubstitutionValue } from './safe-path.js';
15
16
  * CI or on a colleague's machine is blind and falls back to the hash history.
16
17
  */
17
18
  export const MANIFEST_REL = '.claude/.rig-manifest.json';
19
+ /** Every layer this release ships. */
20
+ export const ALL_LAYERS = ['process', 'workflow'];
21
+ /** What a fresh `init`/`create` installs when nothing opts into more. */
22
+ export const DEFAULT_LAYERS = ['process'];
23
+ /**
24
+ * What an OLD manifest — written before this field existed — is read as
25
+ * having installed.
26
+ *
27
+ * Every release before RP-180 shipped exactly one payload, and that payload
28
+ * is what is now split into `process` + `workflow`. So a manifest with no
29
+ * `layers` key did not choose "core only" — there was no choice to make yet
30
+ * — it installed everything the single array named. Reading the absence as
31
+ * `DEFAULT_LAYERS` (core only) would make the very next `upgrade` treat
32
+ * every workflow file a pre-RP-180 rig has on disk as `retired` and stop
33
+ * managing it: exactly the data-loss direction RP-180's acceptance forbids.
34
+ * Pinned in `packages/cli/test/manifest.test.ts` › "a manifest with no
35
+ * `layers` key parses as though it recorded every layer" and
36
+ * `packages/cli/test/upgrade.test.ts` › "a pre-RP-180 manifest with no
37
+ * `layers` field keeps every workflow file it already has".
38
+ */
39
+ const LEGACY_LAYERS = ALL_LAYERS;
40
+ const isLayer = (value) => value === 'process' || value === 'workflow';
18
41
  export function sha256(data) {
19
42
  return createHash('sha256').update(data).digest('hex');
20
43
  }
@@ -24,6 +47,17 @@ function isStringRecord(value) {
24
47
  !Array.isArray(value) &&
25
48
  Object.values(value).every((v) => typeof v === 'string'));
26
49
  }
50
+ /**
51
+ * Manifest keys are both relative paths and user-visible plan text. A path can
52
+ * be lexically harmless to the filesystem while a newline or ANSI escape in
53
+ * it forges the plan a maintainer reviews before approving an upgrade.
54
+ */
55
+ function isSafeManifestPath(value) {
56
+ return (value !== '' &&
57
+ !path.isAbsolute(value) &&
58
+ !hasControlCharacter(value) &&
59
+ value.split('/').every(isSafeSegment));
60
+ }
27
61
  /**
28
62
  * A manifest, or `null` when there is nothing trustworthy to read.
29
63
  *
@@ -89,17 +123,35 @@ export function parseManifest(raw) {
89
123
  return null;
90
124
  if (m.stacks.some((s) => !isSafeSubstitutionValue(s)))
91
125
  return null;
92
- if (!isStringRecord(m.files))
126
+ // Optional, like `kept` below — absent is every manifest written before this
127
+ // field existed (resolved to `LEGACY_LAYERS` in the return, not left
128
+ // undefined). Present in a shape this reader does not accept voids the
129
+ // manifest, exactly as `stacks` and `kept` do.
130
+ if (m.layers !== undefined && (!Array.isArray(m.layers) || !m.layers.every(isLayer))) {
93
131
  return null;
132
+ }
133
+ if (!isStringRecord(m.files) || Object.keys(m.files).some((rel) => !isSafeManifestPath(rel))) {
134
+ return null;
135
+ }
94
136
  // Present in a shape this reader does not accept voids the manifest, exactly
95
137
  // as `files` does; absent is every manifest written before the field existed.
96
- if (m.kept !== undefined && !isStringRecord(m.kept))
138
+ if (m.kept !== undefined &&
139
+ (!isStringRecord(m.kept) || Object.keys(m.kept).some((rel) => !isSafeManifestPath(rel)))) {
97
140
  return null;
141
+ }
98
142
  return {
99
143
  version: m.version,
100
144
  kind: m.kind,
101
145
  project: { name: project.name, scope: project.scope, region: project.region },
102
146
  stacks: [...m.stacks],
147
+ // Deduplicated here, once (RP-180 round 3, security blocker S2): the
148
+ // closed set has exactly two members, so a valid manifest never needs
149
+ // more than two entries, but nothing upstream of this reader bounds the
150
+ // array's length — a committed manifest could otherwise repeat one name
151
+ // thousands of times and make every downstream reader of `.layers` (this
152
+ // module's own callers, `upgrade.ts`, `doctor.mjs`) do unbounded work per
153
+ // entry for no reason.
154
+ layers: m.layers !== undefined ? [...new Set(m.layers)] : [...LEGACY_LAYERS],
103
155
  files: { ...m.files },
104
156
  ...(m.kept !== undefined ? { kept: { ...m.kept } } : {}),
105
157
  };
@@ -133,7 +185,14 @@ export async function readManifest(repoDir) {
133
185
  }
134
186
  }
135
187
  export async function writeManifest(repoDir, manifest) {
136
- const dest = path.join(repoDir, ...MANIFEST_REL.split('/'));
188
+ const dest = await resolveWritableInside(repoDir, MANIFEST_REL);
189
+ if (dest === null) {
190
+ throw new Error(`Refusing to write "${MANIFEST_REL}" through a symlink or outside ${repoDir}.`);
191
+ }
137
192
  await mkdir(path.dirname(dest), { recursive: true });
138
- await writeFile(dest, serializeManifest(manifest));
193
+ const checked = await resolveWritableInside(repoDir, MANIFEST_REL);
194
+ if (checked === null) {
195
+ throw new Error(`Refusing to write "${MANIFEST_REL}" through a symlink or outside ${repoDir}.`);
196
+ }
197
+ await writeFile(checked, serializeManifest(manifest));
139
198
  }
@@ -19,26 +19,3 @@ export function promptConfirm(question, streams) {
19
19
  });
20
20
  });
21
21
  }
22
- /**
23
- * Pick a target interactively: by number, by name, or Enter for the default.
24
- * Anything unrecognised falls back to the default — generation should never
25
- * dead-end on a typo.
26
- */
27
- export function promptTarget(targets, defaultTarget, streams) {
28
- if (!streams.isInteractive) {
29
- return Promise.resolve(defaultTarget);
30
- }
31
- const menu = targets
32
- .map((name, index) => ` ${index + 1}. ${name}${name === defaultTarget ? ' (default)' : ''}`)
33
- .join('\n');
34
- const rl = createInterface({ input: streams.input, output: streams.output });
35
- return new Promise((resolve) => {
36
- rl.question(`Target:\n${menu}\nChoose [1-${targets.length}]: `, (answer) => {
37
- rl.close();
38
- const trimmed = answer.trim();
39
- const byNumber = targets[Number.parseInt(trimmed, 10) - 1];
40
- const byName = targets.find((name) => name === trimmed);
41
- resolve(byName ?? byNumber ?? defaultTarget);
42
- });
43
- });
44
- }
@@ -1,3 +1,4 @@
1
+ import { lstat, realpath } from 'node:fs/promises';
1
2
  import path from 'node:path';
2
3
  /**
3
4
  * Path safety for values that came from **outside the CLI** — the install
@@ -46,10 +47,58 @@ export function isSafeSegment(value) {
46
47
  export function isSafeSubstitutionValue(value) {
47
48
  return /^[a-z0-9_][a-z0-9._-]*$/.test(value);
48
49
  }
50
+ /**
51
+ * The most path segments any legitimate `rel` this codebase constructs or
52
+ * tests ever needs.
53
+ *
54
+ * 🔴 No specific number or path is claimed here as evidence for where this is
55
+ * set — a sentence naming today's deepest shipped path would go stale the day
56
+ * a rule moves one directory deeper, and nothing would catch it (the norm
57
+ * `.claude/rules/invariants.md` states under "State the limits — and test
58
+ * them"). What backs this value instead is
59
+ * `packages/cli/test/safe-path.test.ts` › "caps a path at more segments than
60
+ * any path this release's own install set ships, measured not guessed" —
61
+ * it reads the real install set through `initManifest()`, asserts this
62
+ * constant leaves it comfortable headroom, and pins the cap's own refusal
63
+ * behaviour at the boundary. Read that test for the current numbers; this
64
+ * comment does not repeat them so it cannot drift from what the test finds.
65
+ *
66
+ * `rel` is not always this command's own construction, though — a rig
67
+ * manifest is committed input (`uninstall`'s `files`/`kept` keys reach
68
+ * {@link resolveInside} through `onDisk`, one key at a time, before ownership
69
+ * is even checked), so a hostile one can name a key of unbounded segment
70
+ * count. `path.resolve(base, ...segments)` below is a SPREAD over
71
+ * `segments`, and past roughly 65,000–130,000 elements (engine-dependent)
72
+ * that raises an uncaught `RangeError: Maximum call stack size exceeded` —
73
+ * not caught anywhere on this path, so it would surface as a raw stack trace
74
+ * instead of the refusal this function exists to return, and under `--json`
75
+ * as a bare message instead of the promised payload. This is the "no spread
76
+ * of an array whose length is unbounded by input" case named in
77
+ * `.claude/rules/invariants.md`'s fail-open rule; the cap below closes it by
78
+ * refusing before the spread is ever reached.
79
+ */
80
+ export const MAX_PATH_SEGMENTS = 16;
81
+ /**
82
+ * `rel` has more path segments than {@link MAX_PATH_SEGMENTS} allows.
83
+ * Exported so a caller that needs to say WHICH limit a refusal hit — rather
84
+ * than `resolveInside`'s one undifferentiated `null` — can ask this specific
85
+ * question before calling `resolveInside` at all, without re-implementing the
86
+ * threshold: there is exactly one count-the-segments-and-compare here, and
87
+ * `resolveInside` below calls it rather than repeating it.
88
+ */
89
+ export function exceedsMaxPathSegments(rel) {
90
+ return rel.split('/').length > MAX_PATH_SEGMENTS;
91
+ }
49
92
  /**
50
93
  * `rel` resolved under `root`, or `null` when it would land anywhere else —
51
- * including an absolute path, an empty path, and the classic sibling
52
- * (`/tmp/rig` must not contain `/tmp/rig-evil`).
94
+ * including an absolute path, an empty path, the classic sibling
95
+ * (`/tmp/rig` must not contain `/tmp/rig-evil`), or a segment count past
96
+ * {@link MAX_PATH_SEGMENTS}. The `null` does not say which of these it was;
97
+ * a caller that needs to (`uninstall`'s per-file plan, so it can report a
98
+ * too-deep manifest key honestly instead of through the generic "resolves
99
+ * outside" message) checks {@link exceedsMaxPathSegments} itself, before
100
+ * ever calling this function, rather than trying to reverse-engineer the
101
+ * reason from `null`.
53
102
  *
54
103
  * This is the containment behind every write an upgrade makes. It is deliberate
55
104
  * belt-and-braces: the values that build `rel` are validated where they are
@@ -58,6 +107,8 @@ export function isSafeSubstitutionValue(value) {
58
107
  export function resolveInside(root, rel) {
59
108
  if (rel === '' || path.isAbsolute(rel))
60
109
  return null;
110
+ if (exceedsMaxPathSegments(rel))
111
+ return null;
61
112
  const segments = rel.split('/');
62
113
  // Refused, not repaired: joining an absolute or `..`-bearing path onto the
63
114
  // root would silently turn hostile input into a plausible-looking write.
@@ -69,3 +120,143 @@ export function resolveInside(root, rel) {
69
120
  return null;
70
121
  return dest.startsWith(base + path.sep) ? dest : null;
71
122
  }
123
+ /**
124
+ * The one per-segment walk both {@link resolveWritableInside} and
125
+ * {@link resolveReadableInside} build on (RP-22 round 3 advisory: it used to
126
+ * exist twice, nearly identically, in this file). It answers three things a
127
+ * caller can tell apart: every segment existed and was safe (`'complete'`,
128
+ * carrying the FINAL segment's own `lstat` result, since a read-side caller
129
+ * still needs to know its kind — file or directory); some segment did not
130
+ * exist yet, so nothing PAST that point is real (`'stopped-missing'` — a
131
+ * write-side caller treats this as "safe to create", a read-side caller as
132
+ * "absent"); or a segment could not be trusted at all (`'refused'`, with
133
+ * why). It does not itself decide what either outcome MEANS to a caller —
134
+ * that stays in each function, because a missing component means something
135
+ * different to a writer than to a reader, and a behaviour decision belongs
136
+ * at the call site, not in shared plumbing (`.claude/rules/invariants.md`,
137
+ * "One mechanism, one implementation" — the mechanism here is the WALK, not
138
+ * the interpretation of it).
139
+ */
140
+ async function walkSegments(base, rel) {
141
+ let cursor = base;
142
+ let finalEntry;
143
+ for (const segment of rel.split('/')) {
144
+ cursor = path.join(cursor, segment);
145
+ let resolved;
146
+ try {
147
+ finalEntry = await lstat(cursor);
148
+ if (finalEntry.isSymbolicLink())
149
+ return { outcome: 'refused', reason: 'symlink' };
150
+ // `realpath` stays INSIDE this same try (RP-22 round 4 advisory): the
151
+ // round-2 extraction of this walk from `resolveWritableInside` had
152
+ // moved it outside, so an lstat-succeeds/realpath-throws race (the
153
+ // entry existing for `lstat`, then vanishing or becoming unreadable
154
+ // before `realpath` runs) surfaced as an uncaught exception instead of
155
+ // the same `'refused'` outcome master's own try/catch always gave it.
156
+ // A genuine race is not deterministically testable, so this is
157
+ // restored on the strength of the diff — master's original
158
+ // try/catch scope, read directly — being provably narrower than
159
+ // round 2's, not on a reproduction; the PR body says so.
160
+ resolved = await realpath(cursor);
161
+ }
162
+ catch (error) {
163
+ if (error.code === 'ENOENT')
164
+ return { outcome: 'stopped-missing' };
165
+ return {
166
+ outcome: 'refused',
167
+ reason: 'io-error',
168
+ code: error.code,
169
+ };
170
+ }
171
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) {
172
+ return { outcome: 'refused', reason: 'escapes-root' };
173
+ }
174
+ }
175
+ // `rel` is never `''` here — `resolveInside` above already refused that —
176
+ // so `rel.split('/')` has at least one element and the loop always runs at
177
+ // least once: `finalEntry` is always assigned on this path.
178
+ return { outcome: 'complete', finalEntry: finalEntry };
179
+ }
180
+ /**
181
+ * The read-side counterpart of {@link resolveWritableInside}: walk `rel`
182
+ * under `root` one segment at a time, exactly the same way, but for a
183
+ * caller that only intends to READ what is there — never write, never
184
+ * create. A symlink anywhere in the path is refused, never followed, in
185
+ * EITHER direction: a write-refused symlink that is nonetheless silently
186
+ * READ (a committed `.rig/integrations.json` pointing outside the
187
+ * repository, honoured as if it were the real declaration) is exactly as
188
+ * dangerous as one silently written through, because from S5 on this file's
189
+ * content decides what gets installed (RP-22 round 2 gate finding).
190
+ *
191
+ * Unlike {@link resolveWritableInside}, a missing component is `'absent'`,
192
+ * not a green light — there is nothing to read, and a caller building a
193
+ * fresh declaration from scratch is a different case from one whose read
194
+ * was refused. The final component is additionally required to be the
195
+ * `kind` the caller names, so a directory sitting where a file is expected
196
+ * (or the reverse) is `'unsafe'` rather than surfacing as a raw `EISDIR`/
197
+ * `ENOTDIR` from a subsequent `readFile`/`readdir` call.
198
+ */
199
+ export async function resolveReadableInside(root, rel, kind) {
200
+ let base;
201
+ try {
202
+ base = await realpath(root);
203
+ if (!(await lstat(base)).isDirectory())
204
+ return { status: 'absent' };
205
+ }
206
+ catch {
207
+ return { status: 'absent' };
208
+ }
209
+ const dest = resolveInside(base, rel);
210
+ if (dest === null)
211
+ return { status: 'unsafe', reason: 'escapes-root' };
212
+ const walk = await walkSegments(base, rel);
213
+ if (walk.outcome === 'stopped-missing')
214
+ return { status: 'absent' };
215
+ if (walk.outcome === 'refused') {
216
+ if (walk.reason === 'symlink')
217
+ return { status: 'unsafe', reason: 'symlink' };
218
+ if (walk.reason === 'io-error')
219
+ return { status: 'unsafe', reason: 'io-error', code: walk.code };
220
+ return { status: 'unsafe', reason: 'escapes-root' };
221
+ }
222
+ const isRightKind = kind === 'file' ? walk.finalEntry.isFile() : walk.finalEntry.isDirectory();
223
+ if (!isRightKind)
224
+ return { status: 'unsafe', reason: 'wrong-kind' };
225
+ return { status: 'ok', path: dest };
226
+ }
227
+ /**
228
+ * Resolve a prospective write through the repository's real path and refuse
229
+ * every symlink in the relative path that already exists.
230
+ *
231
+ * Lexical containment is not enough for a writer: `root/.claude` can be a
232
+ * link to another directory, and a link at the final path can be dangling
233
+ * until `writeFile` creates its target. Missing components are allowed because
234
+ * callers create them, then call this function again immediately before the
235
+ * write. The second check makes the newly-created chain evidence too.
236
+ *
237
+ * Observable behaviour is unchanged from before the shared `walkSegments`
238
+ * extraction (RP-22 round 3): a missing component at ANY point in the walk
239
+ * (`'stopped-missing'`) and a fully-walked, fully-safe path (`'complete'`)
240
+ * both still mean "return `dest`" here — only `walkSegments` itself is new,
241
+ * this function's own outward answer for every input is identical to what it
242
+ * was, and `packages/cli/test/safe-path.test.ts`'s existing tests for it are
243
+ * unmodified.
244
+ */
245
+ export async function resolveWritableInside(root, rel) {
246
+ let base;
247
+ try {
248
+ base = await realpath(root);
249
+ if (!(await lstat(base)).isDirectory())
250
+ return null;
251
+ }
252
+ catch {
253
+ return null;
254
+ }
255
+ const dest = resolveInside(base, rel);
256
+ if (dest === null)
257
+ return null;
258
+ const walk = await walkSegments(base, rel);
259
+ if (walk.outcome === 'refused')
260
+ return null;
261
+ return dest;
262
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * A control or Unicode-format character (RTL override, zero-width joiner, an
3
+ * ESC sequence, a bare CR/LF) inside a string that later reaches a screen a
4
+ * maintainer reads before approving a write — a plan line, a manifest key, a
5
+ * declared integration id. Extracted from `manifest.ts` (RP-22 S1) so a second
6
+ * caller does not duplicate the predicate; the manifest's own behaviour and
7
+ * tests are unchanged by the move (`.claude/rules/invariants.md`, "One
8
+ * mechanism, one implementation").
9
+ */
10
+ const FORMAT_CHARACTER_PATTERN = /\p{Cf}/u;
11
+ /**
12
+ * Whether one code point (as yielded by spreading a string, so a surrogate
13
+ * pair arrives as a single two-code-unit element) is a Unicode format
14
+ * character (a bidi override, a zero-width joiner, …) or one of the two
15
+ * Unicode line separators (U+2028, U+2029), used by `hasControlCharacter`.
16
+ */
17
+ export function isFormatOrLineSeparatorCharacter(character) {
18
+ const code = character.charCodeAt(0);
19
+ return code === 0x2028 || code === 0x2029 || FORMAT_CHARACTER_PATTERN.test(character);
20
+ }
21
+ export function hasControlCharacter(value) {
22
+ return [...value].some((character) => {
23
+ const code = character.charCodeAt(0);
24
+ return (code <= 0x1f || (code >= 0x7f && code <= 0x9f) || isFormatOrLineSeparatorCharacter(character));
25
+ });
26
+ }
27
+ /**
28
+ * A type predicate, not a cast: narrows `unknown` to an indexable object
29
+ * without asserting anything. Shared by the integration JSON parsers.
30
+ */
31
+ export function isPlainObject(value) {
32
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
33
+ }
34
+ /**
35
+ * One iterative, explicit-worklist pass over a parsed JSON value that answers
36
+ * two questions at once: does anything here carry a control or Unicode-format
37
+ * character, and does the value nest deeper than `maxDepth` below its root.
38
+ *
39
+ * Iterative on purpose. A naive recursive walk over attacker-controlled JSON
40
+ * stack-overflows well inside any reasonable byte cap — `JSON.parse` itself
41
+ * tolerates a depth of 30 000+ — so recursion here would be the one way a
42
+ * caller's "TOTAL, never throws" promise could break
43
+ * (`.claude/rules/invariants.md`, "no recursion over input"). Total work is
44
+ * bounded by the number of JSON tokens in `root`, which the caller's own byte
45
+ * cap must be checked BEFORE this ever runs.
46
+ *
47
+ * The caller selects the depth bound for its accepted schema.
48
+ */
49
+ export function scanForDepthAndControlChars(root, maxDepth) {
50
+ const stack = [{ value: root, depth: 0 }];
51
+ let hasControlChar = false;
52
+ while (stack.length > 0) {
53
+ const next = stack.pop();
54
+ if (next === undefined)
55
+ break; // guarded by the loop condition; stated for the type checker
56
+ const { value, depth } = next;
57
+ if (depth > maxDepth)
58
+ return { tooDeep: true, hasControlChar };
59
+ if (typeof value === 'string') {
60
+ if (hasControlCharacter(value))
61
+ hasControlChar = true;
62
+ continue;
63
+ }
64
+ if (Array.isArray(value)) {
65
+ for (const item of value)
66
+ stack.push({ value: item, depth: depth + 1 });
67
+ continue;
68
+ }
69
+ if (typeof value === 'object' && value !== null) {
70
+ for (const [key, child] of Object.entries(value)) {
71
+ if (hasControlCharacter(key))
72
+ hasControlChar = true;
73
+ stack.push({ value: child, depth: depth + 1 });
74
+ }
75
+ }
76
+ }
77
+ return { tooDeep: false, hasControlChar };
78
+ }
@@ -1,54 +1,3 @@
1
1
  export function substituteContent(content, ctx) {
2
- return content
3
- .replaceAll('__PROJECT_NAME__', ctx.projectName)
4
- .replaceAll('__PROJECT_SCOPE__', ctx.projectScope)
5
- .replaceAll('__REGION__', ctx.region)
6
- .replaceAll('@app/', `@${ctx.projectScope}/`);
7
- }
8
- /**
9
- * The inverse of {@link substituteContent}, used **only to recognise** an
10
- * installed file as a released version of its template: the released bytes
11
- * carry tokens, the installed bytes carry the project's own name, and without
12
- * this every token-carrying file (the kill switch, the loop skill, `CLAUDE.md`)
13
- * would be a permanent conflict on every rig.
14
- *
15
- * 🔴 Limits, and both of them fail in the safe direction — an unrecognised file
16
- * is reported and left alone, never overwritten:
17
- *
18
- * - `__PROJECT_SCOPE__` is not reversed. It substitutes to the same string as
19
- * `__PROJECT_NAME__`, so the two are indistinguishable after the fact; the
20
- * agent-os layer (the only layer an upgrade touches) uses neither the scope
21
- * token nor `@app/` in prose, which a template test pins.
22
- * - A template that contains the project's name as a *literal* reverses into a
23
- * token that was never there. That costs nothing on its own — recognition
24
- * offers the untouched bytes as a candidate too, and those still match. It
25
- * bites only on a file carrying **both** a token and the literal, which then
26
- * reads as a conflict for that one project.
27
- */
28
- export function detokenizeContent(content, ctx) {
29
- let out = content;
30
- // scope first: reversing the name first would rewrite `@name/` into
31
- // `@__PROJECT_NAME__/` and the scope form could never match afterwards
32
- if (ctx.projectScope !== '')
33
- out = out.replaceAll(`@${ctx.projectScope}/`, '@app/');
34
- if (ctx.region !== '')
35
- out = out.replaceAll(ctx.region, '__REGION__');
36
- if (ctx.projectName !== '')
37
- out = out.replaceAll(ctx.projectName, '__PROJECT_NAME__');
38
- return out;
39
- }
40
- /**
41
- * Files that must exist in the generated project under a dotted name, but are
42
- * stored un-dotted in the template because `npm publish` strips the dotted
43
- * original from tarballs (the create-react-app `gitignore` trick).
44
- */
45
- const UNDOTTED_NAMES = {
46
- gitignore: '.gitignore',
47
- };
48
- export function substituteFileName(name, ctx) {
49
- const substituted = name
50
- .replaceAll('__PROJECT_NAME__', ctx.projectName)
51
- .replaceAll('__PROJECT_SCOPE__', ctx.projectScope)
52
- .replaceAll('__REGION__', ctx.region);
53
- return UNDOTTED_NAMES[substituted] ?? substituted;
2
+ return content.replaceAll('__PROJECT_NAME__', ctx.projectName);
54
3
  }
@@ -28,18 +28,18 @@ export async function collectGovernance(projectDir) {
28
28
  // directory, and counting a config file as an enforced hook would make the
29
29
  // screen overstate the one thing this tool actually sells.
30
30
  hooks: (await names(path.join(claude, 'hooks'), /\.mjs$/, /\.mjs$/)).map(
31
- // guard-core-purity → "core purity": the mechanism, not the filename
31
+ // guard-secret-file → "secret file": the mechanism, not the filename
32
32
  (hook) => HOOK_LABELS[hook] ?? hook.replace(/^(guard|block)-/, '').replaceAll('-', ' ')),
33
33
  skills: await names(path.join(claude, 'skills'), /$^/),
34
34
  };
35
35
  }
36
- export function renderSummary(projectName, target, dirArg, summary, p) {
36
+ export function renderSummary(projectName, dirArg, summary, p) {
37
37
  const row = (label, count, detail, note = '') => {
38
38
  const tag = note ? ` ${note}` : '';
39
39
  return ` ${label.padEnd(8)}${String(count).padEnd(3)}${tag.padEnd(note ? 11 : 0)} ${p.dim(detail)}`;
40
40
  };
41
41
  return [
42
- `Created ${p.accent(projectName)} ${p.dim('·')} ${target}`,
42
+ `Created ${p.accent(projectName)}`,
43
43
  '',
44
44
  row('Rules', summary.rules.length, '.claude/rules/'),
45
45
  row('Agents', summary.agents.length, summary.agents.join(', ')),
@@ -48,8 +48,8 @@ export function renderSummary(projectName, target, dirArg, summary, p) {
48
48
  '',
49
49
  'Next',
50
50
  p.dim(` cd ${dirArg}`),
51
- p.dim(' pnpm install'),
52
- p.dim(' pnpm check'),
51
+ p.dim(' codex'),
52
+ p.dim(' claude'),
53
53
  '',
54
54
  ].join('\n');
55
55
  }
@@ -10,20 +10,10 @@ import { fileURLToPath } from 'node:url';
10
10
  export function templatesRoot() {
11
11
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'templates');
12
12
  }
13
- export function skeletonDir(skeleton) {
14
- return path.join(templatesRoot(), 'skeleton', skeleton);
15
- }
16
- export function agentOsUniversalDir() {
17
- return path.join(templatesRoot(), 'agent-os', 'universal');
18
- }
19
- export function agentOsStackDir(stack) {
20
- return path.join(templatesRoot(), 'agent-os', 'stack', stack);
21
- }
22
13
  /**
23
- * Overrides `init` applies on top of the universal layer. A file here replaces
24
- * its universal namesake when the rig is installed into an existing repo whose
25
- * shape the generator knows nothing about. `create` never reads this directory.
14
+ * The one payload this package ships (RP-177 retired the per-target skeleton
15
+ * and the per-stack overlays that used to sit alongside it).
26
16
  */
27
- export function agentOsInitDir() {
28
- return path.join(templatesRoot(), 'agent-os', 'init');
17
+ export function agentOsUniversalDir() {
18
+ return path.join(templatesRoot(), 'agent-os', 'universal');
29
19
  }
@@ -2,7 +2,7 @@
2
2
  // (`npx github:<user>/create-agent-rig`). It must therefore work with only
3
3
  // the root devDependencies present and no pnpm available.
4
4
  import { spawnSync } from 'node:child_process';
5
- import { existsSync } from 'node:fs';
5
+ import { existsSync, rmSync } from 'node:fs';
6
6
  import { createRequire } from 'node:module';
7
7
  import path from 'node:path';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -34,17 +34,15 @@ export const gitConfigEnv = (env = process.env) => {
34
34
  return sanitised;
35
35
  };
36
36
 
37
- function main() {
38
- // 1. Wire up the pre-commit hook when working inside the git checkout.
39
- if (!process.env.CI && existsSync(path.join(root, '.git'))) {
40
- spawnSync('git', ['config', 'core.hooksPath', '.husky'], {
41
- cwd: root,
42
- env: gitConfigEnv(),
43
- stdio: 'inherit',
44
- });
45
- }
37
+ /**
38
+ * Build from a clean output directory. TypeScript overwrites emitted files,
39
+ * but it does not remove JavaScript whose source disappeared; without this
40
+ * boundary `npm pack` can publish retired commands from an older checkout.
41
+ */
42
+ export function buildCli() {
43
+ const dist = path.join(root, 'packages/cli/dist');
44
+ rmSync(dist, { recursive: true, force: true });
46
45
 
47
- // 2. Build the CLI so the `bin` entry exists (required for git/tarball installs).
48
46
  const require = createRequire(import.meta.url);
49
47
  const tscPath = path.join(
50
48
  path.dirname(require.resolve('typescript/package.json')),
@@ -56,7 +54,25 @@ function main() {
56
54
  [tscPath, '-p', path.join(root, 'packages/cli/tsconfig.build.json')],
57
55
  { cwd: root, stdio: 'inherit' },
58
56
  );
59
- process.exit(result.status ?? 1);
57
+ return result.status ?? 1;
58
+ }
59
+
60
+ function main() {
61
+ // 1. Wire up the pre-commit hook when working inside the git checkout.
62
+ if (
63
+ !process.argv.includes('--build-only') &&
64
+ !process.env.CI &&
65
+ existsSync(path.join(root, '.git'))
66
+ ) {
67
+ spawnSync('git', ['config', 'core.hooksPath', '.husky'], {
68
+ cwd: root,
69
+ env: gitConfigEnv(),
70
+ stdio: 'inherit',
71
+ });
72
+ }
73
+
74
+ // 2. Build the CLI so the `bin` entry exists (required for git/tarball installs).
75
+ process.exit(buildCli());
60
76
  }
61
77
 
62
78
  // Run only when executed, never when imported: a test that imports this module
@@ -12,6 +12,10 @@
12
12
  "claude": { "model": "claude-sonnet-5", "effort": "high" },
13
13
  "codex": { "model": "gpt-5.6-terra", "effort": "high" }
14
14
  },
15
+ "implementation-agent": {
16
+ "claude": { "model": "claude-sonnet-5", "effort": "high" },
17
+ "codex": { "model": "gpt-5.6-terra", "effort": "high" }
18
+ },
15
19
  "prose-reviewer": {
16
20
  "claude": { "model": "claude-sonnet-5", "effort": "high" },
17
21
  "codex": { "model": "gpt-5.6-terra", "effort": "high" }
@@ -23,10 +27,6 @@
23
27
  "security-scanner": {
24
28
  "claude": { "model": "claude-opus-5", "effort": "high" },
25
29
  "codex": { "model": "gpt-5.6-sol", "effort": "high" }
26
- },
27
- "cdk-diff-reviewer": {
28
- "claude": { "model": "claude-opus-5", "effort": "high" },
29
- "codex": { "model": "gpt-5.6-sol", "effort": "high" }
30
30
  }
31
31
  }
32
32
  }