create-agent-rig 0.9.0 → 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 (237) hide show
  1. package/CHANGELOG.md +466 -3
  2. package/README.md +267 -299
  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 +151 -54
  7. package/packages/cli/dist/commands/integrations.js +468 -0
  8. package/packages/cli/dist/commands/memory.js +63 -4
  9. package/packages/cli/dist/commands/setup-wizard.js +61 -0
  10. package/packages/cli/dist/commands/uninstall.js +1223 -0
  11. package/packages/cli/dist/commands/upgrade.js +489 -89
  12. package/packages/cli/dist/index.js +657 -49
  13. package/packages/cli/dist/integrations/declaration.js +158 -0
  14. package/packages/cli/dist/integrations/doctor-guards.js +126 -0
  15. package/packages/cli/dist/integrations/doctor-workflow.js +25 -0
  16. package/packages/cli/dist/integrations/mcp-json.js +107 -0
  17. package/packages/cli/dist/integrations/memory-doctor.js +124 -0
  18. package/packages/cli/dist/integrations/registry.js +22 -0
  19. package/packages/cli/dist/integrations/spawn.js +228 -0
  20. package/packages/cli/dist/integrations/spec-kit.js +280 -0
  21. package/packages/cli/dist/integrations/verify.js +193 -0
  22. package/packages/cli/dist/integrations/windows-job.js +275 -0
  23. package/packages/cli/dist/lib/elevated-paths.js +74 -0
  24. package/packages/cli/dist/lib/install-set.js +1 -44
  25. package/packages/cli/dist/lib/manifest.js +86 -9
  26. package/packages/cli/dist/lib/prompts.js +0 -23
  27. package/packages/cli/dist/lib/safe-path.js +193 -2
  28. package/packages/cli/dist/lib/safe-text.js +78 -0
  29. package/packages/cli/dist/lib/substitute.js +1 -52
  30. package/packages/cli/dist/lib/summary.js +5 -5
  31. package/packages/cli/dist/templates.js +4 -14
  32. package/scripts/prepare.mjs +28 -12
  33. package/templates/agent-os/subagent-routing.json +4 -4
  34. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +13 -9
  35. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +31 -6
  36. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +5 -5
  37. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
  38. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +3 -3
  39. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -6
  40. package/templates/agent-os/universal/.claude/agents/implementation-agent.md +41 -0
  41. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -4
  42. package/templates/agent-os/universal/.claude/agents/security-scanner.md +4 -2
  43. package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -2
  44. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +83 -4
  45. package/templates/agent-os/universal/.claude/rules/autonomy.md +55 -27
  46. package/templates/agent-os/universal/.claude/rules/invariants.md +9 -8
  47. package/templates/agent-os/universal/.claude/rules/workflow.md +47 -22
  48. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -1
  49. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +12 -9
  50. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +100 -2
  51. package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +236 -6
  52. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +43 -0
  53. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +18 -1
  54. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +2 -2
  55. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +273 -35
  56. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +16 -0
  57. package/templates/agent-os/universal/.claude/settings.json +0 -8
  58. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +13 -9
  59. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +31 -6
  60. package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +5 -5
  61. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
  62. package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +3 -3
  63. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -2
  64. package/templates/agent-os/universal/.codex/agents/implementation-agent.toml +6 -0
  65. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -2
  66. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  67. package/templates/agent-os/universal/.codex/agents/test-writer.toml +1 -1
  68. package/templates/agent-os/universal/.codex/hooks.json +0 -10
  69. package/templates/agent-os/universal/AGENTS.md +208 -107
  70. package/templates/agent-os/universal/CLAUDE.md +15 -162
  71. package/templates/agent-os/universal/PLAN.md +17 -10
  72. package/templates/agent-os/universal/docs/decisions/agents-md-canonical.md +313 -0
  73. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +15 -10
  74. package/templates/agent-os/universal/docs/decisions/review-lanes.md +12 -8
  75. package/templates/agent-os/universal/docs/decisions/session-start-wire-format.md +206 -0
  76. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +6 -3
  77. package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +235 -0
  78. package/templates/agent-os/universal/layers.json +25 -30
  79. package/templates/hash-history.json +73 -24
  80. package/templates/release-ledger.json +3 -1
  81. package/packages/cli/dist/lib/composition.js +0 -20
  82. package/packages/cli/dist/lib/targets.js +0 -28
  83. package/packages/cli/dist/policy/benchmark/corpus.js +0 -165
  84. package/packages/cli/dist/policy/core/adapter.js +0 -18
  85. package/packages/cli/dist/policy/core/coverage.js +0 -253
  86. package/packages/cli/dist/policy/core/decision-record.js +0 -287
  87. package/packages/cli/dist/policy/core/declaration.js +0 -127
  88. package/packages/cli/dist/policy/core/evidence-matrix.js +0 -94
  89. package/packages/cli/dist/policy/core/probe.js +0 -442
  90. package/packages/cli/dist/policy/core/registry.js +0 -115
  91. package/packages/cli/dist/policy/core/validation.js +0 -275
  92. package/packages/cli/dist/policy/core/vocabulary.js +0 -123
  93. package/packages/cli/dist/policy/harness/claude.js +0 -47
  94. package/packages/cli/dist/policy/harness/codex.js +0 -87
  95. package/packages/cli/dist/policy/harness/index.js +0 -15
  96. package/packages/cli/dist/policy/harness/shared-hooks.js +0 -28
  97. package/packages/cli/dist/policy/index.js +0 -17
  98. package/templates/agent-os/init/AGENTS.md +0 -201
  99. package/templates/agent-os/init/CLAUDE.md +0 -201
  100. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +0 -105
  101. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +0 -117
  102. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +0 -89
  103. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +0 -105
  104. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +0 -105
  105. package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +0 -117
  106. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +0 -6
  107. package/templates/agent-os/stack/node-ts/.claude/hooks/dod-checks.json +0 -1
  108. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +0 -85
  109. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +0 -89
  110. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +0 -68
  111. package/templates/agent-os/universal/.claude/rules/architecture.md +0 -81
  112. package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +0 -28
  113. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +0 -90
  114. package/templates/skeleton/aws-serverless/README.md +0 -181
  115. package/templates/skeleton/aws-serverless/apps/web/next.config.mjs +0 -17
  116. package/templates/skeleton/aws-serverless/apps/web/package.json +0 -19
  117. package/templates/skeleton/aws-serverless/apps/web/src/app/layout.tsx +0 -17
  118. package/templates/skeleton/aws-serverless/apps/web/src/app/page.tsx +0 -96
  119. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +0 -36
  120. package/templates/skeleton/aws-serverless/apps/web/src/lib/validate.ts +0 -23
  121. package/templates/skeleton/aws-serverless/apps/web/test/shared-validation.test.ts +0 -38
  122. package/templates/skeleton/aws-serverless/apps/web/tsconfig.json +0 -14
  123. package/templates/skeleton/aws-serverless/eslint.config.mjs +0 -20
  124. package/templates/skeleton/aws-serverless/gitignore +0 -56
  125. package/templates/skeleton/aws-serverless/infra/bin/app.ts +0 -79
  126. package/templates/skeleton/aws-serverless/infra/cdk.json +0 -3
  127. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +0 -248
  128. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +0 -45
  129. package/templates/skeleton/aws-serverless/infra/package.json +0 -18
  130. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +0 -301
  131. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +0 -137
  132. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +0 -120
  133. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +0 -44
  134. package/templates/skeleton/aws-serverless/package.json +0 -30
  135. package/templates/skeleton/aws-serverless/packages/core/package.json +0 -11
  136. package/templates/skeleton/aws-serverless/packages/core/src/events.ts +0 -14
  137. package/templates/skeleton/aws-serverless/packages/core/src/index.ts +0 -15
  138. package/templates/skeleton/aws-serverless/packages/core/src/note.ts +0 -69
  139. package/templates/skeleton/aws-serverless/packages/core/test/events.test.ts +0 -23
  140. package/templates/skeleton/aws-serverless/packages/core/test/note.test.ts +0 -101
  141. package/templates/skeleton/aws-serverless/packages/db/package.json +0 -14
  142. package/templates/skeleton/aws-serverless/packages/db/src/client.ts +0 -17
  143. package/templates/skeleton/aws-serverless/packages/db/src/index.ts +0 -2
  144. package/templates/skeleton/aws-serverless/packages/db/src/note-model.ts +0 -52
  145. package/templates/skeleton/aws-serverless/packages/db/test/note-model.test.ts +0 -91
  146. package/templates/skeleton/aws-serverless/packages/shared/package.json +0 -11
  147. package/templates/skeleton/aws-serverless/packages/shared/src/env.ts +0 -17
  148. package/templates/skeleton/aws-serverless/packages/shared/src/errors.ts +0 -33
  149. package/templates/skeleton/aws-serverless/packages/shared/src/index.ts +0 -3
  150. package/templates/skeleton/aws-serverless/packages/shared/src/logger.ts +0 -20
  151. package/templates/skeleton/aws-serverless/packages/shared/test/env.test.ts +0 -26
  152. package/templates/skeleton/aws-serverless/packages/shared/test/errors.test.ts +0 -28
  153. package/templates/skeleton/aws-serverless/packages/shared/test/logger.test.ts +0 -19
  154. package/templates/skeleton/aws-serverless/pnpm-lock.yaml +0 -2855
  155. package/templates/skeleton/aws-serverless/pnpm-workspace.yaml +0 -14
  156. package/templates/skeleton/aws-serverless/services/api/package.json +0 -15
  157. package/templates/skeleton/aws-serverless/services/api/src/adapters/sqs-publisher.ts +0 -26
  158. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +0 -69
  159. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +0 -37
  160. package/templates/skeleton/aws-serverless/services/api/src/list-main.ts +0 -12
  161. package/templates/skeleton/aws-serverless/services/api/src/main.ts +0 -21
  162. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +0 -41
  163. package/templates/skeleton/aws-serverless/services/api/src/usecases/list-notes.ts +0 -14
  164. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +0 -211
  165. package/templates/skeleton/aws-serverless/services/api/test/create-note.usecase.test.ts +0 -45
  166. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +0 -122
  167. package/templates/skeleton/aws-serverless/services/api/test/sqs-publisher.test.ts +0 -22
  168. package/templates/skeleton/aws-serverless/services/worker/package.json +0 -12
  169. package/templates/skeleton/aws-serverless/services/worker/src/handlers/note-created.ts +0 -15
  170. package/templates/skeleton/aws-serverless/services/worker/src/main.ts +0 -7
  171. package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts +0 -37
  172. package/templates/skeleton/aws-serverless/services/worker/test/note-created.test.ts +0 -61
  173. package/templates/skeleton/aws-serverless/tsconfig.base.json +0 -15
  174. package/templates/skeleton/aws-serverless/tsconfig.json +0 -16
  175. package/templates/skeleton/aws-serverless/vitest.config.ts +0 -14
  176. package/templates/skeleton/node-service/.github/workflows/ci.yml +0 -27
  177. package/templates/skeleton/node-service/.github/workflows/deploy.yml +0 -29
  178. package/templates/skeleton/node-service/README.md +0 -104
  179. package/templates/skeleton/node-service/apps/web/next.config.mjs +0 -17
  180. package/templates/skeleton/node-service/apps/web/package.json +0 -19
  181. package/templates/skeleton/node-service/apps/web/src/app/layout.tsx +0 -17
  182. package/templates/skeleton/node-service/apps/web/src/app/page.tsx +0 -96
  183. package/templates/skeleton/node-service/apps/web/src/lib/api.ts +0 -29
  184. package/templates/skeleton/node-service/apps/web/src/lib/validate.ts +0 -23
  185. package/templates/skeleton/node-service/apps/web/test/shared-validation.test.ts +0 -38
  186. package/templates/skeleton/node-service/apps/web/tsconfig.json +0 -14
  187. package/templates/skeleton/node-service/eslint.config.mjs +0 -20
  188. package/templates/skeleton/node-service/gitignore +0 -54
  189. package/templates/skeleton/node-service/package.json +0 -30
  190. package/templates/skeleton/node-service/packages/core/package.json +0 -11
  191. package/templates/skeleton/node-service/packages/core/src/events.ts +0 -14
  192. package/templates/skeleton/node-service/packages/core/src/index.ts +0 -15
  193. package/templates/skeleton/node-service/packages/core/src/note.ts +0 -69
  194. package/templates/skeleton/node-service/packages/core/test/events.test.ts +0 -23
  195. package/templates/skeleton/node-service/packages/core/test/note.test.ts +0 -101
  196. package/templates/skeleton/node-service/packages/db/package.json +0 -12
  197. package/templates/skeleton/node-service/packages/db/src/index.ts +0 -1
  198. package/templates/skeleton/node-service/packages/db/src/note-store.ts +0 -100
  199. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +0 -100
  200. package/templates/skeleton/node-service/packages/shared/package.json +0 -11
  201. package/templates/skeleton/node-service/packages/shared/src/env.ts +0 -17
  202. package/templates/skeleton/node-service/packages/shared/src/errors.ts +0 -33
  203. package/templates/skeleton/node-service/packages/shared/src/index.ts +0 -3
  204. package/templates/skeleton/node-service/packages/shared/src/logger.ts +0 -20
  205. package/templates/skeleton/node-service/packages/shared/test/env.test.ts +0 -26
  206. package/templates/skeleton/node-service/packages/shared/test/errors.test.ts +0 -28
  207. package/templates/skeleton/node-service/packages/shared/test/logger.test.ts +0 -19
  208. package/templates/skeleton/node-service/pnpm-lock.yaml +0 -2402
  209. package/templates/skeleton/node-service/pnpm-workspace.yaml +0 -13
  210. package/templates/skeleton/node-service/scripts/build-artifact.mjs +0 -34
  211. package/templates/skeleton/node-service/services/api/package.json +0 -17
  212. package/templates/skeleton/node-service/services/api/src/adapters/spool-publisher.ts +0 -23
  213. package/templates/skeleton/node-service/services/api/src/handlers/create-note.ts +0 -40
  214. package/templates/skeleton/node-service/services/api/src/handlers/list-notes.ts +0 -23
  215. package/templates/skeleton/node-service/services/api/src/main.ts +0 -40
  216. package/templates/skeleton/node-service/services/api/src/server.ts +0 -172
  217. package/templates/skeleton/node-service/services/api/src/static-dir.ts +0 -20
  218. package/templates/skeleton/node-service/services/api/src/usecases/create-note.ts +0 -30
  219. package/templates/skeleton/node-service/services/api/src/usecases/list-notes.ts +0 -14
  220. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +0 -92
  221. package/templates/skeleton/node-service/services/api/test/create-note.handler.test.ts +0 -64
  222. package/templates/skeleton/node-service/services/api/test/create-note.usecase.test.ts +0 -43
  223. package/templates/skeleton/node-service/services/api/test/list-notes.test.ts +0 -48
  224. package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +0 -40
  225. package/templates/skeleton/node-service/services/api/test/package-manager.ts +0 -51
  226. package/templates/skeleton/node-service/services/api/test/server.test.ts +0 -208
  227. package/templates/skeleton/node-service/services/api/test/spool-publisher.test.ts +0 -32
  228. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +0 -29
  229. package/templates/skeleton/node-service/services/worker/package.json +0 -16
  230. package/templates/skeleton/node-service/services/worker/src/main.ts +0 -28
  231. package/templates/skeleton/node-service/services/worker/src/spool.ts +0 -60
  232. package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts +0 -38
  233. package/templates/skeleton/node-service/services/worker/test/process-note-created.test.ts +0 -34
  234. package/templates/skeleton/node-service/services/worker/test/spool.test.ts +0 -76
  235. package/templates/skeleton/node-service/tsconfig.base.json +0 -15
  236. package/templates/skeleton/node-service/tsconfig.json +0 -13
  237. package/templates/skeleton/node-service/vitest.config.ts +0 -12
