create-agent-rig 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/CHANGELOG.md +419 -3
  2. package/README.md +267 -305
  3. package/package.json +8 -14
  4. package/packages/cli/dist/commands/create.js +56 -88
  5. package/packages/cli/dist/commands/doctor.js +213 -0
  6. package/packages/cli/dist/commands/init.js +111 -50
  7. package/packages/cli/dist/commands/integrations.js +468 -0
  8. package/packages/cli/dist/commands/setup-wizard.js +61 -0
  9. package/packages/cli/dist/commands/uninstall.js +1223 -0
  10. package/packages/cli/dist/commands/upgrade.js +472 -90
  11. package/packages/cli/dist/index.js +651 -46
  12. package/packages/cli/dist/integrations/declaration.js +158 -0
  13. package/packages/cli/dist/integrations/doctor-guards.js +126 -0
  14. package/packages/cli/dist/integrations/doctor-workflow.js +25 -0
  15. package/packages/cli/dist/integrations/mcp-json.js +107 -0
  16. package/packages/cli/dist/integrations/memory-doctor.js +124 -0
  17. package/packages/cli/dist/integrations/registry.js +22 -0
  18. package/packages/cli/dist/integrations/spawn.js +228 -0
  19. package/packages/cli/dist/integrations/spec-kit.js +280 -0
  20. package/packages/cli/dist/integrations/verify.js +193 -0
  21. package/packages/cli/dist/integrations/windows-job.js +275 -0
  22. package/packages/cli/dist/lib/elevated-paths.js +74 -0
  23. package/packages/cli/dist/lib/install-set.js +1 -44
  24. package/packages/cli/dist/lib/manifest.js +64 -5
  25. package/packages/cli/dist/lib/prompts.js +0 -23
  26. package/packages/cli/dist/lib/safe-path.js +193 -2
  27. package/packages/cli/dist/lib/safe-text.js +78 -0
  28. package/packages/cli/dist/lib/substitute.js +1 -52
  29. package/packages/cli/dist/lib/summary.js +5 -5
  30. package/packages/cli/dist/templates.js +4 -14
  31. package/scripts/prepare.mjs +28 -12
  32. package/templates/agent-os/subagent-routing.json +4 -4
  33. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +13 -9
  34. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +31 -6
  35. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +5 -5
  36. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
  37. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +3 -3
  38. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -6
  39. package/templates/agent-os/universal/.claude/agents/implementation-agent.md +41 -0
  40. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -4
  41. package/templates/agent-os/universal/.claude/agents/security-scanner.md +4 -2
  42. package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -2
  43. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +83 -4
  44. package/templates/agent-os/universal/.claude/rules/autonomy.md +55 -27
  45. package/templates/agent-os/universal/.claude/rules/invariants.md +9 -8
  46. package/templates/agent-os/universal/.claude/rules/workflow.md +47 -22
  47. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -1
  48. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +12 -9
  49. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +100 -2
  50. package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +236 -6
  51. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +2 -2
  52. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +4 -3
  53. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +16 -0
  54. package/templates/agent-os/universal/.claude/settings.json +0 -8
  55. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +13 -9
  56. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +31 -6
  57. package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +5 -5
  58. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
  59. package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +3 -3
  60. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -2
  61. package/templates/agent-os/universal/.codex/agents/implementation-agent.toml +6 -0
  62. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -2
  63. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  64. package/templates/agent-os/universal/.codex/agents/test-writer.toml +1 -1
  65. package/templates/agent-os/universal/.codex/hooks.json +0 -10
  66. package/templates/agent-os/universal/AGENTS.md +208 -107
  67. package/templates/agent-os/universal/CLAUDE.md +15 -162
  68. package/templates/agent-os/universal/PLAN.md +17 -10
  69. package/templates/agent-os/universal/docs/decisions/agents-md-canonical.md +313 -0
  70. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +15 -10
  71. package/templates/agent-os/universal/docs/decisions/review-lanes.md +12 -8
  72. package/templates/agent-os/universal/docs/decisions/session-start-wire-format.md +206 -0
  73. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +6 -3
  74. package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +235 -0
  75. package/templates/agent-os/universal/layers.json +25 -30
  76. package/templates/hash-history.json +8 -4
  77. package/templates/release-ledger.json +2 -1
  78. package/packages/cli/dist/lib/composition.js +0 -20
  79. package/packages/cli/dist/lib/targets.js +0 -28
  80. package/packages/cli/dist/policy/benchmark/corpus.js +0 -165
  81. package/packages/cli/dist/policy/core/adapter.js +0 -18
  82. package/packages/cli/dist/policy/core/coverage.js +0 -253
  83. package/packages/cli/dist/policy/core/decision-record.js +0 -287
  84. package/packages/cli/dist/policy/core/declaration.js +0 -127
  85. package/packages/cli/dist/policy/core/evidence-matrix.js +0 -94
  86. package/packages/cli/dist/policy/core/probe.js +0 -442
  87. package/packages/cli/dist/policy/core/registry.js +0 -115
  88. package/packages/cli/dist/policy/core/validation.js +0 -275
  89. package/packages/cli/dist/policy/core/vocabulary.js +0 -123
  90. package/packages/cli/dist/policy/harness/claude.js +0 -47
  91. package/packages/cli/dist/policy/harness/codex.js +0 -87
  92. package/packages/cli/dist/policy/harness/index.js +0 -15
  93. package/packages/cli/dist/policy/harness/shared-hooks.js +0 -28
  94. package/packages/cli/dist/policy/index.js +0 -17
  95. package/templates/agent-os/init/AGENTS.md +0 -201
  96. package/templates/agent-os/init/CLAUDE.md +0 -201
  97. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +0 -105
  98. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +0 -117
  99. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +0 -89
  100. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +0 -105
  101. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +0 -105
  102. package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +0 -117
  103. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +0 -6
  104. package/templates/agent-os/stack/node-ts/.claude/hooks/dod-checks.json +0 -1
  105. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +0 -85
  106. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +0 -89
  107. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +0 -68
  108. package/templates/agent-os/universal/.claude/rules/architecture.md +0 -81
  109. package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +0 -28
  110. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +0 -90
  111. package/templates/skeleton/aws-serverless/README.md +0 -181
  112. package/templates/skeleton/aws-serverless/apps/web/next.config.mjs +0 -17
  113. package/templates/skeleton/aws-serverless/apps/web/package.json +0 -19
  114. package/templates/skeleton/aws-serverless/apps/web/src/app/layout.tsx +0 -17
  115. package/templates/skeleton/aws-serverless/apps/web/src/app/page.tsx +0 -96
  116. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +0 -36
  117. package/templates/skeleton/aws-serverless/apps/web/src/lib/validate.ts +0 -23
  118. package/templates/skeleton/aws-serverless/apps/web/test/shared-validation.test.ts +0 -38
  119. package/templates/skeleton/aws-serverless/apps/web/tsconfig.json +0 -14
  120. package/templates/skeleton/aws-serverless/eslint.config.mjs +0 -20
  121. package/templates/skeleton/aws-serverless/gitignore +0 -56
  122. package/templates/skeleton/aws-serverless/infra/bin/app.ts +0 -79
  123. package/templates/skeleton/aws-serverless/infra/cdk.json +0 -3
  124. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +0 -248
  125. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +0 -45
  126. package/templates/skeleton/aws-serverless/infra/package.json +0 -18
  127. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +0 -301
  128. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +0 -137
  129. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +0 -120
  130. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +0 -44
  131. package/templates/skeleton/aws-serverless/package.json +0 -30
  132. package/templates/skeleton/aws-serverless/packages/core/package.json +0 -11
  133. package/templates/skeleton/aws-serverless/packages/core/src/events.ts +0 -14
  134. package/templates/skeleton/aws-serverless/packages/core/src/index.ts +0 -15
  135. package/templates/skeleton/aws-serverless/packages/core/src/note.ts +0 -69
  136. package/templates/skeleton/aws-serverless/packages/core/test/events.test.ts +0 -23
  137. package/templates/skeleton/aws-serverless/packages/core/test/note.test.ts +0 -101
  138. package/templates/skeleton/aws-serverless/packages/db/package.json +0 -14
  139. package/templates/skeleton/aws-serverless/packages/db/src/client.ts +0 -17
  140. package/templates/skeleton/aws-serverless/packages/db/src/index.ts +0 -2
  141. package/templates/skeleton/aws-serverless/packages/db/src/note-model.ts +0 -52
  142. package/templates/skeleton/aws-serverless/packages/db/test/note-model.test.ts +0 -91
  143. package/templates/skeleton/aws-serverless/packages/shared/package.json +0 -11
  144. package/templates/skeleton/aws-serverless/packages/shared/src/env.ts +0 -17
  145. package/templates/skeleton/aws-serverless/packages/shared/src/errors.ts +0 -33
  146. package/templates/skeleton/aws-serverless/packages/shared/src/index.ts +0 -3
  147. package/templates/skeleton/aws-serverless/packages/shared/src/logger.ts +0 -20
  148. package/templates/skeleton/aws-serverless/packages/shared/test/env.test.ts +0 -26
  149. package/templates/skeleton/aws-serverless/packages/shared/test/errors.test.ts +0 -28
  150. package/templates/skeleton/aws-serverless/packages/shared/test/logger.test.ts +0 -19
  151. package/templates/skeleton/aws-serverless/pnpm-lock.yaml +0 -2855
  152. package/templates/skeleton/aws-serverless/pnpm-workspace.yaml +0 -14
  153. package/templates/skeleton/aws-serverless/services/api/package.json +0 -15
  154. package/templates/skeleton/aws-serverless/services/api/src/adapters/sqs-publisher.ts +0 -26
  155. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +0 -69
  156. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +0 -37
  157. package/templates/skeleton/aws-serverless/services/api/src/list-main.ts +0 -12
  158. package/templates/skeleton/aws-serverless/services/api/src/main.ts +0 -21
  159. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +0 -41
  160. package/templates/skeleton/aws-serverless/services/api/src/usecases/list-notes.ts +0 -14
  161. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +0 -211
  162. package/templates/skeleton/aws-serverless/services/api/test/create-note.usecase.test.ts +0 -45
  163. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +0 -122
  164. package/templates/skeleton/aws-serverless/services/api/test/sqs-publisher.test.ts +0 -22
  165. package/templates/skeleton/aws-serverless/services/worker/package.json +0 -12
  166. package/templates/skeleton/aws-serverless/services/worker/src/handlers/note-created.ts +0 -15
  167. package/templates/skeleton/aws-serverless/services/worker/src/main.ts +0 -7
  168. package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts +0 -37
  169. package/templates/skeleton/aws-serverless/services/worker/test/note-created.test.ts +0 -61
  170. package/templates/skeleton/aws-serverless/tsconfig.base.json +0 -15
  171. package/templates/skeleton/aws-serverless/tsconfig.json +0 -16
  172. package/templates/skeleton/aws-serverless/vitest.config.ts +0 -14
  173. package/templates/skeleton/node-service/.github/workflows/ci.yml +0 -27
  174. package/templates/skeleton/node-service/.github/workflows/deploy.yml +0 -29
  175. package/templates/skeleton/node-service/README.md +0 -104
  176. package/templates/skeleton/node-service/apps/web/next.config.mjs +0 -17
  177. package/templates/skeleton/node-service/apps/web/package.json +0 -19
  178. package/templates/skeleton/node-service/apps/web/src/app/layout.tsx +0 -17
  179. package/templates/skeleton/node-service/apps/web/src/app/page.tsx +0 -96
  180. package/templates/skeleton/node-service/apps/web/src/lib/api.ts +0 -29
  181. package/templates/skeleton/node-service/apps/web/src/lib/validate.ts +0 -23
  182. package/templates/skeleton/node-service/apps/web/test/shared-validation.test.ts +0 -38
  183. package/templates/skeleton/node-service/apps/web/tsconfig.json +0 -14
  184. package/templates/skeleton/node-service/eslint.config.mjs +0 -20
  185. package/templates/skeleton/node-service/gitignore +0 -54
  186. package/templates/skeleton/node-service/package.json +0 -30
  187. package/templates/skeleton/node-service/packages/core/package.json +0 -11
  188. package/templates/skeleton/node-service/packages/core/src/events.ts +0 -14
  189. package/templates/skeleton/node-service/packages/core/src/index.ts +0 -15
  190. package/templates/skeleton/node-service/packages/core/src/note.ts +0 -69
  191. package/templates/skeleton/node-service/packages/core/test/events.test.ts +0 -23
  192. package/templates/skeleton/node-service/packages/core/test/note.test.ts +0 -101
  193. package/templates/skeleton/node-service/packages/db/package.json +0 -12
  194. package/templates/skeleton/node-service/packages/db/src/index.ts +0 -1
  195. package/templates/skeleton/node-service/packages/db/src/note-store.ts +0 -100
  196. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +0 -100
  197. package/templates/skeleton/node-service/packages/shared/package.json +0 -11
  198. package/templates/skeleton/node-service/packages/shared/src/env.ts +0 -17
  199. package/templates/skeleton/node-service/packages/shared/src/errors.ts +0 -33
  200. package/templates/skeleton/node-service/packages/shared/src/index.ts +0 -3
  201. package/templates/skeleton/node-service/packages/shared/src/logger.ts +0 -20
  202. package/templates/skeleton/node-service/packages/shared/test/env.test.ts +0 -26
  203. package/templates/skeleton/node-service/packages/shared/test/errors.test.ts +0 -28
  204. package/templates/skeleton/node-service/packages/shared/test/logger.test.ts +0 -19
  205. package/templates/skeleton/node-service/pnpm-lock.yaml +0 -2402
  206. package/templates/skeleton/node-service/pnpm-workspace.yaml +0 -13
  207. package/templates/skeleton/node-service/scripts/build-artifact.mjs +0 -34
  208. package/templates/skeleton/node-service/services/api/package.json +0 -17
  209. package/templates/skeleton/node-service/services/api/src/adapters/spool-publisher.ts +0 -23
  210. package/templates/skeleton/node-service/services/api/src/handlers/create-note.ts +0 -40
  211. package/templates/skeleton/node-service/services/api/src/handlers/list-notes.ts +0 -23
  212. package/templates/skeleton/node-service/services/api/src/main.ts +0 -40
  213. package/templates/skeleton/node-service/services/api/src/server.ts +0 -172
  214. package/templates/skeleton/node-service/services/api/src/static-dir.ts +0 -20
  215. package/templates/skeleton/node-service/services/api/src/usecases/create-note.ts +0 -30
  216. package/templates/skeleton/node-service/services/api/src/usecases/list-notes.ts +0 -14
  217. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +0 -92
  218. package/templates/skeleton/node-service/services/api/test/create-note.handler.test.ts +0 -64
  219. package/templates/skeleton/node-service/services/api/test/create-note.usecase.test.ts +0 -43
  220. package/templates/skeleton/node-service/services/api/test/list-notes.test.ts +0 -48
  221. package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +0 -40
  222. package/templates/skeleton/node-service/services/api/test/package-manager.ts +0 -51
  223. package/templates/skeleton/node-service/services/api/test/server.test.ts +0 -208
  224. package/templates/skeleton/node-service/services/api/test/spool-publisher.test.ts +0 -32
  225. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +0 -29
  226. package/templates/skeleton/node-service/services/worker/package.json +0 -16
  227. package/templates/skeleton/node-service/services/worker/src/main.ts +0 -28
  228. package/templates/skeleton/node-service/services/worker/src/spool.ts +0 -60
  229. package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts +0 -38
  230. package/templates/skeleton/node-service/services/worker/test/process-note-created.test.ts +0 -34
  231. package/templates/skeleton/node-service/services/worker/test/spool.test.ts +0 -76
  232. package/templates/skeleton/node-service/tsconfig.base.json +0 -15
  233. package/templates/skeleton/node-service/tsconfig.json +0 -13
  234. package/templates/skeleton/node-service/vitest.config.ts +0 -12
