create-agent-rig 0.9.1 → 0.10.1

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