@@ -1,26 +1,39 @@
1
- import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { access, lstat, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { initInstallSet, projectNameFor } from './init.js';
3
+ import { initInstallSet, initManifest, layerOnlyPaths, projectNameFor } from './init.js';
4
+ import { isReadableRulebook } from '../lib/elevated-paths.js';
4
5
  import { hookFilesReferencedIn } from '../lib/init-settings.js';
5
6
  import { loadHashHistory, presentInEveryRelease } from '../lib/history.js';
6
- import { agentOsInstallSet, agentOsLayerDirs } from '../lib/install-set.js';
7
- import { listTree } from '../lib/copy-tree.js';
8
- import { readManifest, sha256, writeManifest } from '../lib/manifest.js';
9
- import { isSafeSubstitutionValue, resolveInside } from '../lib/safe-path.js';
10
- import { detokenizeContent, substituteFileName } from '../lib/substitute.js';
11
- import { TARGETS } from '../lib/targets.js';
7
+ import { ALL_LAYERS, MANIFEST_REL, readManifest, sha256, writeManifest } from '../lib/manifest.js';
8
+ import { isSafeSubstitutionValue, resolveInside, resolveWritableInside } from '../lib/safe-path.js';
9
+ import { substituteContent } from '../lib/substitute.js';
12
10
  import { packageVersion } from '../lib/version.js';
13
11
  /** A user-facing failure: message is printed as-is, no stack trace. */
14
12
  export class UpgradeError extends Error {
15
13
  }
14
+ /**
15
+ * The sibling file `upgrade` writes RENDERED (already project-substituted)
16
+ * AGENTS.md bytes into when — and only when — CLAUDE.md's shim adoption is
17
+ * genuinely held back (round 5's content-based rule; see
18
+ * {@link isReadableRulebook}). Never `AGENTS.md` itself, and never recorded
19
+ * in the manifest. See {@link AgentsRescueStatus} for what decides whether
20
+ * this run touches it at all.
21
+ */
22
+ export const AGENTS_MD_RESCUE = 'AGENTS.md.rig-new';
16
23
  const SETTINGS = '.claude/settings.json';
17
24
  const CODEX_HOOKS = '.codex/hooks.json';
18
25
  const WIRING_PATHS = new Set([SETTINGS, CODEX_HOOKS]);
19
- /** The universal layer's architecture group — installed by `create`, never by `init`. */
20
- const ARCHITECTURE_ONLY = [
26
+ // Files only the pre-0.10 `create` shape installed. They remain recognition
27
+ // evidence for upgrades even though none of them is shipped by the new single
28
+ // payload. Keeping their names here does not restore a stack or architecture
29
+ // promise; it preserves the identity those old bytes were substituted with.
30
+ const LEGACY_CREATE_MARKERS = [
21
31
  '.claude/rules/architecture.md',
22
32
  '.claude/hooks/guard-core-purity.mjs',
23
33
  '.claude/hooks/guard-web-boundary.mjs',
34
+ '.claude/rules/node-ts.md',
35
+ '.claude/rules/aws-cdk.md',
36
+ '.claude/agents/cdk-diff-reviewer.md',
24
37
  ];
25
38
  async function exists(p) {
26
39
  try {
@@ -31,6 +44,90 @@ async function exists(p) {
31
44
  return false;
32
45
  }
33
46
  }
47
+ /**
48
+ * Whether `p` is a REGULAR file — `lstat`, never `access`, and never
49
+ * following a symlink. Quorum evidence (`detectLayersOnDisk`) uses this
50
+ * rather than `exists`: a directory or a symlink sitting at a workflow-layer
51
+ * path is not a file this release ever installed there, and counting it
52
+ * toward the quorum would let something that is not actually one of the
53
+ * layer's files push an unrelated layer over the threshold. `lstat` (not
54
+ * `stat`) so a symlink itself is correctly seen as "not a regular file"
55
+ * rather than resolved through to whatever it points at.
56
+ */
57
+ async function isRegularFile(p) {
58
+ try {
59
+ return (await lstat(p)).isFile();
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ /**
66
+ * The quorum a bootstrapped, opt-in layer must clear to be adopted: MORE
67
+ * THAN HALF of its own files already on disk (RP-180 round 4, blocker A).
68
+ *
69
+ * Chosen from the two populations this has to tell apart, not from either
70
+ * one's exact size: a genuine workflow rig with a deleted or corrupted
71
+ * manifest still has (nearly) all ~33 of its files on disk — comfortably
72
+ * over half. A Core-only rig that happens to have a stray file sharing a
73
+ * workflow-layer path (a hand-placed `journal/README.md`, a `.claude/
74
+ * queue.json` written for something unrelated) has one, or a small handful
75
+ * — comfortably under half. The threshold does not need to sit close to
76
+ * either population; it needs to separate them, and a strict majority does
77
+ * that with room on both sides. Pinned in `packages/cli/test/upgrade.test.ts`
78
+ * › `describe('upgrade — the layer-adoption quorum on the bootstrapped path
79
+ * (RP-180 round 4, blocker A)')`.
80
+ */
81
+ export const LAYER_ADOPTION_QUORUM = 0.5;
82
+ /**
83
+ * Which layer(s) to treat as installed when there is no manifest to read at
84
+ * all — `readManifest` returns `null` both for a genuinely missing file and
85
+ * for one `parseManifest` voided over ANY invalid field (a corrupt `layers`,
86
+ * an unsafe `version`, a non-array `stacks`, …), so this path is reached far
87
+ * more often than "this rig predates the manifest" alone.
88
+ *
89
+ * Two defects lived here in turn. The first (round 3): the fallback was
90
+ * `ALL_LAYERS` unconditionally — `presentInEveryRelease` below does not save
91
+ * it, since that guard only covers paths the hash history already has an
92
+ * entry for, and a workflow file added for the first time has none. The
93
+ * second (round 4, blocker A): the round-3 fix asked only "does AT LEAST ONE
94
+ * of this layer's files exist", so a Core-only rig with a single stray file
95
+ * sharing a workflow-layer path — the user's own `journal/README.md` is
96
+ * enough — re-adopted the WHOLE ~33-file layer the moment the manifest broke
97
+ * for any reason.
98
+ *
99
+ * The fix is a quorum, not a presence check: an opt-in layer is adopted only
100
+ * when {@link LAYER_ADOPTION_QUORUM} of its own files are already there.
101
+ * `process` is never put through this — it is not opt-in, and is normalised
102
+ * into the result unconditionally below, so a `layers` this function returns
103
+ * can never lack it (a `["workflow"]`-only result would leave every
104
+ * enforcement hook unowned). Below quorum, a layer's stray files are left
105
+ * alone entirely: not read into the plan, not adopted into the manifest —
106
+ * see the caller in `planUpgrade` for how "adopted" also bounds what gets
107
+ * WRITTEN (an adopted layer's ABSENT files are still never created).
108
+ */
109
+ export async function detectLayersOnDisk(repoDir) {
110
+ const notes = [];
111
+ const adopted = new Set();
112
+ for (const layer of ALL_LAYERS) {
113
+ if (layer === 'process')
114
+ continue; // never opt-in; normalised in below regardless of measurement
115
+ const paths = await layerOnlyPaths(layer);
116
+ let present = 0;
117
+ for (const rel of paths) {
118
+ if (await isRegularFile(onDisk(repoDir, rel)))
119
+ present += 1;
120
+ }
121
+ const isAdopted = present > paths.length * LAYER_ADOPTION_QUORUM;
122
+ notes.push({ layer, present, total: paths.length, adopted: isAdopted });
123
+ if (isAdopted)
124
+ adopted.add(layer);
125
+ }
126
+ // `process` is always present in the result — never a measurement, a
127
+ // normalisation, so a bootstrapped `layers` can never lack Core.
128
+ const layers = ['process', ...adopted];
129
+ return { layers, notes };
130
+ }
34
131
  /**
35
132
  * Where `rel` lives inside the rig — refused outright if it lands anywhere
36
133
  * else. Nothing should be able to produce such a path once the manifest is
@@ -45,6 +142,13 @@ function onDisk(repoDir, rel) {
45
142
  }
46
143
  return dest;
47
144
  }
145
+ async function writableOnDisk(repoDir, rel) {
146
+ const dest = await resolveWritableInside(repoDir, rel);
147
+ if (dest === null) {
148
+ throw new UpgradeError(`Refusing to touch "${rel}" through a symlink or outside ${repoDir}.`);
149
+ }
150
+ return dest;
151
+ }
48
152
  /**
49
153
  * The file's bytes, or `null` when it is genuinely **absent**.
50
154
  *
@@ -55,7 +159,7 @@ function onDisk(repoDir, rel) {
55
159
  */
56
160
  async function readIfPresent(repoDir, rel) {
57
161
  try {
58
- return await readFile(onDisk(repoDir, rel), 'utf8');
162
+ return await readFile(await writableOnDisk(repoDir, rel));
59
163
  }
60
164
  catch (error) {
61
165
  if (error.code === 'ENOENT')
@@ -63,61 +167,68 @@ async function readIfPresent(repoDir, rel) {
63
167
  throw error;
64
168
  }
65
169
  }
66
- /** Every stack overlay any target composes — the candidates a rig can carry. */
67
- function knownStacks() {
68
- return [...new Set(Object.values(TARGETS).flatMap((t) => t.stacks))];
170
+ /**
171
+ * The bytes THIS release would render for AGENTS.md, for THIS project,
172
+ * right now — the ONE implementation `upgrade` (deciding the rescue file's
173
+ * status) and `uninstall` (recognising a byte-identical rescue file as
174
+ * rig-owned) both call, closing round 4 cycle 4's finding that the two had
175
+ * separate re-renders which could drift apart. `null` only if a future
176
+ * release ever stopped shipping AGENTS.md as a plain substituted file (it
177
+ * does not today).
178
+ */
179
+ export async function renderedAgentsMd(project, layers) {
180
+ const files = await initManifest(layers);
181
+ const entry = files.find((f) => f.rel === 'AGENTS.md');
182
+ if (entry === undefined || entry.source === null)
183
+ return null;
184
+ const ctx = { projectName: project.name };
185
+ return substituteContent(await readFile(entry.source, 'utf8'), ctx);
186
+ }
187
+ /**
188
+ * What is actually sitting at {@link AGENTS_MD_RESCUE}, read ONCE at plan
189
+ * time (round 5, blocker 3) — `absent`, a plain file's bytes, or `unsafe`
190
+ * (a symlink anywhere in the path, a directory sitting at the leaf, or any
191
+ * other non-regular entry). Never throws: `applyUpgrade`'s preflight is what
192
+ * refuses to proceed on `unsafe`, and a healthy rig with unrelated clutter at
193
+ * this path must see `unsafe` reported, not an exception raised, so it can
194
+ * decide (per {@link AgentsRescueStatus}) to simply ignore it.
195
+ */
196
+ async function readRescueFile(repoDir) {
197
+ let dest;
198
+ try {
199
+ dest = await writableOnDisk(repoDir, AGENTS_MD_RESCUE);
200
+ }
201
+ catch {
202
+ return { kind: 'unsafe' };
203
+ }
204
+ let stat;
205
+ try {
206
+ stat = await lstat(dest);
207
+ }
208
+ catch (error) {
209
+ if (error.code === 'ENOENT')
210
+ return { kind: 'absent' };
211
+ return { kind: 'unsafe' };
212
+ }
213
+ if (!stat.isFile())
214
+ return { kind: 'unsafe' };
215
+ return { kind: 'file', bytes: await readFile(dest) };
69
216
  }
70
217
  /**
71
218
  * What a rig with no manifest looks like it is, from the files it has.
72
219
  *
73
- * Two signals, because one file is too thin a thread to hang a project's map
74
- * on: the architecture rules and hooks, which `create` installs and `init`
75
- * deliberately does not, **and** any stack-overlay file at all — `init`
76
- * composes no overlays, so one of those is proof on its own. The region comes
77
- * from the target whose stack set matches; it is the only value substitution
78
- * needs that the directory name cannot give.
79
- *
80
- * 🔴 Limit: a `create` rig that deleted every architecture file *and* every
81
- * stack file reads as an `init` rig. It is then offered the `init` flavour of
82
- * `CLAUDE.md` — a map of a different project shape. Nothing but a manifest
83
- * distinguishes those two rigs, which is why 0.4.0 writes one.
220
+ * RP-177 ships only one payload, but a pre-0.10 rig may still carry files that
221
+ * only `create` installed. Those retired paths are compatibility evidence: the
222
+ * new release never installs them, yet their presence preserves the raw
223
+ * directory identity old `create` substituted (including a trailing `-`).
224
+ * A readable manifest remains the stronger source and bypasses this heuristic.
84
225
  */
85
226
  async function detectInstall(repoDir) {
86
- const ctx = { projectName: '', projectScope: '', region: '' };
87
- const stacks = [];
88
- for (const stack of knownStacks()) {
89
- const [layer] = agentOsLayerDirs([stack]).slice(1);
90
- if (layer === undefined)
91
- continue;
92
- const rels = await listTree(layer.dir, {
93
- transformName: (name) => substituteFileName(name, ctx),
94
- });
95
- for (const rel of rels) {
96
- if (await exists(onDisk(repoDir, rel))) {
97
- stacks.push(stack);
98
- break;
99
- }
100
- }
101
- }
102
- let architectural = stacks.length > 0;
103
- for (const rel of ARCHITECTURE_ONLY) {
104
- if (architectural)
105
- break;
106
- architectural = await exists(onDisk(repoDir, rel));
227
+ for (const rel of LEGACY_CREATE_MARKERS) {
228
+ if (await exists(onDisk(repoDir, rel)))
229
+ return { kind: 'create', stacks: [], region: '' };
107
230
  }
108
- if (!architectural)
109
- return { kind: 'init', stacks: [], region: '' };
110
- const target = Object.values(TARGETS).find((t) => t.stacks.length === stacks.length && t.stacks.every((s) => stacks.includes(s)));
111
- return { kind: 'create', stacks, region: target?.defaultRegion ?? '' };
112
- }
113
- async function installSetFor(repoDir, kind, project, stacks) {
114
- if (kind === 'init')
115
- return initInstallSet(repoDir, project);
116
- return agentOsInstallSet(stacks, {
117
- projectName: project.name,
118
- projectScope: project.scope,
119
- region: project.region,
120
- });
231
+ return { kind: 'init', stacks: [], region: '' };
121
232
  }
122
233
  /**
123
234
  * Whether these bytes are a released version of this file.
@@ -126,11 +237,19 @@ async function installSetFor(repoDir, kind, project, stacks) {
126
237
  * bytes with the project's own values turned back into tokens — released
127
238
  * template bytes carry `__PROJECT_NAME__`, installed bytes never do.
128
239
  */
129
- function isReleasedVersion(history, rel, content, ctx) {
240
+ function isReleasedVersion(history, rel, content, projectName) {
130
241
  const known = history.files[rel];
131
242
  if (known === undefined || known.hashes.length === 0)
132
243
  return false;
133
- const candidates = new Set([sha256(content), sha256(detokenizeContent(content, ctx))]);
244
+ const candidates = new Set([sha256(content)]);
245
+ const decoded = content.toString('utf8');
246
+ // Detokenization is a text operation. Invalid UTF-8 must not be rewritten
247
+ // through replacement characters and then mistaken for released bytes.
248
+ // The raw-byte candidate above remains authoritative either way.
249
+ if (Buffer.from(decoded, 'utf8').equals(content)) {
250
+ const detokenized = projectName === '' ? decoded : decoded.replaceAll(projectName, '__PROJECT_NAME__');
251
+ candidates.add(sha256(detokenized));
252
+ }
134
253
  return known.hashes.some((hash) => candidates.has(hash));
135
254
  }
136
255
  /**
@@ -167,8 +286,9 @@ async function unwiresAnInstalledHook(repoDir, current, next) {
167
286
  */
168
287
  export async function planUpgrade(repoDir, options = {}) {
169
288
  const manifest = await readManifest(repoDir);
170
- // Detection is a whole-tree probe, and it answers a question the manifest
171
- // has already answered when there is one.
289
+ // Old create-only files are no longer shipped, but while they remain in a
290
+ // pre-0.10 rig they still distinguish its substitution identity. A readable
291
+ // manifest remains authoritative whenever one exists.
172
292
  const detected = manifest === null
173
293
  ? await detectInstall(repoDir)
174
294
  : { kind: manifest.kind, stacks: manifest.stacks, region: manifest.project.region };
@@ -209,28 +329,80 @@ export async function planUpgrade(repoDir, options = {}) {
209
329
  scope: bootstrapName,
210
330
  region: detected.region,
211
331
  };
212
- // Only overlays this version actually ships. An unknown name is not input
213
- // being dropped — there is no layer behind it to install from — and reading
214
- // a directory a manifest names would be reading a directory a manifest names.
215
- const shipped = new Set(knownStacks());
216
- const stacks = (manifest?.stacks ?? detected.stacks).filter((stack) => shipped.has(stack));
217
332
  const history = options.history ?? (await loadHashHistory());
218
- const files = await installSetFor(repoDir, kind, project, stacks);
219
- const ctx = {
220
- projectName: project.name,
221
- projectScope: project.scope,
222
- region: project.region,
223
- };
333
+ // RP-180: which layer(s) this rig is allowed to have refreshed.
334
+ //
335
+ // - A readable manifest is authoritative, and `parseManifest` already
336
+ // resolves its own absence of `layers` to `ALL_LAYERS` (every release
337
+ // before RP-180 shipped one payload, so an old manifest with no such
338
+ // field installed everything) — so `manifest.layers` is never actually
339
+ // `undefined` here.
340
+ // - No manifest at all (`bootstrapped` — `manifest === null`, which covers
341
+ // a genuinely missing file AND one `parseManifest` voided over ANY
342
+ // invalid field, not only a missing `layers` key) reads the candidate
343
+ // set off the disk itself, by quorum — see {@link detectLayersOnDisk}
344
+ // and its own history of what was tried here and why each attempt
345
+ // before it was not enough.
346
+ const inference = manifest === null ? await detectLayersOnDisk(repoDir) : null;
347
+ const layers = manifest?.layers ?? inference.layers;
348
+ // A path an OLDER manifest still names but this rig's OWN recorded layers
349
+ // no longer cover (a manifest hand-edited to drop a layer, or one from a
350
+ // release that shipped a layer this one renamed) falls out of `files`
351
+ // below exactly like a path RP-177 retired outright: never written, never
352
+ // deleted, simply no longer this plan's to manage.
353
+ const files = await initInstallSet(repoDir, project, layers);
354
+ // Blocker A's second half, and round 5's correction to it: even an
355
+ // ADOPTED bootstrapped opt-in layer must never manufacture a file it did
356
+ // not find, but "does not create it" is not the same thing as "forgets it
357
+ // existed". Round 4 dropped an absent adopted-layer path from the plan
358
+ // ENTIRELY, which left the rebuilt manifest with no memory of it at all —
359
+ // the very next ORDINARY upgrade (a readable manifest now) then read the
360
+ // absence as "never installed" and proposed `new`, silently reinstating an
361
+ // operator's deliberate deletion with no note that that is what it was
362
+ // doing. An intact manifest never has this problem: a `files` entry naming
363
+ // a path that is now absent already gets the `deleted` verdict, reason
364
+ // "installed by the rig, removed since — not restored", and carries the
365
+ // path's hash forward into the next manifest so this stays true on every
366
+ // later run too.
367
+ //
368
+ // The fix reuses exactly that path rather than inventing a new manifest
369
+ // field: for an absent file of an ADOPTED bootstrapped opt-in layer,
370
+ // `recorded` below is treated as the hash THIS RELEASE would have
371
+ // installed — the same value a normal install would have recorded for it
372
+ // — instead of `undefined`. That is the one piece of information the
373
+ // per-file logic needs to take the identical `deleted`/"not restored"
374
+ // branch an intact manifest takes; nothing else about that branch changes.
375
+ // A file that IS present is untouched by this: `recorded` stays whatever
376
+ // the (bootstrapped, so absent) manifest says, `undefined`, and the
377
+ // existing present-file logic decides its verdict exactly as before.
378
+ const inferredOptInPaths = new Set();
379
+ if (inference !== null) {
380
+ for (const layer of inference.layers) {
381
+ if (layer === 'process')
382
+ continue;
383
+ for (const rel of await layerOnlyPaths(layer))
384
+ inferredOptInPaths.add(rel);
385
+ }
386
+ }
224
387
  const actions = [];
225
388
  const contents = new Map();
226
389
  const nextFiles = {};
227
390
  let wiring = null;
228
391
  const wiringByPath = new Map();
392
+ // Only ever consulted by the CLAUDE.md/AGENTS.md coupling below, which runs
393
+ // after this loop and needs each file's on-disk bytes to re-vouch for
394
+ // CLAUDE.md's CURRENT content when it holds the shim back.
395
+ const currentBytesByRel = new Map();
229
396
  for (const file of files) {
230
- const current = await readIfPresent(repoDir, file.rel);
231
- const recorded = manifest?.files[file.rel];
397
+ const currentBytes = await readIfPresent(repoDir, file.rel);
398
+ if (currentBytes !== null)
399
+ currentBytesByRel.set(file.rel, currentBytes);
400
+ const recordedInManifest = manifest?.files[file.rel];
401
+ const recorded = recordedInManifest === undefined && currentBytes === null && inferredOptInPaths.has(file.rel)
402
+ ? sha256(file.content)
403
+ : recordedInManifest;
232
404
  contents.set(file.rel, file.content);
233
- if (current === null) {
405
+ if (currentBytes === null) {
234
406
  // Evidence, not a command. The manifest is the direct evidence; without
235
407
  // one, a path that shipped in *every* release the table covers was there
236
408
  // to be removed, so its absence is a decision. A path added later is
@@ -239,7 +411,16 @@ export async function planUpgrade(repoDir, options = {}) {
239
411
  actions.push({
240
412
  rel: file.rel,
241
413
  verdict: 'deleted',
242
- reason: 'installed by the rig, removed since — not restored',
414
+ reason: 'installed by the rig, removed since — not restored' +
415
+ // Round 4 advisory: this branch fires just as readily on a rig
416
+ // that finished the RP-186 migration long ago (CLAUDE.md already
417
+ // the `@AGENTS.md` shim) as on one mid-migration — "not
418
+ // restored" alone does not say that CLAUDE.md's own import makes
419
+ // AGENTS.md's absence load NO rulebook at all, not merely an old
420
+ // one.
421
+ (file.rel === 'AGENTS.md'
422
+ ? ' — CLAUDE.md imports it (`@AGENTS.md`), so no rulebook loads until it is back'
423
+ : ''),
243
424
  });
244
425
  nextFiles[file.rel] = recorded;
245
426
  }
@@ -256,6 +437,9 @@ export async function planUpgrade(repoDir, options = {}) {
256
437
  }
257
438
  continue;
258
439
  }
440
+ const current = currentBytes.toString('utf8');
441
+ const currentHash = sha256(currentBytes);
442
+ const releasedBytes = Buffer.from(file.content, 'utf8');
259
443
  // The two limits that keep wiring files' new replaceability from
260
444
  // disarming the rig, both measured rather than reasoned about.
261
445
  //
@@ -274,10 +458,10 @@ export async function planUpgrade(repoDir, options = {}) {
274
458
  const isWiring = WIRING_PATHS.has(file.rel);
275
459
  const wouldUnwireAnInstalledHook = isWiring && (await unwiresAnInstalledHook(repoDir, current, file.content));
276
460
  const vouched = isWiring
277
- ? recorded !== undefined && sha256(current) === recorded
278
- : (recorded !== undefined && sha256(current) === recorded) ||
279
- isReleasedVersion(history, file.rel, current, ctx);
280
- if (current === file.content) {
461
+ ? recorded !== undefined && currentHash === recorded
462
+ : (recorded !== undefined && currentHash === recorded) ||
463
+ isReleasedVersion(history, file.rel, currentBytes, project.name);
464
+ if (currentBytes.equals(releasedBytes)) {
281
465
  actions.push({ rel: file.rel, verdict: 'unchanged' });
282
466
  nextFiles[file.rel] = sha256(file.content);
283
467
  }
@@ -315,17 +499,178 @@ export async function planUpgrade(repoDir, options = {}) {
315
499
  nextFiles[file.rel] = recorded;
316
500
  }
317
501
  else {
502
+ // A path `init` found and left alone carries its provenance in `kept`
503
+ // (RP-182): the reason can then say what happened to the file since,
504
+ // instead of only that no release ever shipped these bytes. It names no
505
+ // version — `manifest.version` is rewritten by every upgrade, so it is
506
+ // not the version of the init that kept the file.
507
+ const kept = recorded === undefined ? manifest?.kept?.[file.rel] : undefined;
508
+ const keptReason = (since) => `kept by init (already here, not the rig's bytes), ${since} since — treated as yours`;
509
+ let reason = kept !== undefined
510
+ ? keptReason(currentHash === kept ? 'unchanged' : 'edited')
511
+ : recorded === undefined
512
+ ? 'not a version this rig ever released — treated as yours'
513
+ : 'edited since it was installed';
514
+ // PR #241 round 2, blocker 3: CLAUDE.md is the one file whose CONTENT
515
+ // decides which rulebook Claude Code reads at all. A CLAUDE.md that is
516
+ // not already the `@AGENTS.md` shim — this release's shim, an older
517
+ // rig's own full rulebook text, or genuinely unrelated content — makes
518
+ // Claude Code read THIS file INSTEAD OF AGENTS.md by default (Claude
519
+ // Code's own docs, "AGENTS.md", the `claude-md-or-agents-md` default).
520
+ // A `conflict` verdict already means "kept, never written"; without
521
+ // this the reader has no way to learn that keeping it also means the
522
+ // canonical AGENTS.md this release ships is not what gets read.
523
+ // The exact first line, not a prefix: `@AGENTS.mdEVIL` and
524
+ // `@AGENTS.md.bak` both satisfy `startsWith('@AGENTS.md')` while being
525
+ // neither Claude Code's import syntax nor anything it reads as one.
526
+ if (file.rel === 'CLAUDE.md' && current.split(/\r?\n/, 1)[0] !== '@AGENTS.md') {
527
+ reason +=
528
+ ' — this file is not the `@AGENTS.md` shim, so it shadows AGENTS.md: Claude Code ' +
529
+ 'reads it INSTEAD OF AGENTS.md by default. Adopt the shim by hand — replace this ' +
530
+ "file's content with `@AGENTS.md` plus anything Claude-Code-specific (see " +
531
+ 'docs/decisions/agents-md-canonical.md) — to have Claude Code read the canonical ' +
532
+ 'rulebook again.';
533
+ }
318
534
  actions.push({
319
535
  rel: file.rel,
320
536
  verdict: 'conflict',
321
- reason: recorded === undefined
322
- ? 'not a version this rig ever released — treated as yours'
323
- : 'edited since it was installed',
537
+ reason,
324
538
  templatePath: file.source,
325
539
  });
326
- // deliberately NOT recorded: the rig does not own these bytes
540
+ // deliberately NOT recorded in `files`: the rig does not own these bytes
327
541
  }
328
542
  }
543
+ // Round 5 design ruling (replacing round 4's verdict-only rule, gate cycle
544
+ // 4 blocker 1): a PRISTINE CLAUDE.md must never be replaced by the
545
+ // `@AGENTS.md` shim while the on-disk AGENTS.md cannot actually SERVE as
546
+ // the rulebook — but a customised AGENTS.md that still carries a non-empty
547
+ // `elevated-paths` block is exactly the STEADY STATE this project's own
548
+ // shipped rulebook tells every project to reach (extend the block for your
549
+ // own paths). Keying the hold on AGENTS.md's VERDICT alone (`conflict` or
550
+ // `deleted`) made every customisation indistinguishable from a broken
551
+ // rulebook — round 4's own bug. The hold now keys on CONTENT: absent
552
+ // (`deleted`), or present but unreadable as a rulebook
553
+ // ({@link isReadableRulebook} says no — no block, or a block with nothing
554
+ // left after stripping comments and blank lines). An AGENTS.md that IS a
555
+ // readable rulebook, however edited, lets the shim through — the shim then
556
+ // imports the user's own rulebook, which is the whole point of it — and
557
+ // AGENTS.md itself stays an perfectly ordinary, quiet `conflict`: no
558
+ // rescue file, no special wording, nothing this section touches.
559
+ //
560
+ // Both files are ordinary, independently-decided manifest-tracked paths,
561
+ // so nothing upstream of this point knows about the other when it decides
562
+ // either one's verdict; this is the one place both are visible together,
563
+ // after the loop, before the plan is returned.
564
+ //
565
+ // Held back means: not written, and re-vouched for its CURRENT bytes (the
566
+ // still-untouched old rulebook text) rather than the shim's — so the very
567
+ // next `upgrade`, once the human fixes AGENTS.md, evaluates CLAUDE.md as
568
+ // `unchanged` up front and `update` the moment this release's CLAUDE.md
569
+ // content next changes, instead of falling through to a manifest that no
570
+ // longer vouches for anything and staying `conflict` forever.
571
+ const claudeAction = actions.find((a) => a.rel === 'CLAUDE.md');
572
+ const agentsAction = actions.find((a) => a.rel === 'AGENTS.md');
573
+ const agentsUnreadable = (() => {
574
+ if (agentsAction === undefined)
575
+ return true; // no rulebook to speak of at all
576
+ if (agentsAction.verdict === 'deleted')
577
+ return true;
578
+ // `update` / `unchanged` / `new`: this release's OWN canonical AGENTS.md
579
+ // is what will be (or already is) on disk — always a readable rulebook,
580
+ // never a case that needs judging by content.
581
+ if (agentsAction.verdict !== 'conflict')
582
+ return false;
583
+ const bytes = currentBytesByRel.get('AGENTS.md');
584
+ // A `conflict` verdict is only ever reached from the branch that
585
+ // requires `currentBytes !== null` — see the per-file loop above — so
586
+ // this is always defined here; the `undefined` arm is a defensive
587
+ // fallback, never an observed path (same proof shape as the guard
588
+ // below).
589
+ return bytes === undefined || !isReadableRulebook(bytes.toString('utf8'));
590
+ })();
591
+ const heldBack = claudeAction !== undefined && claudeAction.verdict === 'update' && agentsUnreadable;
592
+ if (heldBack) {
593
+ claudeAction.verdict = 'conflict';
594
+ claudeAction.heldBack = true;
595
+ // Round 5, blocker 2: this text never hardcodes `mv` — the actual
596
+ // remedy (`mv` a written/verified rescue copy, or `rm` a differing one)
597
+ // depends on `agentsRescue`'s status, computed AFTER this point, and
598
+ // printed by the CLI's own closing section (`index.ts`'s
599
+ // `renderAgentsRescueNotice`), which reads that status directly. A
600
+ // fixed `mv` promise here was gate cycle 4's own blocker 2: it survived
601
+ // even the `differs` case, where `mv` would install bytes this run
602
+ // never wrote or verified as the live rulebook.
603
+ claudeAction.reason =
604
+ `held back — AGENTS.md ${agentsAction?.verdict === 'deleted'
605
+ ? 'is deleted'
606
+ : 'does not carry a readable rulebook (no non-empty `elevated-paths` block)'}, so writing the \`@AGENTS.md\` shim now would leave the rulebook unreadable. Resolve ` +
607
+ `AGENTS.md first — see the \`${AGENTS_MD_RESCUE}\` section below for the exact remedy, ` +
608
+ 'then run `create-agent-rig upgrade` again to finish adopting the shim.';
609
+ // `claudeAction.verdict === 'update'` (the guard above) is only ever
610
+ // reached from the branch that requires `currentBytes !== null` — see the
611
+ // per-file loop above — so `currentBytesByRel` always has an entry for
612
+ // CLAUDE.md here. A guard, not a `!` assertion (round 5 advisory): if this
613
+ // invariant is ever wrong, a clear internal error beats a crash on
614
+ // `undefined`. Pinned by `upgrade.test.ts`'s full RP-186 describe block,
615
+ // none of which would pass if this guard ever actually threw.
616
+ const heldBytes = currentBytesByRel.get('CLAUDE.md');
617
+ if (heldBytes === undefined) {
618
+ throw new UpgradeError('Internal: CLAUDE.md verdict was `update` with no current bytes on record — cannot hold it back.');
619
+ }
620
+ nextFiles['CLAUDE.md'] = sha256(heldBytes);
621
+ }
622
+ // Round 5, blocker 3: the rescue file's status is decided HERE, once, at
623
+ // plan time — never re-probed by `applyUpgrade`, so a dry run and a real
624
+ // run report the identical status and every refusal is known before a
625
+ // single byte is written. Round 4 cycle 4's blocker 3 was exactly the
626
+ // alternative: probing the filesystem again inside `applyUpgrade`, AFTER
627
+ // the ordinary payload writes had already run and BEFORE the manifest
628
+ // write — a symlink or a directory there crashed (or exited 1) with files
629
+ // already rewritten and the manifest left stale.
630
+ const rescueFile = await readRescueFile(repoDir);
631
+ const renderedAgentsForRescue = await renderedAgentsMd(project, layers);
632
+ const rescueMatchesRelease = rescueFile.kind === 'file' &&
633
+ renderedAgentsForRescue !== null &&
634
+ rescueFile.bytes.equals(Buffer.from(renderedAgentsForRescue, 'utf8'));
635
+ const agentsRescue = heldBack
636
+ ? rescueFile.kind === 'unsafe'
637
+ ? { holdBack: true, status: 'unsafe' }
638
+ : rescueFile.kind === 'absent'
639
+ ? { holdBack: true, status: 'would-write' }
640
+ : { holdBack: true, status: rescueMatchesRelease ? 'identical' : 'differs' }
641
+ : { holdBack: false, status: rescueMatchesRelease ? 'cleanup' : 'none' };
642
+ // A path an OLDER manifest still names but this release's single payload no
643
+ // longer contains at all (RP-177: the per-target stack overlays and the
644
+ // architecture-only group are retired outright, not merely excluded from
645
+ // one flavour). It is never written and never deleted — there is no
646
+ // template behind it to write, and deleting a file this run does not even
647
+ // read would be exactly the "resolve a stack name into a directory" step
648
+ // this release removes. It simply drops out of `nextFiles`: the rig no
649
+ // longer vouches for it, whatever state it is in on disk.
650
+ //
651
+ // Bootstrapped rigs (`manifest === null`) never reach this loop at all —
652
+ // there is no `manifest.files` to diff the current install set against, so
653
+ // "no longer shipped" has no `files` list to be true of. A claim with
654
+ // nothing behind it is not a smaller retired list; it is not a claim.
655
+ const currentRels = new Set(files.map((f) => f.rel));
656
+ for (const rel of Object.keys(manifest?.files ?? {})) {
657
+ if (currentRels.has(rel))
658
+ continue;
659
+ actions.push({
660
+ rel,
661
+ verdict: 'retired',
662
+ reason: 'no longer shipped by this release — the rig no longer manages it; it is now yours',
663
+ });
664
+ // deliberately NOT recorded in `nextFiles`: the rig drops its claim
665
+ }
666
+ // `kept` travels forward untouched, minus every path the plan now vouches
667
+ // for in `files` — a kept file that turned out to be a released version, or
668
+ // is byte-identical to this release, has become the rig's to manage.
669
+ const nextKept = {};
670
+ for (const [rel, hash] of Object.entries(manifest?.kept ?? {})) {
671
+ if (nextFiles[rel] === undefined)
672
+ nextKept[rel] = hash;
673
+ }
329
674
  // With no manifest, "there is a rig here" has to be *recognised*, not
330
675
  // assumed from a file existing: `CLAUDE.md` and `.claude/settings.json` are
331
676
  // in the install set and in nearly every repository ever opened by an agent.
@@ -342,16 +687,26 @@ export async function planUpgrade(repoDir, options = {}) {
342
687
  fromVersion: manifest?.version ?? null,
343
688
  toVersion: await packageVersion(),
344
689
  bootstrapped: manifest === null,
690
+ layerInference: inference?.notes ?? null,
345
691
  actions,
346
692
  wiring,
347
693
  wiringByPath,
348
694
  contents,
695
+ agentsRescue,
349
696
  manifest: {
350
697
  version: await packageVersion(),
351
698
  kind,
352
699
  project,
353
- stacks: [...stacks],
700
+ // Always empty: the single payload has no overlays to record any more.
701
+ // An older manifest's `stacks` is read (never crashes on an unknown
702
+ // entry — RP-177) but never carried forward.
703
+ stacks: [],
704
+ // Carried forward unchanged: `upgrade` refreshes the layers a rig
705
+ // already recorded, it never adds or drops one. Opting in happens
706
+ // through `init --layer workflow`.
707
+ layers,
354
708
  files: nextFiles,
709
+ ...(Object.keys(nextKept).length > 0 ? { kept: nextKept } : {}),
355
710
  },
356
711
  };
357
712
  }
@@ -363,6 +718,29 @@ export async function applyUpgrade(repoDir, plan, options = {}) {
363
718
  const written = [];
364
719
  if (options.dryRun === true)
365
720
  return { written };
721
+ // Round 5, blocker 3: refused BEFORE any write at all — not merely before
722
+ // the manifest write. Round 4 cycle 4 measured that probing the rescue
723
+ // path AFTER the ordinary payload writes left files rewritten and the
724
+ // manifest stale on exactly this refusal. `plan.agentsRescue` was already
725
+ // decided at plan time, so this is a plain read of a fact, not a new probe.
726
+ if (plan.agentsRescue.holdBack && plan.agentsRescue.status === 'unsafe') {
727
+ throw new UpgradeError(`Refusing to write the AGENTS.md rescue file: "${AGENTS_MD_RESCUE}" exists but is not a ` +
728
+ 'plain file — a symlink, a directory, or similar sits there. Move or remove it by hand, ' +
729
+ 'then run `create-agent-rig upgrade` again.');
730
+ }
731
+ // Preflight the complete write set, including the manifest, before changing
732
+ // any file. Then re-check each destination after mkdir and immediately before
733
+ // writeFile, so both pre-existing and newly-visible symlink components are
734
+ // refused.
735
+ const destinations = new Map();
736
+ for (const rel of [
737
+ ...plan.actions
738
+ .filter(({ verdict }) => verdict === 'update' || verdict === 'new')
739
+ .map(({ rel }) => rel),
740
+ MANIFEST_REL,
741
+ ]) {
742
+ destinations.set(rel, await writableOnDisk(repoDir, rel));
743
+ }
366
744
  for (const action of plan.actions) {
367
745
  if (action.verdict !== 'update' && action.verdict !== 'new')
368
746
  continue;
@@ -372,11 +750,33 @@ export async function applyUpgrade(repoDir, plan, options = {}) {
372
750
  if (content === undefined) {
373
751
  throw new UpgradeError(`Internal: no content planned for "${action.rel}" — nothing written.`);
374
752
  }
375
- const dest = onDisk(repoDir, action.rel);
753
+ const dest = destinations.get(action.rel);
376
754
  await mkdir(path.dirname(dest), { recursive: true });
377
- await writeFile(dest, content);
755
+ await writeFile(await writableOnDisk(repoDir, action.rel), content);
378
756
  written.push(action.rel);
379
757
  }
758
+ // Round 5: no new filesystem probing here — `plan.agentsRescue` already
759
+ // says exactly what (if anything) this run does, decided before any write
760
+ // above ever ran. `would-write` is the only status that writes; `identical`
761
+ // and `differs` are both "leave it exactly as it is"; `cleanup` is the only
762
+ // status that deletes; `unsafe` already returned above and `none` does
763
+ // nothing at all — round 4 cycle 4's "must not affect upgrade" case.
764
+ if (plan.agentsRescue.holdBack) {
765
+ if (plan.agentsRescue.status === 'would-write') {
766
+ const rendered = plan.contents.get('AGENTS.md');
767
+ if (rendered === undefined) {
768
+ throw new UpgradeError('Internal: no rendered content planned for "AGENTS.md".');
769
+ }
770
+ const rescueDest = await writableOnDisk(repoDir, AGENTS_MD_RESCUE);
771
+ await mkdir(path.dirname(rescueDest), { recursive: true });
772
+ await writeFile(rescueDest, rendered);
773
+ written.push(AGENTS_MD_RESCUE);
774
+ }
775
+ }
776
+ else if (plan.agentsRescue.status === 'cleanup') {
777
+ const rescueDest = await writableOnDisk(repoDir, AGENTS_MD_RESCUE);
778
+ await unlink(rescueDest);
779
+ }
380
780
  await writeManifest(repoDir, plan.manifest);
381
781
  return { written };
382
782
  }