@@ -7,7 +7,9 @@ Red → Green → Refactor, in that order, every time:
7
7
  1. **Red** — write the test that describes the behavior; run it; watch it fail.
8
8
  Use the `test-writer` agent for this step: it writes the failing test and is
9
9
  scoped so it cannot "helpfully" write the implementation too.
10
- 2. **Green** — the minimum implementation that makes the test pass.
10
+ 2. **Green** — the minimum implementation that makes the test pass. Use the
11
+ `implementation-agent` for this step: it starts from the failing test and
12
+ never edits a test to reach green.
11
13
  3. **Refactor** — clean up with the tests staying green.
12
14
 
13
15
  No implementation before its failing test exists. A bug fix starts with a test
@@ -34,9 +36,14 @@ that reproduces the bug.
34
36
  The session that wrote the code is measurably worse at reviewing it: it
35
37
  carries its own reasoning in context and will not challenge its own decisions
36
38
  the way a cold reader does. That is *why* `code-reviewer` is a separate
37
- subagent with a fresh context, and why the `pr-ship` gate fans reviewers out
38
- instead of self-checking. This isolation is load-bearing, not ceremony — do
39
- not "optimise" it away by reviewing in the authoring session.
39
+ subagent with a fresh context. **The `pr-ship` skill, which fans reviewers
40
+ out automatically, ships only with the opt-in workflow layer** (`init
41
+ --layer workflow`; `AGENTS.md`'s "The opt-in workflow layer" section) —
42
+ without it, the session itself dispatches `code-reviewer` (and
43
+ `security-scanner`/`prose-reviewer` when their triggers fire) directly,
44
+ before opening or merging a PR. Either way, this isolation is load-bearing,
45
+ not ceremony — do not "optimise" it away by reviewing in the authoring
46
+ session.
40
47
 
41
48
  A reviewer whose definition pins its model is never dispatched with a call-site
42
49
  `model`: which model reads a change is the routing policy's decision, not the
@@ -61,6 +68,13 @@ travels one path to merge, in this order:
61
68
  | `fast-path` | documentation outside the rulebook, and derived files under those same two rules | `prose-reviewer` |
62
69
  | `model` | everything else, including anything unclassifiable | `code-reviewer`, **always** |
63
70
 
71
+ **`.claude/scripts/decision-router.mjs` ships with the opt-in workflow
72
+ layer** (`init --layer workflow`; `AGENTS.md`'s "The opt-in workflow layer"
73
+ section). Without it, this table is still the rule — it is just applied by
74
+ a human or the session rather than by the script, and the safe default on
75
+ any doubt is `model`, exactly as the script's own refusal-to-decide reads
76
+ below.
77
+
64
78
  `.claude/scripts/decision-router.mjs` decides this from the **committed**
65
79
  diff's paths — an uncommitted edit is not routed — and **risk flags escalate
66
80
  ahead of all three**: a file under a declared elevated path, a dependency
@@ -70,8 +84,8 @@ travels one path to merge, in this order:
70
84
  so it never reaches the prose lane; `.md`/`.mdx` files and test paths that
71
85
  provision nothing are inert, so a README inside an elevated directory does
72
86
  not escalate on that ground alone. **Rulebook paths are exempt from that
73
- carve-out** — `CLAUDE.md`, anything under `.claude/`, and the decision
74
- records under `docs/decisions/`, which are extracted rationale and reviewed
87
+ carve-out** — `CLAUDE.md`, `AGENTS.md`, anything under `.claude/`, and the
88
+ decision records under `docs/decisions/`, which are extracted rationale and reviewed
75
89
  like the rules they explain. The inert set is otherwise those two extensions
76
90
  and test paths exactly — **not** the router's own notion of prose, which is
77
91
  `.md`/`.txt`. Neither set contains the other, and reconciling them breaks a
@@ -96,19 +110,29 @@ travels one path to merge, in this order:
96
110
  parsing, file handling, or outbound calls;
97
111
  - `prose-reviewer` when it touches the documents that instruct agents — a
98
112
  rule file, a skill, an agent spec, a decision record under
99
- `docs/decisions/`, `CLAUDE.md`, the README. In this layer the prose *is*
113
+ `docs/decisions/`, `CLAUDE.md`, `AGENTS.md`, the README. In this layer the prose *is*
100
114
  the implementation, and it fails the same way code does: silently, in the
101
115
  direction of false confidence;
102
- - an infrastructure review when it touches infrastructure (the stack layer
103
- names the reviewing agent for the target).
104
-
105
- The `pr-ship` skill drives this fan-out and returns a SHIP / HOLD verdict
106
- with named blockers; blocking findings are resolved, not argued with.
116
+ - any additional review a repository rule explicitly names for the touched
117
+ surface.
118
+
119
+ **The `pr-ship` skill ships only with the opt-in workflow layer**
120
+ (`init --layer workflow`) and, where installed, drives this fan-out and
121
+ returns a SHIP / HOLD verdict with named blockers. **Without the layer,
122
+ the session itself drives the same fan-out** — dispatch each reviewer the
123
+ table and the triggers above name, read every verdict, and resolve every
124
+ blocking finding the same way `pr-ship` would; nothing about the review
125
+ floor changes with or without the skill. Blocking findings are resolved,
126
+ not argued with, either way.
107
127
 
108
128
  **A verdict is a block, not a sentence.** Every gate ends its report with one
109
- fenced `json` block of the shape `.claude/scripts/lib/verdict.mjs` defines,
110
- and `pr-ship` runs `node .claude/scripts/verdict.mjs check` on each answer
111
- **before** it decides anything from it. A report that does not parse — no
129
+ fenced `json` block of the shape `.claude/scripts/lib/verdict.mjs` defines
130
+ (Core — `verdict.mjs` and its two dependencies ship unconditionally,
131
+ `docs/decisions/workflow-layer-split.md` explains why), and `node
132
+ .claude/scripts/verdict.mjs check` runs on each answer **before** anything
133
+ is decided from it — `pr-ship` runs it when the layer is installed; the
134
+ session runs the same command by hand otherwise. A report that does not
135
+ parse — no
112
136
  block, a word no gate returns, a blocker naming no rule, a stop naming no
113
137
  blocker — is `incomplete`: the reviewer did not answer, which is neither a
114
138
  pass nor a stop. Reading it as a pass is the failure the check exists to
@@ -118,13 +142,14 @@ travels one path to merge, in this order:
118
142
  3. **Merge — on an explicit, non-lazy criterion.** Do not trust a watcher
119
143
  command that can exit before the checks have even registered. Confirm that
120
144
  the **required** check completed successfully **for this commit** — a list
121
- that is merely "not failing yet" is not a pass. The concrete command is
122
- stack-specific and lives in `stack/*`; the criterion here does not name one.
145
+ that is merely "not failing yet" is not a pass. The concrete command is the
146
+ repository's documented check command when it names one; otherwise query
147
+ the hosting service's check runs by the exact head SHA.
123
148
 
124
- **Post-merge tail:** verify the deployed surface is healthy (the target's
125
- post-deploy verdict — `autonomy.md`), then update `PLAN.md` (close the task,
126
- record any follow-up in a queue). Merge is not the finish line; a healthy
127
- runtime and an honest plan are.
149
+ **Post-merge tail:** update `PLAN.md` (close the task, record any follow-up in
150
+ a queue). When the repository's own documentation declares a runtime or other
151
+ operational surface, run the health check it documents. Merge is not the finish
152
+ line; the queue and every declared operational check must reflect reality.
128
153
 
129
154
  ## PR policy
130
155
 
@@ -141,7 +166,7 @@ A change is done when **all** of these hold:
141
166
  - [ ] A test written first demonstrates the new behavior (and failed before the change)
142
167
  - [ ] The full test suite is green — nothing skipped, nothing weakened
143
168
  - [ ] Lint and typecheck are clean
144
- - [ ] Layer boundaries respected (no new cross-layer imports; core still pure)
169
+ - [ ] Repository-specific invariants and declared boundaries remain satisfied
145
170
  - [ ] No secrets, credentials, or personal data in code, config, or fixtures
146
171
  - [ ] Docs touched by the change (README, rules) are updated
147
172
  - [ ] The autonomy tier of the change was checked and honored (`autonomy.md`)
@@ -1193,7 +1193,7 @@ if (invokedDirectly()) {
1193
1193
  process.stderr.write(
1194
1194
  'decision-router: nothing in this project declares an elevated path, so the ' +
1195
1195
  '`elevated-path` risk flag cannot be evaluated and no lane can be trusted. Add an ' +
1196
- '`elevated-paths` block to CLAUDE.md or a rule file. Nothing was routed — treat ' +
1196
+ '`elevated-paths` block to AGENTS.md or a rule file. Nothing was routed — treat ' +
1197
1197
  'this as the expensive lane, never as a cheap one.\n',
1198
1198
  );
1199
1199
  process.exit(1);
@@ -36,15 +36,18 @@ import { dirname, join } from 'node:path';
36
36
  * detecting. Reading the declaration removes that failure mode instead of
37
37
  * monitoring it.
38
38
  *
39
- * `CLAUDE.md` carries the project's own paths; each stack layer's rule file
40
- * carries the ones that only exist in that shape (`infra/` comes from the
41
- * infrastructure layer, and a project without one must not declare it). Seeding
42
- * every path in one place would declare directories that do not exist in half the
43
- * targets — and a gate declared over a missing directory reports "clean" while
44
- * looking nowhere.
39
+ * `AGENTS.md` carries the project's own paths — it is the canonical rulebook
40
+ * since RP-186, and `CLAUDE.md` is a short shim that imports it and declares
41
+ * no block of its own; reading both costs nothing when a not-yet-migrated
42
+ * rig still carries the full text in `CLAUDE.md`. Each stack layer's rule
43
+ * file carries the ones that only exist in that shape (`infra/` comes from
44
+ * the infrastructure layer, and a project without one must not declare it).
45
+ * Seeding every path in one place would declare directories that do not
46
+ * exist in half the targets — and a gate declared over a missing directory
47
+ * reports "clean" while looking nowhere.
45
48
  */
46
49
  export const readDeclaredPaths = (projectRoot, { readFile = readFileSync, listDir = null } = {}) => {
47
- const sources = [join(projectRoot, 'CLAUDE.md')];
50
+ const sources = [join(projectRoot, 'AGENTS.md'), join(projectRoot, 'CLAUDE.md')];
48
51
  try {
49
52
  const rulesDir = join(projectRoot, '.claude', 'rules');
50
53
  const entries = listDir ? listDir(rulesDir) : readdirSync(rulesDir);
@@ -52,7 +55,7 @@ export const readDeclaredPaths = (projectRoot, { readFile = readFileSync, listDi
52
55
  if (entry.endsWith('.md')) sources.push(join(rulesDir, entry));
53
56
  }
54
57
  } catch {
55
- // no rules directory — CLAUDE.md alone then
58
+ // no rules directory — AGENTS.md and CLAUDE.md alone then
56
59
  }
57
60
 
58
61
  const declared = [];
@@ -396,7 +399,7 @@ export const sweep = ({ prs = [], elevatedPaths = [], epoch = null } = {}) => {
396
399
  findings.push({
397
400
  kind: 'no-elevated-paths-declared',
398
401
  why:
399
- 'CLAUDE.md declares no `elevated-paths` block, so this sweep cannot tell ' +
402
+ 'AGENTS.md declares no `elevated-paths` block, so this sweep cannot tell ' +
400
403
  'an elevated merge from an ordinary one. Until it does, "no findings" ' +
401
404
  'means "did not look".',
402
405
  actions: ['journal-line', 'escalation-issue'],
@@ -208,6 +208,66 @@ export const manifestFilesOf = (root) => {
208
208
  return files;
209
209
  };
210
210
 
211
+ /** The closed set `layers.json` names today — the only strings `layersOf` ever echoes back. */
212
+ const KNOWN_LAYERS = ['process', 'workflow'];
213
+
214
+ /**
215
+ * Which `layers.json` layer(s) this rig recorded installing (RP-180), or
216
+ * `null` when there is no manifest to read at all — a third answer, not a
217
+ * guess: `report()` prints nothing about layers in that case rather than
218
+ * claiming either "Core only" or "every layer".
219
+ *
220
+ * A manifest with no `layers` key (every release before RP-180) means "every
221
+ * layer" — the CLI's own default (`packages/cli/src/lib/manifest.ts`,
222
+ * `LEGACY_LAYERS`), restated here rather than imported: this script ships
223
+ * standalone into a generated rig and has no access to the CLI package.
224
+ *
225
+ * RP-180 round 3 security review: a PRESENT `layers` is filtered down to
226
+ * {@link KNOWN_LAYERS} and deduplicated — never echoed as typed, and never
227
+ * widened to "every layer" just because it failed to parse as a clean array.
228
+ * A committed manifest is untrusted input read by a script whose own output
229
+ * lands on a terminal: an unknown or non-string entry is dropped rather than
230
+ * printed, and a `layers` field that is present but not a usable array (the
231
+ * wrong type, or an array with nothing recognisable in it) reports as
232
+ * `[]` — the caller's own "nothing to say" — never silently promoted back to
233
+ * "every layer", which is reserved for the field's TRUE ABSENCE. Bounded
234
+ * work regardless of the array's length: filter + `Set` is one pass, and the
235
+ * result can never hold more than {@link KNOWN_LAYERS}'s own two entries, so
236
+ * a manifest naming the same layer 100,000 times costs no more render-time
237
+ * output than naming it once.
238
+ *
239
+ * ⚠ **Exported for this file's own tests only — not a stable contract.**
240
+ * Round 4 changed this function's own return shape (`string[] | null` to
241
+ * `{ known, unrecognisedCount }`) to carry the unrecognised count out to
242
+ * `report()`; a caller outside this module that had come to depend on the
243
+ * OLD shape would have broken silently. `report()`'s own return value
244
+ * (`layers: string[] | null`, `layersUnrecognisedCount: number`) is the
245
+ * stable, documented surface — read that, or the rendered/`--json` output,
246
+ * never this function's return value directly.
247
+ */
248
+ export const layersOf = (root) => {
249
+ const parsed = readJson(path.join(root, ...MANIFEST_REL.split('/')));
250
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
251
+ if (!Object.prototype.hasOwnProperty.call(parsed, 'layers')) {
252
+ return { known: [...KNOWN_LAYERS], unrecognisedCount: 0 };
253
+ }
254
+ const { layers } = parsed;
255
+ if (!Array.isArray(layers)) return { known: [], unrecognisedCount: 0 };
256
+ const known = new Set();
257
+ let unrecognisedCount = 0;
258
+ // One pass, bounded regardless of the array's length: a manifest naming
259
+ // the same junk entry 100,000 times reports the same one count, never
260
+ // grows the output, and the entry itself is counted, never echoed.
261
+ for (const entry of layers) {
262
+ if (typeof entry === 'string' && KNOWN_LAYERS.includes(entry)) known.add(entry);
263
+ else unrecognisedCount += 1;
264
+ }
265
+ return { known: [...known], unrecognisedCount };
266
+ };
267
+
268
+ /** `workflow` reads as experimental everywhere doctor names it; every other layer is plain. */
269
+ const layerLabel = (layer) => (layer === 'workflow' ? `${layer} (experimental)` : layer);
270
+
211
271
  /**
212
272
  * The files directly in `dir`: `{ names, unreadable }`, or `null` when the
213
273
  * directory itself cannot be listed. One entry that cannot be stat'ed (a dangling
@@ -307,9 +367,29 @@ export const report = (root) => {
307
367
  const all = [...problems, ...audited.hooks];
308
368
  const audit = { verdict: verdictOf(all.map((r) => r.mark)), hooks: all };
309
369
  const absent = scopes.filter((scope) => !scope.present && scope.dir !== HOOKS_DIR).map((scope) => scope.dir);
370
+ const layerInfo = layersOf(root);
371
+ // `layers` is untrusted committed input, exactly like an exemption reason
372
+ // or a hook's own relative path elsewhere in this report — printed only
373
+ // through `printable`, never raw, and an unrecognised entry is COUNTED,
374
+ // never echoed. An empty `known` list (but a non-null result) means the
375
+ // field was PRESENT and had nothing recognisable in it, which reads
376
+ // differently from "nothing to say" and is worth its own line. A MIXED
377
+ // manifest (a known layer alongside junk) says both: the known layer by
378
+ // name, and that something else was there and dropped.
379
+ const layersLine = (() => {
380
+ if (layerInfo === null) return null;
381
+ const { known, unrecognisedCount } = layerInfo;
382
+ const suffix =
383
+ unrecognisedCount > 0
384
+ ? ` (+${unrecognisedCount} unrecognised ${unrecognisedCount === 1 ? 'entry' : 'entries'})`
385
+ : '';
386
+ if (known.length === 0) return `**layers:** (unrecognised — see ${MANIFEST_REL})`;
387
+ return `**layers:** ${printable(known.map(layerLabel).join(', '))}${printable(suffix)}`;
388
+ })();
310
389
  const lines = [
311
390
  `**doctor** — verdict: ${audit.verdict}`,
312
391
  '',
392
+ ...(layersLine !== null ? [layersLine, ''] : []),
313
393
  // Names come from the file system, reasons from a repo file; both are
314
394
  // stripped of control bytes here, once, where they reach the terminal.
315
395
  ...audit.hooks.map((hook) => `- ${hook.mark} · ${printable(hook.rel)} — ${printable(hook.detail)}`),
@@ -320,7 +400,14 @@ export const report = (root) => {
320
400
  `_Not checked by this script — still yours (${UNCHECKED.length}):_`,
321
401
  ...UNCHECKED.map((item) => `- ${item}`),
322
402
  ];
323
- return { ...audit, scopes, unchecked: UNCHECKED, rendered: lines.join('\n') };
403
+ return {
404
+ ...audit,
405
+ scopes,
406
+ layers: layerInfo === null ? null : layerInfo.known,
407
+ layersUnrecognisedCount: layerInfo === null ? 0 : layerInfo.unrecognisedCount,
408
+ unchecked: UNCHECKED,
409
+ rendered: lines.join('\n'),
410
+ };
324
411
  };
325
412
 
326
413
  const invokedDirectly = () => {
@@ -347,7 +434,18 @@ if (invokedDirectly()) {
347
434
  const result = report(root);
348
435
  process.stdout.write(
349
436
  args.includes('--json')
350
- ? `${JSON.stringify({ verdict: result.verdict, hooks: result.hooks, scopes: result.scopes, unchecked: result.unchecked }, null, 2)}\n`
437
+ ? `${JSON.stringify(
438
+ {
439
+ verdict: result.verdict,
440
+ hooks: result.hooks,
441
+ scopes: result.scopes,
442
+ layers: result.layers,
443
+ layersUnrecognisedCount: result.layersUnrecognisedCount,
444
+ unchecked: result.unchecked,
445
+ },
446
+ null,
447
+ 2,
448
+ )}\n`
351
449
  : `${result.rendered}\n`,
352
450
  );
353
451
  process.exit(result.verdict === 'STOP' ? 1 : 0);
@@ -439,14 +439,225 @@ const committedObjectOf = (projectRoot, path) => {
439
439
  }
440
440
  };
441
441
 
442
- const objectOf = (projectRoot, raw) =>
443
- execFileSync('git', ['-C', projectRoot, 'hash-object', '--stdin'], {
442
+ // RP-190: `--path <relativePath>` makes `hash-object` apply the same clean
443
+ // filter git itself would apply when staging that path — including the
444
+ // built-in CRLF-to-LF normalisation `core.autocrlf=true` performs on commit.
445
+ // Windows defaults `core.autocrlf` to `true`, so a checkout that
446
+ // re-materializes a tracked (LF-committed) claim record smudges it back to
447
+ // CRLF in the worktree; a bare `hash-object --stdin` (no `--path`) hashes
448
+ // those CRLF bytes literally, which can never equal the LF blob `HEAD:<path>`
449
+ // names, and the integrity check below reported "diverges" for a file whose
450
+ // CONTENT never changed. `--path` makes this comparison ask git's own
451
+ // question — "would `git add` at this path produce the committed blob" —
452
+ // instead of a byte-literal one: an edit git itself would see as a
453
+ // modification at this path still yields a different blob and still holds.
454
+ // LIMIT: the answer is now git's, so it follows this repository's attributes
455
+ // and configuration — a clean filter configured for the path decides what
456
+ // counts as "unmodified", exactly as it does for `git status`.
457
+ // Pinned in the generator's (absent in a generated rig)
458
+ // `test/template/revalidate.test.ts` › "does not report UNVERIFIABLE when a
459
+ // checkout re-materializes the tracked claim as CRLF", and in the generator's
460
+ // (absent in a generated rig) `test/template/revalidate.test.ts` › "still
461
+ // holds on a genuine content change to the tracked claim under
462
+ // core.autocrlf=true".
463
+ const objectOf = (projectRoot, raw, relativePath) =>
464
+ execFileSync('git', ['-C', projectRoot, 'hash-object', '--path', relativePath, '--stdin'], {
444
465
  encoding: 'utf8',
445
466
  env: withoutGitLocation(),
446
467
  input: raw,
447
468
  stdio: ['pipe', 'pipe', 'ignore'],
448
469
  }).trim();
449
470
 
471
+ /**
472
+ * At `BEFORE_CLOSE`, `targetSha` is resolved right after this item's OWN PR
473
+ * merged and was fetched — so it almost always differs from the value the
474
+ * claim recorded at take-up, even when nothing else landed on the default
475
+ * branch. This asks a narrower question than "did the target move": did it
476
+ * move by EXACTLY the one commit that IS `mergeCommit` — the SHA the CALLER
477
+ * vouches for as this item's own merge (resolved from the tracker's own PR
478
+ * metadata, e.g. `gh pr view <pr> --json mergeCommit -q .mergeCommit.oid`,
479
+ * never guessed from anything inside this function) — AND that this exact
480
+ * checkout's own tree is what that merge commit actually shipped.
481
+ *
482
+ * This decides identity, never text. An earlier version matched the moved
483
+ * commit's SUBJECT LINE against the ticket id in parentheses; a review gate
484
+ * found that unsound on two counts — an empty commit message silently
485
+ * disappeared from a naive `git log` line count, and ANY commit merely
486
+ * mentioning the ticket (a stray doc commit, a revert of the real merge)
487
+ * satisfied a text match without having merged anything. Nothing here reads a
488
+ * commit message any more.
489
+ *
490
+ * A LATER review gate found the SHA-identity version still unsound:
491
+ * `mergeCommit` is pure caller attestation — the only check tying it to
492
+ * anything was `toSha === mergeCommit`, which a lazy or hostile
493
+ * `--merge-commit "$(git rev-parse origin/master)"` satisfies trivially, no
494
+ * matter what actually advanced the target. Conditions 5, 6 and 7 below are
495
+ * the fix: a LOCAL binding to `HEAD` in `projectRoot` that nothing but the
496
+ * actual checkout can satisfy. Condition 2 stays load-bearing on its own —
497
+ * it is the only place `mergeCommit` is ever compared against the REAL
498
+ * target advance (`toSha`); conditions 3 and 4 check `fromSha`/`toSha`
499
+ * without reading `mergeCommit` at all, so a caller could otherwise name a
500
+ * commit that is not the target's tip yet still shares HEAD's tree, and
501
+ * nothing past condition 2 would catch it.
502
+ *
503
+ * True only when ALL of:
504
+ * 1. `fromSha`, `toSha` and `mergeCommit` are each a syntactically valid git
505
+ * object id, and `fromSha !== toSha`;
506
+ * 2. `toSha === mergeCommit` — the target's current tip literally IS the SHA
507
+ * the caller named, not merely a commit somewhere in the range that
508
+ * mentions the item;
509
+ * 3. `fromSha` is a git-ancestor of `toSha` (`merge-base --is-ancestor`) — a
510
+ * target that moved BACKWARDS (a rewind, a force-push) is never exempted,
511
+ * no matter what `mergeCommit` claims;
512
+ * 4. `git rev-list --count fromSha..toSha` is exactly `1` — counting commits,
513
+ * not lines of text, so a foreign commit sharing the window with the real
514
+ * merge (whether or not IT carries an empty message) still holds;
515
+ * 5. `mergeCommit` is neither `HEAD` nor an ancestor of it, in `projectRoot`
516
+ * — `HEAD` must still be the genuine PRE-merge checkout (the loop's own
517
+ * task branch, not yet fast-forwarded onto the merge). Without this, a
518
+ * checkout already sitting on the merge would compare its tree against
519
+ * itself and pass vacuously;
520
+ * 6. `fromSha` IS an ancestor of `HEAD` — `HEAD`'s own history must descend
521
+ * from the same baseline the target advanced from, so a tree match can
522
+ * only mean "this checkout's work is what the merge shipped", never a
523
+ * coincidence from an unrelated checkout that happens to hold the same
524
+ * files;
525
+ * 7. `git rev-parse mergeCommit^{tree}` equals `git rev-parse HEAD^{tree}` —
526
+ * the exempted commit's TREE is byte-identical to this checkout's own
527
+ * tree. A foreign commit whose tree DIFFERS is refused by this condition;
528
+ * a stale squash (the target moved before it landed) carries that extra
529
+ * content and is refused the same way. This condition judges CONTENT,
530
+ * never provenance: a commit built by any other route that happens to
531
+ * carry the exact byte-identical tree — meaning the target's content
532
+ * really is exactly what this run's own merge would have produced — is
533
+ * indistinguishable from a genuine squash merge and IS exempted. That is
534
+ * judged correct, not a gap: what is being protected is the target's
535
+ * content, not the mechanism that produced it.
536
+ *
537
+ * Any other shape — no `mergeCommit` supplied, a mismatch, a non-ancestor
538
+ * `fromSha`, more than one commit in range, `HEAD` already at or past the
539
+ * merge, `HEAD` not descended from `fromSha`, a tree mismatch, or git itself
540
+ * failing to resolve any of the above — returns false, and the caller keeps
541
+ * treating the target movement as scope drift exactly as it did before this
542
+ * existed. The false side is the safe default: this function only ever makes
543
+ * `scopeMoved` MORE permissive, never less, and every path that cannot prove
544
+ * "this SHA is my own merge, and this checkout IS the work it merged" falls
545
+ * back to holding.
546
+ *
547
+ * ⚠ What this function still CANNOT prove: it stops a LAZY or MISTAKEN
548
+ * `--merge-commit`, not an adversary who controls the run. `HEAD` is this
549
+ * run's own checkout — a hostile run can move it (`git checkout`,
550
+ * `git reset`) to whatever it likes before calling this, and `targetShaOf`
551
+ * (below) reads a local ref (`origin/HEAD`/`master`/`main`) a hostile run has
552
+ * already had every opportunity to rewrite. Every input here is something the
553
+ * SAME process that calls this function could have fabricated; the binding
554
+ * only raises the cost of an honest mistake, it does not authenticate the
555
+ * caller.
556
+ *
557
+ * Pinned in the generator's `test/template/revalidate.test.ts`
558
+ * — absent in a generated rig — › "the item's own REAL squash merge (actual
559
+ * file content, not an empty commit), nothing foreign, correctly named by
560
+ * --merge-commit", › "no --merge-commit supplied at all, even though the
561
+ * target moved by exactly one commit that would otherwise exempt it", › "a
562
+ * --merge-commit that does not match the target's new tip (the old recorded
563
+ * baseline sha)", › "a foreign commit plus the real merge in the same range,
564
+ * --merge-commit correctly naming the real one", › "an empty-message commit
565
+ * landing before the real merge, --merge-commit correctly naming the real
566
+ * one (a commit COUNT still catches it even though its subject line is
567
+ * blank)", › "a foreign EMPTY commit ahead of the genuine squash still holds
568
+ * — only the commit COUNT refuses it (conditions 2, 3, 5, 6 and 7 all pass
569
+ * on their own)", › "holds when content also drifted even though --merge-commit
570
+ * correctly identifies the target advance", › "rejects a target that moved
571
+ * backward off the recorded baseline, even though the final commit alone
572
+ * would look like a correct single-commit identity match", › "only exempts
573
+ * at BEFORE_CLOSE — the identical own-merge shape still holds on claim:scope
574
+ * at BEFORE_PR", › "a single FOREIGN commit named as --merge-commit does not
575
+ * exempt it — reproduces the gate-hold attack and proves it now holds", ›
576
+ * "a FABRICATED --merge-commit sharing HEAD's tree, correctly rooted at the
577
+ * baseline, but naming a commit the target never actually advanced to,
578
+ * still holds (condition 2 is load-bearing on its own)", › "HEAD already at
579
+ * the merge commit is vacuous, and still holds (a fast-forwarded checkout
580
+ * must not self-satisfy the exemption)", › "a squash merge of a branch that
581
+ * was NOT up to date still holds, even with a correctly-named
582
+ * --merge-commit (acceptable: the safe side)", › "a tree that matches by
583
+ * coincidence but shares no ancestry with the recorded baseline still holds
584
+ * (lineage, not just content)", › "a --merge-commit that is well-formed but
585
+ * names no object this repository has holds (git failure is the safe side,
586
+ * not a crash)", › "a foreign commit landed by another route is exempted
587
+ * when its tree is byte-identical to HEAD's (condition 7 judges content,
588
+ * not provenance)", › "pins evidence.ownMergeAdvance on the genuine
589
+ * own-merge case" and › "carries no evidence.ownMergeAdvance when content
590
+ * also drifted (the `!scopeContentMoved &&` conjunct only ever governs
591
+ * whether this evidence is emitted, never the verdict — content drift
592
+ * already holds on its own)".
593
+ */
594
+ const isAncestorOf = (projectRoot, ancestor, descendant) => {
595
+ try {
596
+ execFileSync('git', ['-C', projectRoot, 'merge-base', '--is-ancestor', ancestor, descendant], {
597
+ env: withoutGitLocation(),
598
+ stdio: ['ignore', 'ignore', 'ignore'],
599
+ });
600
+ return true;
601
+ } catch {
602
+ return false;
603
+ }
604
+ };
605
+
606
+ const revParseOrNull = (projectRoot, revision) => {
607
+ try {
608
+ return execFileSync('git', ['-C', projectRoot, 'rev-parse', '--verify', revision], {
609
+ encoding: 'utf8',
610
+ env: withoutGitLocation(),
611
+ stdio: ['ignore', 'pipe', 'ignore'],
612
+ }).trim();
613
+ } catch {
614
+ return null;
615
+ }
616
+ };
617
+
618
+ const isOwnMergeAdvance = (projectRoot, fromSha, toSha, mergeCommit) => {
619
+ if (
620
+ !GIT_OBJECT_ID.test(fromSha ?? '') ||
621
+ !GIT_OBJECT_ID.test(toSha ?? '') ||
622
+ !GIT_OBJECT_ID.test(mergeCommit ?? '') ||
623
+ fromSha === toSha ||
624
+ toSha !== mergeCommit
625
+ ) {
626
+ return false;
627
+ }
628
+ if (!isAncestorOf(projectRoot, fromSha, toSha)) return false;
629
+ let count;
630
+ try {
631
+ count = execFileSync(
632
+ 'git',
633
+ ['-C', projectRoot, 'rev-list', '--count', `${fromSha}..${toSha}`],
634
+ {
635
+ encoding: 'utf8',
636
+ env: withoutGitLocation(),
637
+ stdio: ['ignore', 'pipe', 'ignore'],
638
+ },
639
+ ).trim();
640
+ } catch {
641
+ return false;
642
+ }
643
+ if (count !== '1') return false;
644
+
645
+ // The non-attestable part: bind the exempted commit to THIS checkout, not
646
+ // merely to the caller's say-so. See the doc comment above for what
647
+ // conditions 5-7 prove and what they still cannot.
648
+ const head = revParseOrNull(projectRoot, 'HEAD');
649
+ if (head === null) return false;
650
+ // 5. Vacuity guard — HEAD must be the genuine pre-merge checkout.
651
+ if (head === mergeCommit || isAncestorOf(projectRoot, mergeCommit, head)) return false;
652
+ // 6. Lineage guard — HEAD must descend from the same recorded baseline.
653
+ if (!isAncestorOf(projectRoot, fromSha, head)) return false;
654
+ // 7. The actual content binding.
655
+ const mergeTree = revParseOrNull(projectRoot, `${mergeCommit}^{tree}`);
656
+ const headTree = revParseOrNull(projectRoot, `${head}^{tree}`);
657
+ if (mergeTree === null || headTree === null) return false;
658
+ return mergeTree === headTree;
659
+ };
660
+
450
661
  const readClaim = (projectRoot, path) => {
451
662
  const raw = readRepositoryFile(projectRoot, path, {
452
663
  label: 'claim record',
@@ -586,6 +797,7 @@ export const revalidateClaim = ({
586
797
  point,
587
798
  claimedState = 'in-progress',
588
799
  targetSha,
800
+ mergeCommit = null,
589
801
  allowCreate = false,
590
802
  isResume = false,
591
803
  }) => {
@@ -708,7 +920,7 @@ export const revalidateClaim = ({
708
920
  let claim;
709
921
  try {
710
922
  const read = readClaim(projectRoot, path);
711
- if (objectOf(projectRoot, read.raw) !== committedObject) {
923
+ if (objectOf(projectRoot, read.raw, pointer) !== committedObject) {
712
924
  throw new Error('tracked claim worktree content diverges from its committed Git version');
713
925
  }
714
926
  claim = read.parsed;
@@ -745,9 +957,24 @@ export const revalidateClaim = ({
745
957
  pairedFacts,
746
958
  });
747
959
 
748
- const scopeMoved =
749
- claim.fingerprints.scope.value !== current.scope.value ||
750
- claim.fingerprints.scope.targetSha !== current.scope.targetSha;
960
+ const scopeContentMoved = claim.fingerprints.scope.value !== current.scope.value;
961
+ const scopeTargetMoved = claim.fingerprints.scope.targetSha !== current.scope.targetSha;
962
+ // The only widening this change makes: a target that moved by nothing but
963
+ // this item's own squash merge is not scope drift. Content drift and
964
+ // commentary drift (below) stay hold-authoritative no matter what moved the
965
+ // target, and this only ever runs at BEFORE_CLOSE — BEFORE_PR has no merge
966
+ // of its own yet to exempt.
967
+ const scopeTargetIsOwnMerge =
968
+ point === 'BEFORE_CLOSE' &&
969
+ scopeTargetMoved &&
970
+ !scopeContentMoved &&
971
+ isOwnMergeAdvance(
972
+ projectRoot,
973
+ claim.fingerprints.scope.targetSha,
974
+ current.scope.targetSha,
975
+ mergeCommit,
976
+ );
977
+ const scopeMoved = scopeContentMoved || (scopeTargetMoved && !scopeTargetIsOwnMerge);
751
978
  const commentaryMoved = claim.fingerprints.commentary.value !== current.commentary.value;
752
979
  const movedFingerprintSet = [
753
980
  ...(scopeMoved ? ['scope'] : []),
@@ -763,6 +990,9 @@ export const revalidateClaim = ({
763
990
  evidence: {
764
991
  claim: pointer,
765
992
  ...(commentaryMoved && point !== 'BEFORE_CLOSE' ? { observedFingerprintSet: ['commentary'] } : {}),
993
+ ...(scopeTargetIsOwnMerge
994
+ ? { ownMergeAdvance: { from: claim.fingerprints.scope.targetSha, to: current.scope.targetSha } }
995
+ : {}),
766
996
  },
767
997
  identity: fingerprintIdentity(current),
768
998
  });
@@ -138,7 +138,7 @@ export const recordCompletedTier = ({ changedFiles, projectRoot, statePath, runD
138
138
  if (!declared || declared.length === 0) {
139
139
  throw new Error(
140
140
  'nothing in this project declares an elevated path, so no tier can be ' +
141
- 'computed: add an `elevated-paths` block to CLAUDE.md or a rule file. ' +
141
+ 'computed: add an `elevated-paths` block to AGENTS.md or a rule file. ' +
142
142
  'Treating the absence as `normal` would ration on a declaration that ' +
143
143
  'does not exist.',
144
144
  );
@@ -157,7 +157,7 @@ export const recordCompletedTier = ({ changedFiles, projectRoot, statePath, runD
157
157
  // and silently relocating it would make it useless.
158
158
  //
159
159
  // Note the asymmetry, which is deliberate: the DECLARATION is read from the
160
- // given `projectRoot` (the worktree's own `CLAUDE.md` is the rulebook the
160
+ // given `projectRoot` (the worktree's own `AGENTS.md` is the rulebook the
161
161
  // change was written against), while the STATE goes to the checkout that
162
162
  // outlives the task.
163
163
  const file = statePath ?? join(mainCheckoutRoot(projectRoot), '.claude', 'queue.state.json');
@@ -22,9 +22,10 @@ import { dirname, join } from 'node:path';
22
22
  // Imported, never copied: one home for the elevated declaration and one home for
23
23
  // the lane rule. A second copy of either would drift, and each copy would keep
24
24
  // passing on its own.
25
- // `readDeclaredPaths` unions CLAUDE.md with every .claude/rules/*.md declaration,
26
- // so this sweep sees exactly what the gate sweep sees — including the paths a
27
- // stack layer contributes for its own shape.
25
+ // `readDeclaredPaths` unions AGENTS.md (the canonical rulebook since RP-186)
26
+ // and CLAUDE.md with every .claude/rules/*.md declaration, so this sweep sees
27
+ // exactly what the gate sweep sees — including the paths a stack layer
28
+ // contributes for its own shape.
28
29
  import { elevatedPathsIn, laneOf, readDeclaredPaths } from './detect-missed-gate.mjs';
29
30
  // The one credential vocabulary (`guard-secret-file`, the commit sweep and this
30
31
  // diagnostic all read it): a second list of token shapes here would drift.