create-agent-rig 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/CHANGELOG.md +466 -3
  2. package/README.md +267 -299
  3. package/package.json +8 -14
  4. package/packages/cli/dist/commands/create.js +56 -88
  5. package/packages/cli/dist/commands/doctor.js +213 -0
  6. package/packages/cli/dist/commands/init.js +151 -54
  7. package/packages/cli/dist/commands/integrations.js +468 -0
  8. package/packages/cli/dist/commands/memory.js +63 -4
  9. package/packages/cli/dist/commands/setup-wizard.js +61 -0
  10. package/packages/cli/dist/commands/uninstall.js +1223 -0
  11. package/packages/cli/dist/commands/upgrade.js +489 -89
  12. package/packages/cli/dist/index.js +657 -49
  13. package/packages/cli/dist/integrations/declaration.js +158 -0
  14. package/packages/cli/dist/integrations/doctor-guards.js +126 -0
  15. package/packages/cli/dist/integrations/doctor-workflow.js +25 -0
  16. package/packages/cli/dist/integrations/mcp-json.js +107 -0
  17. package/packages/cli/dist/integrations/memory-doctor.js +124 -0
  18. package/packages/cli/dist/integrations/registry.js +22 -0
  19. package/packages/cli/dist/integrations/spawn.js +228 -0
  20. package/packages/cli/dist/integrations/spec-kit.js +280 -0
  21. package/packages/cli/dist/integrations/verify.js +193 -0
  22. package/packages/cli/dist/integrations/windows-job.js +275 -0
  23. package/packages/cli/dist/lib/elevated-paths.js +74 -0
  24. package/packages/cli/dist/lib/install-set.js +1 -44
  25. package/packages/cli/dist/lib/manifest.js +86 -9
  26. package/packages/cli/dist/lib/prompts.js +0 -23
  27. package/packages/cli/dist/lib/safe-path.js +193 -2
  28. package/packages/cli/dist/lib/safe-text.js +78 -0
  29. package/packages/cli/dist/lib/substitute.js +1 -52
  30. package/packages/cli/dist/lib/summary.js +5 -5
  31. package/packages/cli/dist/templates.js +4 -14
  32. package/scripts/prepare.mjs +28 -12
  33. package/templates/agent-os/subagent-routing.json +4 -4
  34. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +13 -9
  35. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +31 -6
  36. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +5 -5
  37. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
  38. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +3 -3
  39. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -6
  40. package/templates/agent-os/universal/.claude/agents/implementation-agent.md +41 -0
  41. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -4
  42. package/templates/agent-os/universal/.claude/agents/security-scanner.md +4 -2
  43. package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -2
  44. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +83 -4
  45. package/templates/agent-os/universal/.claude/rules/autonomy.md +55 -27
  46. package/templates/agent-os/universal/.claude/rules/invariants.md +9 -8
  47. package/templates/agent-os/universal/.claude/rules/workflow.md +47 -22
  48. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -1
  49. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +12 -9
  50. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +100 -2
  51. package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +236 -6
  52. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +43 -0
  53. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +18 -1
  54. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +2 -2
  55. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +273 -35
  56. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +16 -0
  57. package/templates/agent-os/universal/.claude/settings.json +0 -8
  58. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +13 -9
  59. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +31 -6
  60. package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +5 -5
  61. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
  62. package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +3 -3
  63. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -2
  64. package/templates/agent-os/universal/.codex/agents/implementation-agent.toml +6 -0
  65. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -2
  66. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  67. package/templates/agent-os/universal/.codex/agents/test-writer.toml +1 -1
  68. package/templates/agent-os/universal/.codex/hooks.json +0 -10
  69. package/templates/agent-os/universal/AGENTS.md +208 -107
  70. package/templates/agent-os/universal/CLAUDE.md +15 -162
  71. package/templates/agent-os/universal/PLAN.md +17 -10
  72. package/templates/agent-os/universal/docs/decisions/agents-md-canonical.md +313 -0
  73. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +15 -10
  74. package/templates/agent-os/universal/docs/decisions/review-lanes.md +12 -8
  75. package/templates/agent-os/universal/docs/decisions/session-start-wire-format.md +206 -0
  76. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +6 -3
  77. package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +235 -0
  78. package/templates/agent-os/universal/layers.json +25 -30
  79. package/templates/hash-history.json +73 -24
  80. package/templates/release-ledger.json +3 -1
  81. package/packages/cli/dist/lib/composition.js +0 -20
  82. package/packages/cli/dist/lib/targets.js +0 -28
  83. package/packages/cli/dist/policy/benchmark/corpus.js +0 -165
  84. package/packages/cli/dist/policy/core/adapter.js +0 -18
  85. package/packages/cli/dist/policy/core/coverage.js +0 -253
  86. package/packages/cli/dist/policy/core/decision-record.js +0 -287
  87. package/packages/cli/dist/policy/core/declaration.js +0 -127
  88. package/packages/cli/dist/policy/core/evidence-matrix.js +0 -94
  89. package/packages/cli/dist/policy/core/probe.js +0 -442
  90. package/packages/cli/dist/policy/core/registry.js +0 -115
  91. package/packages/cli/dist/policy/core/validation.js +0 -275
  92. package/packages/cli/dist/policy/core/vocabulary.js +0 -123
  93. package/packages/cli/dist/policy/harness/claude.js +0 -47
  94. package/packages/cli/dist/policy/harness/codex.js +0 -87
  95. package/packages/cli/dist/policy/harness/index.js +0 -15
  96. package/packages/cli/dist/policy/harness/shared-hooks.js +0 -28
  97. package/packages/cli/dist/policy/index.js +0 -17
  98. package/templates/agent-os/init/AGENTS.md +0 -201
  99. package/templates/agent-os/init/CLAUDE.md +0 -201
  100. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +0 -105
  101. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +0 -117
  102. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +0 -89
  103. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +0 -105
  104. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +0 -105
  105. package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +0 -117
  106. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +0 -6
  107. package/templates/agent-os/stack/node-ts/.claude/hooks/dod-checks.json +0 -1
  108. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +0 -85
  109. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +0 -89
  110. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +0 -68
  111. package/templates/agent-os/universal/.claude/rules/architecture.md +0 -81
  112. package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +0 -28
  113. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +0 -90
  114. package/templates/skeleton/aws-serverless/README.md +0 -181
  115. package/templates/skeleton/aws-serverless/apps/web/next.config.mjs +0 -17
  116. package/templates/skeleton/aws-serverless/apps/web/package.json +0 -19
  117. package/templates/skeleton/aws-serverless/apps/web/src/app/layout.tsx +0 -17
  118. package/templates/skeleton/aws-serverless/apps/web/src/app/page.tsx +0 -96
  119. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +0 -36
  120. package/templates/skeleton/aws-serverless/apps/web/src/lib/validate.ts +0 -23
  121. package/templates/skeleton/aws-serverless/apps/web/test/shared-validation.test.ts +0 -38
  122. package/templates/skeleton/aws-serverless/apps/web/tsconfig.json +0 -14
  123. package/templates/skeleton/aws-serverless/eslint.config.mjs +0 -20
  124. package/templates/skeleton/aws-serverless/gitignore +0 -56
  125. package/templates/skeleton/aws-serverless/infra/bin/app.ts +0 -79
  126. package/templates/skeleton/aws-serverless/infra/cdk.json +0 -3
  127. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +0 -248
  128. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +0 -45
  129. package/templates/skeleton/aws-serverless/infra/package.json +0 -18
  130. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +0 -301
  131. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +0 -137
  132. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +0 -120
  133. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +0 -44
  134. package/templates/skeleton/aws-serverless/package.json +0 -30
  135. package/templates/skeleton/aws-serverless/packages/core/package.json +0 -11
  136. package/templates/skeleton/aws-serverless/packages/core/src/events.ts +0 -14
  137. package/templates/skeleton/aws-serverless/packages/core/src/index.ts +0 -15
  138. package/templates/skeleton/aws-serverless/packages/core/src/note.ts +0 -69
  139. package/templates/skeleton/aws-serverless/packages/core/test/events.test.ts +0 -23
  140. package/templates/skeleton/aws-serverless/packages/core/test/note.test.ts +0 -101
  141. package/templates/skeleton/aws-serverless/packages/db/package.json +0 -14
  142. package/templates/skeleton/aws-serverless/packages/db/src/client.ts +0 -17
  143. package/templates/skeleton/aws-serverless/packages/db/src/index.ts +0 -2
  144. package/templates/skeleton/aws-serverless/packages/db/src/note-model.ts +0 -52
  145. package/templates/skeleton/aws-serverless/packages/db/test/note-model.test.ts +0 -91
  146. package/templates/skeleton/aws-serverless/packages/shared/package.json +0 -11
  147. package/templates/skeleton/aws-serverless/packages/shared/src/env.ts +0 -17
  148. package/templates/skeleton/aws-serverless/packages/shared/src/errors.ts +0 -33
  149. package/templates/skeleton/aws-serverless/packages/shared/src/index.ts +0 -3
  150. package/templates/skeleton/aws-serverless/packages/shared/src/logger.ts +0 -20
  151. package/templates/skeleton/aws-serverless/packages/shared/test/env.test.ts +0 -26
  152. package/templates/skeleton/aws-serverless/packages/shared/test/errors.test.ts +0 -28
  153. package/templates/skeleton/aws-serverless/packages/shared/test/logger.test.ts +0 -19
  154. package/templates/skeleton/aws-serverless/pnpm-lock.yaml +0 -2855
  155. package/templates/skeleton/aws-serverless/pnpm-workspace.yaml +0 -14
  156. package/templates/skeleton/aws-serverless/services/api/package.json +0 -15
  157. package/templates/skeleton/aws-serverless/services/api/src/adapters/sqs-publisher.ts +0 -26
  158. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +0 -69
  159. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +0 -37
  160. package/templates/skeleton/aws-serverless/services/api/src/list-main.ts +0 -12
  161. package/templates/skeleton/aws-serverless/services/api/src/main.ts +0 -21
  162. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +0 -41
  163. package/templates/skeleton/aws-serverless/services/api/src/usecases/list-notes.ts +0 -14
  164. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +0 -211
  165. package/templates/skeleton/aws-serverless/services/api/test/create-note.usecase.test.ts +0 -45
  166. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +0 -122
  167. package/templates/skeleton/aws-serverless/services/api/test/sqs-publisher.test.ts +0 -22
  168. package/templates/skeleton/aws-serverless/services/worker/package.json +0 -12
  169. package/templates/skeleton/aws-serverless/services/worker/src/handlers/note-created.ts +0 -15
  170. package/templates/skeleton/aws-serverless/services/worker/src/main.ts +0 -7
  171. package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts +0 -37
  172. package/templates/skeleton/aws-serverless/services/worker/test/note-created.test.ts +0 -61
  173. package/templates/skeleton/aws-serverless/tsconfig.base.json +0 -15
  174. package/templates/skeleton/aws-serverless/tsconfig.json +0 -16
  175. package/templates/skeleton/aws-serverless/vitest.config.ts +0 -14
  176. package/templates/skeleton/node-service/.github/workflows/ci.yml +0 -27
  177. package/templates/skeleton/node-service/.github/workflows/deploy.yml +0 -29
  178. package/templates/skeleton/node-service/README.md +0 -104
  179. package/templates/skeleton/node-service/apps/web/next.config.mjs +0 -17
  180. package/templates/skeleton/node-service/apps/web/package.json +0 -19
  181. package/templates/skeleton/node-service/apps/web/src/app/layout.tsx +0 -17
  182. package/templates/skeleton/node-service/apps/web/src/app/page.tsx +0 -96
  183. package/templates/skeleton/node-service/apps/web/src/lib/api.ts +0 -29
  184. package/templates/skeleton/node-service/apps/web/src/lib/validate.ts +0 -23
  185. package/templates/skeleton/node-service/apps/web/test/shared-validation.test.ts +0 -38
  186. package/templates/skeleton/node-service/apps/web/tsconfig.json +0 -14
  187. package/templates/skeleton/node-service/eslint.config.mjs +0 -20
  188. package/templates/skeleton/node-service/gitignore +0 -54
  189. package/templates/skeleton/node-service/package.json +0 -30
  190. package/templates/skeleton/node-service/packages/core/package.json +0 -11
  191. package/templates/skeleton/node-service/packages/core/src/events.ts +0 -14
  192. package/templates/skeleton/node-service/packages/core/src/index.ts +0 -15
  193. package/templates/skeleton/node-service/packages/core/src/note.ts +0 -69
  194. package/templates/skeleton/node-service/packages/core/test/events.test.ts +0 -23
  195. package/templates/skeleton/node-service/packages/core/test/note.test.ts +0 -101
  196. package/templates/skeleton/node-service/packages/db/package.json +0 -12
  197. package/templates/skeleton/node-service/packages/db/src/index.ts +0 -1
  198. package/templates/skeleton/node-service/packages/db/src/note-store.ts +0 -100
  199. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +0 -100
  200. package/templates/skeleton/node-service/packages/shared/package.json +0 -11
  201. package/templates/skeleton/node-service/packages/shared/src/env.ts +0 -17
  202. package/templates/skeleton/node-service/packages/shared/src/errors.ts +0 -33
  203. package/templates/skeleton/node-service/packages/shared/src/index.ts +0 -3
  204. package/templates/skeleton/node-service/packages/shared/src/logger.ts +0 -20
  205. package/templates/skeleton/node-service/packages/shared/test/env.test.ts +0 -26
  206. package/templates/skeleton/node-service/packages/shared/test/errors.test.ts +0 -28
  207. package/templates/skeleton/node-service/packages/shared/test/logger.test.ts +0 -19
  208. package/templates/skeleton/node-service/pnpm-lock.yaml +0 -2402
  209. package/templates/skeleton/node-service/pnpm-workspace.yaml +0 -13
  210. package/templates/skeleton/node-service/scripts/build-artifact.mjs +0 -34
  211. package/templates/skeleton/node-service/services/api/package.json +0 -17
  212. package/templates/skeleton/node-service/services/api/src/adapters/spool-publisher.ts +0 -23
  213. package/templates/skeleton/node-service/services/api/src/handlers/create-note.ts +0 -40
  214. package/templates/skeleton/node-service/services/api/src/handlers/list-notes.ts +0 -23
  215. package/templates/skeleton/node-service/services/api/src/main.ts +0 -40
  216. package/templates/skeleton/node-service/services/api/src/server.ts +0 -172
  217. package/templates/skeleton/node-service/services/api/src/static-dir.ts +0 -20
  218. package/templates/skeleton/node-service/services/api/src/usecases/create-note.ts +0 -30
  219. package/templates/skeleton/node-service/services/api/src/usecases/list-notes.ts +0 -14
  220. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +0 -92
  221. package/templates/skeleton/node-service/services/api/test/create-note.handler.test.ts +0 -64
  222. package/templates/skeleton/node-service/services/api/test/create-note.usecase.test.ts +0 -43
  223. package/templates/skeleton/node-service/services/api/test/list-notes.test.ts +0 -48
  224. package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +0 -40
  225. package/templates/skeleton/node-service/services/api/test/package-manager.ts +0 -51
  226. package/templates/skeleton/node-service/services/api/test/server.test.ts +0 -208
  227. package/templates/skeleton/node-service/services/api/test/spool-publisher.test.ts +0 -32
  228. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +0 -29
  229. package/templates/skeleton/node-service/services/worker/package.json +0 -16
  230. package/templates/skeleton/node-service/services/worker/src/main.ts +0 -28
  231. package/templates/skeleton/node-service/services/worker/src/spool.ts +0 -60
  232. package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts +0 -38
  233. package/templates/skeleton/node-service/services/worker/test/process-note-created.test.ts +0 -34
  234. package/templates/skeleton/node-service/services/worker/test/spool.test.ts +0 -76
  235. package/templates/skeleton/node-service/tsconfig.base.json +0 -15
  236. package/templates/skeleton/node-service/tsconfig.json +0 -13
  237. package/templates/skeleton/node-service/vitest.config.ts +0 -12
@@ -680,6 +680,23 @@ export const escalate = async (ticket, diagnosis, { env = process.env } = {}) =>
680
680
  return { ok: true };
681
681
  };
682
682
 
683
+ /**
684
+ * Jira refuses a summary longer than this, and creates nothing: `400
685
+ * {"errors":{"summary":"Summary can't exceed 255 characters."}}` (RP-121). The
686
+ * summary is therefore a prefix; the full `change` stays in the description,
687
+ * where the fingerprint lives too, so the dedupe does not depend on the cut.
688
+ * The cut never leaves half of a surrogate pair behind. Pinned in
689
+ * `test/template/queue-jira.test.ts` › "bounds the POSTed summary so a
690
+ * 2186-character change still files, keeping the full text in the body".
691
+ */
692
+ const MAX_SUMMARY_LENGTH = 255;
693
+
694
+ const boundedSummary = (text) => {
695
+ if (text.length <= MAX_SUMMARY_LENGTH) return text;
696
+ const cut = text.slice(0, MAX_SUMMARY_LENGTH);
697
+ return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
698
+ };
699
+
683
700
  /**
684
701
  * 🔴 INVARIANT 2: the agent never creates its own work. A proposal is labelled
685
702
  * `triage`, which `buildJql` excludes explicitly, and it never receives a ready
@@ -689,7 +706,7 @@ export const triageItemFor = (proposal) => {
689
706
  validateProposal(proposal);
690
707
  const fingerprint = fingerprintOf(proposal);
691
708
  return {
692
- title: `proposal: ${proposal.change}`,
709
+ title: boundedSummary(`proposal: ${proposal.change}`),
693
710
  body: [
694
711
  `- finding — ${proposal.finding}`,
695
712
  `- part to change — ${proposal.part}`,
@@ -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,10 +22,14 @@ 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';
30
+ // The one credential vocabulary (`guard-secret-file`, the commit sweep and this
31
+ // diagnostic all read it): a second list of token shapes here would drift.
32
+ import { SECRET_VALUE_PATTERNS } from './lib/secrets.mjs';
29
33
 
30
34
  /**
31
35
  * The audit trail for work that already happened: a record born **closed**.
@@ -210,37 +214,261 @@ const parseArgs = (argv) => {
210
214
 
211
215
  const daysAgo = (n) => new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10);
212
216
 
213
- /** Same rule as the gate sweep: "could not look" must never render as "clean". */
214
- const fetchMergedPrs = (since) => {
215
- try {
216
- return JSON.parse(
217
- execFileSync(
218
- 'gh',
219
- [
220
- 'pr',
221
- 'list',
222
- '--state',
223
- 'merged',
224
- '--limit',
225
- '100',
226
- '--search',
227
- `merged:>=${since}`,
228
- '--json',
229
- 'number,title,body,headRefName,mergedAt,url,files,changedFiles,authorAssociation',
230
- ],
231
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
232
- ),
233
- );
234
- } catch (error) {
235
- process.stderr.write(
236
- 'lane reconciliation: could not list merged PRs — the `gh` CLI is missing, ' +
237
- 'unauthenticated, or the API is unreachable. No lane was reconciled; do not ' +
238
- 'record an empty `external lane` block from this run. Use --input <file> to ' +
239
- 'work offline.\n' +
240
- ` ${String(error?.stderr ?? error?.message ?? error).trim().split('\n')[0]}\n`,
217
+ // --- Acquisition (RP-99) --------------------------------------------------------
218
+ //
219
+ // Two calls, not one. `gh pr list --json` does not know `authorAssociation` on
220
+ // every supported `gh` (2.71.2 answers `Unknown JSON field`), so the BASE
221
+ // projection asks only for fields every `gh` has, and the association — the
222
+ // trust signal — is fetched afterwards through one GraphQL query. When that
223
+ // second step fails for any reason the base list is returned WITHOUT the field
224
+ // and `reconcile()` treats those PRs as untrusted, and an answer is kept only
225
+ // when it names the same pull request URL the base list did — see the
226
+ // generator's test/template/reconcile-acquisition.test.ts (absent in a generated rig)
227
+ // › "reconcile() treats a PR that lost authorAssociation to a failed enrichment as untrusted when it crosses an elevated path"
228
+ // and › "drops an association whose answer names a different pull request URL — another host or repository never becomes trust".
229
+
230
+ /** The base projection: fields every supported `gh` serves. */
231
+ const BASE_FIELDS = ['number', 'title', 'body', 'headRefName', 'mergedAt', 'url', 'files', 'changedFiles'];
232
+ /** What GitHub returns for `authorAssociation`: an upper-case enum word. */
233
+ const ASSOCIATION_SHAPE = /^[A-Z_]{1,32}$/;
234
+ const MAX_PRS = 100;
235
+
236
+ /** The default runner: one `gh` child, stdout as text, a thrown error otherwise. */
237
+ const execGh = (file, args) =>
238
+ execFileSync(file, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
239
+
240
+ /** A base record the rest of this script can read: an object with a positive PR number. */
241
+ const isPrRecord = (row) =>
242
+ row !== null &&
243
+ typeof row === 'object' &&
244
+ !Array.isArray(row) &&
245
+ Number.isSafeInteger(row.number) &&
246
+ row.number > 0;
247
+
248
+ /**
249
+ * The merged PRs since `since`, plus the warnings the acquisition produced.
250
+ *
251
+ * Throws only from the BASE list — the caller classifies that with
252
+ * `classifyAcquisitionFailure`. A parsed base list that is not an array is
253
+ * thrown with `notAnArray: true` (JSON.parse succeeded, so a SyntaxError would
254
+ * misdescribe it). Enrichment never throws: it degrades to a warning.
255
+ */
256
+ export const fetchMergedPrs = (since, { exec = execGh } = {}) => {
257
+ const raw = exec('gh', [
258
+ 'pr',
259
+ 'list',
260
+ '--state',
261
+ 'merged',
262
+ '--limit',
263
+ String(MAX_PRS),
264
+ '--search',
265
+ `merged:>=${since}`,
266
+ '--json',
267
+ BASE_FIELDS.join(','),
268
+ ]);
269
+ const parsed = JSON.parse(raw);
270
+ if (!Array.isArray(parsed)) {
271
+ throw Object.assign(new Error('gh pr list answered with something other than a JSON array'), {
272
+ notAnArray: true,
273
+ });
274
+ }
275
+
276
+ const warnings = [];
277
+ const prs = [];
278
+ for (const row of parsed.slice(0, MAX_PRS)) {
279
+ if (!isPrRecord(row)) {
280
+ warnings.push('a merged-PR record without a positive numeric `number` was dropped from the sweep');
281
+ continue;
282
+ }
283
+ // Copy the base fields only; whatever else `gh` returned does not travel.
284
+ const record = {};
285
+ for (const field of BASE_FIELDS) if (row[field] !== undefined) record[field] = row[field];
286
+ prs.push(record);
287
+ }
288
+ if (prs.length === 0) return { prs, warnings };
289
+
290
+ const associations = fetchAuthorAssociations(prs, exec);
291
+ if (associations === null) {
292
+ warnings.push(
293
+ 'author association unavailable for this window — every external merge below is treated as untrusted origin',
241
294
  );
242
- process.exit(1);
295
+ return { prs, warnings };
296
+ }
297
+ for (const pr of prs) {
298
+ const answer = associations.get(pr.number);
299
+ if (answer === undefined) continue;
300
+ // `gh api graphql` asks its own default host, not the remote's: an answer
301
+ // about another pull request must never lend its association to this one.
302
+ if (typeof pr.url !== 'string' || answer.url !== pr.url) {
303
+ warnings.push(`PR #${pr.number}: an author association whose pull request URL does not match the listed one was dropped`);
304
+ continue;
305
+ }
306
+ const value = answer.authorAssociation;
307
+ if (typeof value === 'string' && ASSOCIATION_SHAPE.test(value)) pr.authorAssociation = value;
308
+ else warnings.push(`PR #${pr.number}: an author association of an unexpected shape was dropped`);
243
309
  }
310
+ return { prs, warnings };
311
+ };
312
+
313
+ /**
314
+ * `number → { url, authorAssociation }` for the listed PRs through one GraphQL
315
+ * query, or `null` when the answer cannot be trusted: a failed call, unusable
316
+ * JSON, a repository name of an unexpected shape. Never throws.
317
+ */
318
+ const fetchAuthorAssociations = (prs, exec) => {
319
+ try {
320
+ const repo = JSON.parse(exec('gh', ['repo', 'view', '--json', 'nameWithOwner']));
321
+ const nameWithOwner = typeof repo?.nameWithOwner === 'string' ? repo.nameWithOwner : '';
322
+ const parts = nameWithOwner.split('/');
323
+ if (parts.length !== 2 || !parts.every((part) => /^[A-Za-z0-9_.-]{1,100}$/.test(part))) return null;
324
+ const [owner, name] = parts;
325
+ const fields = prs
326
+ .map((pr) => `pr${pr.number}: pullRequest(number: ${pr.number}) { url authorAssociation }`)
327
+ .join(' ');
328
+ const query = `query { repository(owner: "${owner}", name: "${name}") { ${fields} } }`;
329
+ const answer = JSON.parse(exec('gh', ['api', 'graphql', '-f', `query=${query}`]));
330
+ const repository = answer?.data?.repository;
331
+ if (repository === null || typeof repository !== 'object') return null;
332
+ const out = new Map();
333
+ for (const pr of prs) {
334
+ const node = repository[`pr${pr.number}`];
335
+ if (node === null || typeof node !== 'object' || node.authorAssociation === undefined) continue;
336
+ out.set(pr.number, { url: node.url, authorAssociation: node.authorAssociation });
337
+ }
338
+ return out;
339
+ } catch {
340
+ return null;
341
+ }
342
+ };
343
+
344
+ // --- Failure diagnostics (RP-99) -----------------------------------------------
345
+
346
+ const CAUSES = Object.freeze({
347
+ 'gh-missing': 'the `gh` CLI could not be started (not installed, or not on PATH)',
348
+ 'gh-unauthenticated': 'the `gh` CLI is not authenticated for this host — run `gh auth login`',
349
+ 'api-unreachable': 'the GitHub API could not be reached',
350
+ 'unsupported-projection': 'this `gh` version does not serve a field the sweep asked for',
351
+ 'malformed-response': '`gh` answered, but not with the JSON array the sweep expects',
352
+ unknown: 'the cause is unknown',
353
+ });
354
+ const STDERR_UNAUTHENTICATED = /gh auth login|not logged in|HTTP 401|authentication required/i;
355
+ const STDERR_UNREACHABLE =
356
+ /dial tcp|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|HTTP 5\d\d|could not connect|connection refused/i;
357
+ const STDERR_UNSUPPORTED = /Unknown JSON field/i;
358
+ // ESC-led sequences (CSI, OSC and the single-character escapes); the
359
+ // whitespace controls, which become a space where they sit next to whitespace
360
+ // and a mark where they sit inside a run (so they cannot split a token into
361
+ // pieces too short to match); and every other character a terminal would not
362
+ // print as itself — the remaining C0/C1 controls except the line feed the next
363
+ // step splits on, the invisible format characters (zero-width, bidi,
364
+ // byte-order mark), the line and paragraph separators, and U+E000, the mark
365
+ // they are all replaced with.
366
+ // eslint-disable-next-line no-control-regex -- the pattern exists to remove these very bytes
367
+ const TERMINAL_SEQUENCES = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
368
+ const WHITESPACE_CONTROLS = /[\t\v\f\r]/g;
369
+ const WHITESPACE_CONTROLS_INSIDE_A_RUN = /(?<=\S)[\t\v\f\r]+(?=\S)/g;
370
+ // eslint-disable-next-line no-control-regex -- likewise
371
+ const HIDDEN_CHARACTERS = /[\x00-\x08\x0E-\x1F\x7F-\x9F\p{Cf}\p{Zl}\p{Zp}\uE000]/gu;
372
+ const MARK = '\uE000';
373
+ // No leading word boundary: a keyword glued to the word before must still be
374
+ // found, and over-redacting prose is the safe way to be wrong here.
375
+ const AUTHORIZATION_VALUE = /(?:Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]{8,}|Authorization:\s*[^\s]+(?:\s+[^\s]+)?/gi;
376
+ const DIAGNOSTIC_CAP = 200;
377
+ // Redaction runs on at most this much of the line, and the 200-character cut
378
+ // comes after it: cutting first can shorten a token below what its pattern
379
+ // needs and print the prefix. When the window itself cuts the line, the last
380
+ // whitespace-free run is dropped before redaction, so no token is ever
381
+ // shortened by a cut.
382
+ const REDACTION_WINDOW = 4096;
383
+ // A whitespace-free run that had a hidden character removed from it is masked
384
+ // whole once what is left is at least this long — the shortest value the
385
+ // vocabulary redacts, an authorization value. A removed character can glue a
386
+ // token to the word before it or split it into pieces too short to match, and
387
+ // neither can be told apart from honest text, so such a run is not trusted to
388
+ // the patterns at all.
389
+ const MARKED_RUN_MIN = 8;
390
+ // A masked run that ended in one of these keywords takes the next run with it:
391
+ // the value that followed the keyword can no longer be recognised on its own.
392
+ const AUTHORIZATION_KEYWORD_END = /(?:bearer|basic|token|authorization:)$/i;
393
+
394
+ /** `text` without its trailing whitespace-free run. One backward pass. */
395
+ const dropTrailingRun = (text) => {
396
+ let end = text.length;
397
+ while (end > 0 && !/\s/.test(text[end - 1])) end -= 1;
398
+ return text.slice(0, end);
399
+ };
400
+
401
+ const redactPatterns = (text) => {
402
+ let out = text.replace(AUTHORIZATION_VALUE, '[redacted]');
403
+ for (const { pattern } of SECRET_VALUE_PATTERNS) {
404
+ out = out.replace(new RegExp(pattern.source, `${pattern.flags.replace('g', '')}g`), '[redacted]');
405
+ }
406
+ return out;
407
+ };
408
+
409
+ /**
410
+ * One printable line of a subprocess's stderr: no terminal sequences, control
411
+ * or invisible format characters, the first non-empty line only, every run a
412
+ * hidden character was removed from masked when it is long enough to be a
413
+ * credential, the credential shapes the shared vocabulary knows — plus HTTP
414
+ * authorization values — replaced by `[redacted]`, and then at most 200
415
+ * characters.
416
+ */
417
+ export const sanitizeDiagnostic = (text) => {
418
+ const source = typeof text === 'string' ? text : String(text ?? '');
419
+ const marked = source
420
+ .replace(TERMINAL_SEQUENCES, MARK)
421
+ .replace(HIDDEN_CHARACTERS, MARK)
422
+ .replace(WHITESPACE_CONTROLS_INSIDE_A_RUN, MARK)
423
+ .replace(WHITESPACE_CONTROLS, ' ');
424
+ const line = marked.split('\n').find((candidate) => candidate.replaceAll(MARK, '').trim() !== '') ?? '';
425
+ const windowed = line.length > REDACTION_WINDOW ? dropTrailingRun(line.slice(0, REDACTION_WINDOW)) : line;
426
+ let maskNext = false;
427
+ const masked = windowed
428
+ .split(/(\s+)/)
429
+ .map((run) => {
430
+ if (run.trim() === '') return run;
431
+ const visible = run.replaceAll(MARK, '');
432
+ const hidden = run.includes(MARK) && visible.length >= MARKED_RUN_MIN;
433
+ const mask = hidden || maskNext;
434
+ maskNext = mask && AUTHORIZATION_KEYWORD_END.test(visible);
435
+ return mask ? '[redacted]' : visible;
436
+ })
437
+ .join('');
438
+ return redactPatterns(masked).trim().slice(0, DIAGNOSTIC_CAP);
439
+ };
440
+
441
+ /**
442
+ * The cause the evidence supports, and nothing more. `detail` is the sanitized
443
+ * first non-empty stderr line, whatever the cause, or `null` when there was none. An
444
+ * `unknown` failure with no stderr falls back to the error's own message; the
445
+ * other causes never do, because a parse error's message quotes `gh` output.
446
+ */
447
+ export const classifyAcquisitionFailure = (error) => {
448
+ const stderr = typeof error?.stderr === 'string' ? error.stderr : '';
449
+ const detail = stderr.trim() === '' ? null : sanitizeDiagnostic(stderr);
450
+ if (error?.code === 'ENOENT') return { cause: 'gh-missing', detail };
451
+ if (error instanceof SyntaxError || error?.notAnArray === true) {
452
+ return { cause: 'malformed-response', detail };
453
+ }
454
+ if (STDERR_UNSUPPORTED.test(stderr)) return { cause: 'unsupported-projection', detail };
455
+ if (STDERR_UNAUTHENTICATED.test(stderr)) return { cause: 'gh-unauthenticated', detail };
456
+ if (STDERR_UNREACHABLE.test(stderr)) return { cause: 'api-unreachable', detail };
457
+ if (detail === null && typeof error?.message === 'string' && error.message.trim() !== '') {
458
+ return { cause: 'unknown', detail: sanitizeDiagnostic(error.message) };
459
+ }
460
+ return { cause: 'unknown', detail };
461
+ };
462
+
463
+ /** The message the CLI prints for a failed acquisition: one cause, named. */
464
+ export const acquisitionFailureMessage = ({ cause, detail }) => {
465
+ const named = CAUSES[cause] ?? CAUSES.unknown;
466
+ return (
467
+ `lane reconciliation: could not list merged PRs — ${named}. ` +
468
+ 'No lane was reconciled; do not record an empty `external lane` block from this run. ' +
469
+ 'Use --input <file> to work offline.' +
470
+ (detail ? `\n ${detail}` : '')
471
+ );
244
472
  };
245
473
 
246
474
  /**
@@ -267,9 +495,19 @@ const invokedDirectly = () => {
267
495
  if (invokedDirectly()) {
268
496
  const args = parseArgs(process.argv.slice(2));
269
497
  const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
270
- const prs = args.input
271
- ? readInput(args.input, 'lane reconciliation')
272
- : fetchMergedPrs(args.since ?? daysAgo(7));
498
+ let prs;
499
+ let warnings = [];
500
+ if (args.input) {
501
+ prs = readInput(args.input, 'lane reconciliation');
502
+ } else {
503
+ try {
504
+ ({ prs, warnings } = fetchMergedPrs(args.since ?? daysAgo(7)));
505
+ } catch (error) {
506
+ process.stderr.write(`${acquisitionFailureMessage(classifyAcquisitionFailure(error))}\n`);
507
+ process.exit(1);
508
+ }
509
+ }
510
+ for (const warning of warnings) process.stderr.write(`lane reconciliation: ${warning}\n`);
273
511
  // Lane sorting still works without a declaration; only the elevated marks go
274
512
  // missing, and they render as "no elevated path crossed" rather than lying.
275
513
  const elevatedPaths = readDeclaredPaths(projectRoot) ?? [];
@@ -179,6 +179,7 @@ const parseArgs = (argv) => {
179
179
  json: false,
180
180
  actionChanged: null,
181
181
  note: null,
182
+ mergeCommit: null,
182
183
  bad: null,
183
184
  };
184
185
  for (let i = 0; i < argv.length; i += 1) {
@@ -201,6 +202,15 @@ const parseArgs = (argv) => {
201
202
  const value = revisionOrNull(argv[++i]);
202
203
  if (value === null) args.bad = arg;
203
204
  else args.base = value;
205
+ }
206
+ else if (arg === '--merge-commit') {
207
+ // Read only at BEFORE_CLOSE (below); ignored elsewhere, the same way
208
+ // BEFORE_CLOSE ignores --base. A value starting with '-' would be read
209
+ // as another option, so it is rejected the same defensive way --ticket
210
+ // and --base already are.
211
+ const value = revisionOrNull(argv[++i]);
212
+ if (value === null) args.bad = arg;
213
+ else args.mergeCommit = value;
204
214
  } else if (args.bad === null) args.bad = arg;
205
215
  }
206
216
  return args;
@@ -806,6 +816,12 @@ if (invokedDirectly()) {
806
816
  // target SELECT pinned, so a missing `origin/master` cannot turn an
807
817
  // otherwise current local rig into claim:scope drift.
808
818
  targetSha: targetShaOf(claimRoot),
819
+ // Optional (RP-175): the SHA the caller resolved, from the tracker's own
820
+ // PR metadata, as THIS item's own merge commit. Omit it and a target
821
+ // that moved for any reason still holds, exactly as before this
822
+ // existed — see claim-records.mjs's isOwnMergeAdvance for what this
823
+ // does and does not prove.
824
+ mergeCommit: args.mergeCommit,
809
825
  });
810
826
  const result = {
811
827
  ...claim,
@@ -7,14 +7,6 @@
7
7
  {
8
8
  "matcher": "Write|Edit|MultiEdit|NotebookEdit|apply_patch",
9
9
  "hooks": [
10
- {
11
- "type": "command",
12
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-core-purity.mjs\""
13
- },
14
- {
15
- "type": "command",
16
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-web-boundary.mjs\""
17
- },
18
10
  {
19
11
  "type": "command",
20
12
  "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-secret-file.mjs\""
@@ -28,7 +28,7 @@ same four steps with the diff as the code, and §4 carries what is different.
28
28
 
29
29
  **What "rulebook prose" means here is not a new list** — it is the set
30
30
  `.claude/rules/workflow.md` already uses for the `prose-reviewer` trigger: a rule
31
- file, a skill, an agent spec, a decision record, `CLAUDE.md`, the README. Where a
31
+ file, a skill, an agent spec, a decision record, `CLAUDE.md`, `AGENTS.md`, the README. Where a
32
32
  rulebook file exists twice (a template source and a generated copy), check the
33
33
  **source**; the copy is composed from it. A comment in a test or a hook is in scope
34
34
  too when it asserts behaviour — the file it lives in does not change what a claim is.
@@ -36,8 +36,8 @@ too when it asserts behaviour — the file it lives in does not change what a cl
36
36
  🔴 **`PREMISE FALSE` belongs to the first entry point only.** At the second one the
37
37
  claims are your own and the remedy is an edit, so a false one is not an escalation:
38
38
  it is `UNMEASURED`'s neighbour — delete or correct the sentence and carry on. Reading
39
- it as the escalation `loop` §6 defines would send a finished branch back to the queue
40
- over one sentence.
39
+ it as the escalation `loop` §6 (opt-in workflow layer) defines would send a
40
+ finished branch back to the queue over one sentence.
41
41
 
42
42
  ## Why it sits here and not in review
43
43
 
@@ -185,9 +185,13 @@ inside the block; both forms are the contract.
185
185
  actually says, and `file`/`line` is the citation §3 requires.
186
186
  - `PREMISES_HOLD` carries an empty `blockers` list; the other three name at
187
187
  least one. A stop verdict without one is not an answer the caller can act on,
188
- and the caller is what checks: the `loop` skill runs
189
- `node .claude/scripts/verdict.mjs check <report> check-premises` on what you
190
- return. Nothing in this skill runs it — this skill writes nothing at all.
188
+ and the caller is what checks: `node .claude/scripts/verdict.mjs check
189
+ <report> check-premises` on what you return — `verdict.mjs` is Core and runs
190
+ standalone, so the caller is whichever session invoked this skill (this is a
191
+ Core skill, meant to run with no queue and no loop); the `loop` skill (opt-in
192
+ workflow layer) runs the same command the same way when it is the one that
193
+ invoked you. Nothing in this skill runs it — this skill writes nothing at
194
+ all.
191
195
  - **`headSha` is the commit you read** — `git rev-parse HEAD` in the checkout
192
196
  you examined. A premise check answers about a tree, and the tree moves.
193
197
 
@@ -231,6 +235,6 @@ is invisible to every gate downstream.
231
235
  when a task starts building on an unchecked claim, and no artifact outlives the
232
236
  step — so a run that skipped it and a run that passed it look identical
233
237
  afterwards. That is the honest description of every rule of this shape here
234
- (the `loop` skill says the same about its own no-hand-feeding rule), and it is
235
- why the citation matters: a `file:line` in the report is the one part of this a
236
- later reader can re-check.
238
+ (the `loop` skill — opt-in workflow layer — says the same about its own
239
+ no-hand-feeding rule), and it is why the citation matters: a `file:line` in
240
+ the report is the one part of this a later reader can re-check.
@@ -12,7 +12,7 @@ decisions. This skill is the driver in between: what gets picked,
12
12
  what keeps the loop going, what stops it, and where the report goes.
13
13
 
14
14
  Per-task procedure: (worktree if another session may run) → `check-premises` on the
15
- item → failing test first → implement → **`check-premises` again, on your own prose**
15
+ item → failing test first (`test-writer`) → implement (`implementation-agent`) → **`check-premises` again, on your own prose**
16
16
  → `pr-ship` → merge on the named criterion → verify the deployed surface if one
17
17
  changed.
18
18
 
@@ -344,7 +344,7 @@ it only here. A tier outside that vocabulary **holds**, never releases
344
344
  (`docs/decisions/spacing-rations-mechanisms.md`).
345
345
 
346
346
  **The tier marker is a pre-filter, not the authority.** If an item passed as normal
347
- and the work turns out to touch an elevated path (`CLAUDE.md` →
347
+ and the work turns out to touch an elevated path (`AGENTS.md` →
348
348
  `elevated-paths`), run the gate anyway, record the verdict on the PR, and treat it
349
349
  as this run's elevated item for spacing.
350
350
 
@@ -842,8 +842,8 @@ unbounded improvement list is another diary, and three forces a choice. Each nam
842
842
  four things, and a proposal missing any of them is not ready to file:
843
843
 
844
844
  1. the finding it came from, cited as the journal line it appears on;
845
- 2. the part to change — a skill, an agent spec, a hook, a rule file, `CLAUDE.md`,
846
- the CI workflow;
845
+ 2. the part to change — a skill, an agent spec, a hook, a rule file, `AGENTS.md`,
846
+ `CLAUDE.md`, the CI workflow;
847
847
  3. the change, concretely enough to diff;
848
848
  4. how the next run would prove it worked — the observation that would differ.
849
849
 
@@ -858,7 +858,7 @@ node --input-type=module -e '
858
858
  const a = await import("./.claude/scripts/queue/plan-md.mjs"); // or github-issues / jira
859
859
  console.log(await a.proposeTriage({
860
860
  finding: "<the journal line it came from>",
861
- part: "<skill | agent | hook | rule | CLAUDE.md | workflow>",
861
+ part: "<skill | agent | hook | rule | AGENTS.md | CLAUDE.md | workflow>",
862
862
  change: "<concretely enough to diff>",
863
863
  proof: "<the observation that would differ next run>",
864
864
  // a pair: what the probe touched, and what is concluded from it. The
@@ -961,9 +961,34 @@ three poisons the only channel by which this project learns.
961
961
  underneath it:
962
962
 
963
963
  ```bash
964
- node .claude/scripts/revalidate.mjs --point BEFORE_CLOSE --ticket <item-id>
964
+ node .claude/scripts/revalidate.mjs --point BEFORE_CLOSE --ticket <item-id> \
965
+ --merge-commit "$(gh pr view <pr> --json mergeCommit -q .mergeCommit.oid)"
965
966
  ```
966
967
 
968
+ `--merge-commit` names the exact SHA the tracker's own PR metadata records as
969
+ THIS item's merge commit — one piece of evidence among several that let a
970
+ `targetSha` move which is nothing but that merge read as `CURRENT` instead
971
+ of `claim:scope` drift. Resolve it from the PR just merged, by name, never by
972
+ reading `git log` text, and only after the merge has landed and been
973
+ fetched. Omitting it (the item has not merged yet, or an older loop that
974
+ predates this flag) leaves any target movement holding exactly as it did
975
+ before this flag existed — it is purely additive to that default. Naming the
976
+ SHA is necessary but never sufficient: the exemption also requires the
977
+ target to have advanced by EXACTLY ONE commit — a genuine squash merge; a
978
+ merge commit or any multi-commit range still holds
979
+ (`test/template/revalidate.test.ts` (absent in a generated rig) › "a foreign
980
+ commit plus the real merge in the same range, --merge-commit correctly
981
+ naming the real one") — and it binds to THIS checkout: the named commit's
982
+ tree must equal `HEAD`'s tree here, and `HEAD` must still be the pre-merge
983
+ checkout, not one already fast-forwarded onto the merge
984
+ (`test/template/revalidate.test.ts` (absent in a generated rig) › "a single
985
+ FOREIGN commit named as --merge-commit does not exempt it — reproduces the
986
+ gate-hold attack and proves it now holds" and › "HEAD already at the merge
987
+ commit is vacuous, and still holds (a fast-forwarded checkout must not
988
+ self-satisfy the exemption)"). That binding stops a lazy or mistaken flag,
989
+ not an adversary who controls this run's own checkout — see
990
+ `isOwnMergeAdvance` in `claim-records.mjs` for the exact limit.
991
+
967
992
  It compares the tracked claim's `scope` and `commentary` fingerprint sets;
968
993
  commentary becomes hold-authoritative only here. Marker/take-up movement is
969
994
  retained in evidence but cannot decide drift. A missing claim is
@@ -94,9 +94,9 @@ is a booby trap: someone will hit it, not understand it, and route around it.
94
94
  - [ ] The rule is written down, with the hook named next to it
95
95
  - [ ] The hook guards **one** invariant, and its scope is as narrow as the rule
96
96
 
97
- ## A candidate that is already sitting there
97
+ ## Choosing a candidate
98
98
 
99
- The stack rules say service code logs through the shared structured logger, never
100
- `console.log` — and nothing enforces it. That is the invariant the shipped example
101
- encodes, so if the rule matters in your project, promoting the example into a real
102
- hook is a copy, a rename and a wiring line.
99
+ Start from a rule that already matters in this project and has been violated or
100
+ missed in review. Do not invent a sample architecture rule just to exercise this
101
+ skill: the hook must protect a real local invariant, and its test must demonstrate
102
+ the exact violation it blocks.
@@ -257,7 +257,7 @@ blockers.
257
257
  - `security-scanner` when the diff touches auth, secrets or configuration,
258
258
  input parsing, file handling, new outbound calls, dependency changes;
259
259
  - `prose-reviewer` when the diff touches a rule file, a skill, an agent spec,
260
- a decision record under `docs/decisions/`, `CLAUDE.md` or the README — a
260
+ a decision record under `docs/decisions/`, `CLAUDE.md`, `AGENTS.md` or the README — a
261
261
  rulebook that overstates its own enforcement fails silently and in the
262
262
  direction of false confidence;
263
263
  - an infrastructure review when it touches infrastructure (the stack layer
@@ -12,9 +12,9 @@ directories, with one `.git`. Without it, an unattended run and a hand-driven
12
12
  session share a working tree and overwrite each other's edits.
13
13
 
14
14
  **Use one when** anything else may touch this repo while you work — an
15
- unattended `loop` run, a colleague, a second Claude session. A single attended
16
- session on a quiet repo can just use a branch; the discipline that is never
17
- optional is the branch, not the worktree.
15
+ unattended `loop` run (opt-in workflow layer), a colleague, a second Claude
16
+ session. A single attended session on a quiet repo can just use a branch; the
17
+ discipline that is never optional is the branch, not the worktree.
18
18
 
19
19
  ## Start
20
20
 
@@ -1,6 +1,6 @@
1
1
  name = "code-reviewer"
2
- description = "Reviews a completed change against the checklist before a PR is opened or merged. Use after any non-trivial implementation work, and always before opening a PR the decision-router puts on its `model` lane, which is everything its two cheap lanes did not claim — code, a rulebook document, an unclassifiable path, a derived artifact git does not report as drift, or anything a risk flag escalated. Blocking findings must be resolved, not argued with."
2
+ description = "Reviews a completed change against the checklist before a PR is opened or merged. Use after any non-trivial implementation work, and always before opening a PR that reaches the `model` lane — everything the two cheap lanes do not claim (code, a rulebook document, an unclassifiable path, a derived artifact git does not report as drift, or anything a risk flag escalated), decided by `decision-router.mjs` where the opt-in workflow layer is installed, or by the same rule applied by hand otherwise. Blocking findings must be resolved, not argued with."
3
3
  model = "gpt-5.6-sol"
4
4
  model_reasoning_effort = "high"
5
5
  sandbox_mode = "read-only"
6
- developer_instructions = "You review changes. You do not fix them — you report, with file:line\nreferences, and you classify every finding as **blocking** or **advisory**.\n\n## Checklist (blocking findings)\n\n1. **Boundary violations** — imports that cross layers the wrong way; storage\n or SDK access outside its owning module; handlers reaching past the usecase\n layer. See the architecture rules in `.claude/rules/`.\n2. **Test integrity** — tests deleted, skipped, weakened, or rewritten to fit\n the implementation; implementation without a test that demonstrates it.\n3. **Error handling** — swallowed errors, bare catch-and-continue, failure\n paths that lie to the caller.\n4. **Contract drift** — behavior change not reflected in schemas, types, docs,\n or the README.\n5. **Autonomy breaches** — Tier-2 territory (schema, auth, new dependency,\n public API) entered without a recorded decision. See\n `.claude/rules/autonomy.md`.\n6. **Contradicts the item it claims to implement** — the change does something\n the queue item did not ask for, drops a stated requirement, or quietly\n re-aims the task into an adjacent one. Read the item first, then the diff.\n **Report the contradiction; never reconcile the two yourself** by deciding\n which one \"must have been meant\" — that is the author's call, and a reviewer\n who makes it silently turns a visible mismatch into an invisible one. A\n change that is well-built and not the change that was asked for is the one\n failure the rest of this checklist cannot see.\n\n **If the item was not handed to you, say so and stop there.** Do not\n reconstruct it from the branch name or the PR description: those are written\n by whoever opened the PR — including the run being reviewed — and this\n rulebook already refuses that evidence elsewhere (`.claude/rules/autonomy.md`).\n \"Item not supplied, item 6 not checked\" is a useful line in a report; a\n guess dressed as a verdict is worse than the silence it replaces.\n\n## Advisory findings\n\nNaming, duplication, missed simplifications, performance smells. Report them;\ndo not block on them.\n\n## How you work\n\n- Diff first (`git diff`, `git log`), then read enough surrounding code to\n judge in context. Review what changed, not the whole repo.\n- Quote the checklist item a blocking finding violates. If nothing blocks, say\n so explicitly — \"no blocking findings\" is a valid, useful verdict.\n- Do not request rewrites of working, tested code for style alone.\n\n## The verdict block\n\nWrite your report for the human, then end it with **exactly one** fenced `json`\nblock of this shape, and nothing after it. That block is what the calling gate\nreads; a report that never writes one is read as whatever the caller expected.\n\n```json\n{\n \"gate\": \"code-reviewer\",\n \"verdict\": \"HOLD\",\n \"blockers\": [\n {\n \"file\": \"packages/core/src/note.ts\",\n \"line\": 42,\n \"rule\": \"checklist item 2 — test integrity\",\n \"note\": \"the failing case was deleted rather than fixed\"\n }\n ],\n \"advisories\": [],\n \"evidence\": [\"diffed against origin/master\", \"queue item supplied\"],\n \"headSha\": \"9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70\"\n}\n```\n\n- `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.\n- Every blocker names the `rule` it violates. `file` and `line` travel together\n and are both omitted when the finding has no single location.\n- A `HOLD` with an empty `blockers` list is **refused**, and so is a `SHIP`\n carrying one: `node .claude/scripts/verdict.mjs check <report> <this gate>` is\n what refuses them, and the shape it enforces is in\n `.claude/scripts/lib/verdict.mjs`. The gate name is what stops your answer\n being read as somebody else's.\n- **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the\n checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage\n <commit>` tell \"this gate answered for the commit being merged\" from \"it\n answered two pushes ago\". A verdict naming no commit is counted as neither\n covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook\n runs that check, so a session that skips the gate skips this with it."
6
+ developer_instructions = "You review changes. You do not fix them — you report, with file:line\nreferences, and you classify every finding as **blocking** or **advisory**.\n\n## Checklist (blocking findings)\n\n1. **Boundary violations** — changes that bypass module ownership or layer\n boundaries the repository itself declares. See any applicable architecture\n rules in `.claude/rules/`.\n2. **Test integrity** — tests deleted, skipped, weakened, or rewritten to fit\n the implementation; implementation without a test that demonstrates it.\n3. **Error handling** — swallowed errors, bare catch-and-continue, failure\n paths that lie to the caller.\n4. **Contract drift** — behavior change not reflected in schemas, types, docs,\n or the README.\n5. **Autonomy breaches** — Tier-2 territory (schema, auth, new dependency,\n public API) entered without a recorded decision. See\n `.claude/rules/autonomy.md`.\n6. **Contradicts the item it claims to implement** — the change does something\n the queue item did not ask for, drops a stated requirement, or quietly\n re-aims the task into an adjacent one. Read the item first, then the diff.\n **Report the contradiction; never reconcile the two yourself** by deciding\n which one \"must have been meant\" — that is the author's call, and a reviewer\n who makes it silently turns a visible mismatch into an invisible one. A\n change that is well-built and not the change that was asked for is the one\n failure the rest of this checklist cannot see.\n\n **If the item was not handed to you, say so and stop there.** Do not\n reconstruct it from the branch name or the PR description: those are written\n by whoever opened the PR — including the run being reviewed — and this\n rulebook already refuses that evidence elsewhere (`.claude/rules/autonomy.md`).\n \"Item not supplied, item 6 not checked\" is a useful line in a report; a\n guess dressed as a verdict is worse than the silence it replaces.\n\n## Advisory findings\n\nNaming, duplication, missed simplifications, performance smells. Report them;\ndo not block on them.\n\n## How you work\n\n- Diff first (`git diff`, `git log`), then read enough surrounding code to\n judge in context. Review what changed, not the whole repo.\n- Quote the checklist item a blocking finding violates. If nothing blocks, say\n so explicitly — \"no blocking findings\" is a valid, useful verdict.\n- Do not request rewrites of working, tested code for style alone.\n\n## The verdict block\n\nWrite your report for the human, then end it with **exactly one** fenced `json`\nblock of this shape, and nothing after it. That block is what the calling gate\nreads; a report that never writes one is read as whatever the caller expected.\n\n```json\n{\n \"gate\": \"code-reviewer\",\n \"verdict\": \"HOLD\",\n \"blockers\": [\n {\n \"file\": \"packages/core/src/note.ts\",\n \"line\": 42,\n \"rule\": \"checklist item 2 — test integrity\",\n \"note\": \"the failing case was deleted rather than fixed\"\n }\n ],\n \"advisories\": [],\n \"evidence\": [\"diffed against origin/master\", \"queue item supplied\"],\n \"headSha\": \"9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70\"\n}\n```\n\n- `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.\n- Every blocker names the `rule` it violates. `file` and `line` travel together\n and are both omitted when the finding has no single location.\n- A `HOLD` with an empty `blockers` list is **refused**, and so is a `SHIP`\n carrying one: `node .claude/scripts/verdict.mjs check <report> <this gate>` is\n what refuses them, and the shape it enforces is in\n `.claude/scripts/lib/verdict.mjs`. The gate name is what stops your answer\n being read as somebody else's.\n- **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the\n checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage\n <commit>` tell \"this gate answered for the commit being merged\" from \"it\n answered two pushes ago\". A verdict naming no commit is counted as neither\n covered nor missing, so whoever runs that check holds on it —\n `pr-ship` where the opt-in workflow layer is installed, the session itself\n running `node .claude/scripts/verdict.mjs coverage` by hand otherwise: no\n hook runs it either way, so skipping the gate skips this with it."
@@ -0,0 +1,6 @@
1
+ name = "implementation-agent"
2
+ description = "Writes the minimum production code that makes an existing failing test pass — the Green step of TDD, and the Refactor that follows it. Use for ordinary implementation work once test-writer has produced the failing test."
3
+ model = "gpt-5.6-terra"
4
+ model_reasoning_effort = "high"
5
+ sandbox_mode = "workspace-write"
6
+ developer_instructions = "You make a failing test pass. You are the Green step of TDD, and the Refactor\nthat keeps it green — never the Red step, and never the reviewer of your own\nwork.\n\n## Scope — hard boundaries\n\n- You start from a failing test that already exists. If there is none, stop\n and say so: the test comes first, from `test-writer`.\n- You never delete, skip, weaken or rewrite a test to make it pass. If a test\n looks wrong, stop and surface the conflict instead of editing it.\n- You change only what the task needs. Unrelated files, formatting sweeps and\n speculative abstractions are out of scope.\n- You do not commit, push, merge or open pull requests; the session that\n dispatched you owns the branch and the gates.\n\n## How you work\n\n1. Read the failing test and the code around it; follow the repository's\n rules in `AGENTS.md` and `.claude/rules/`.\n2. Write the smallest change that makes the test pass, in the style of the\n surrounding code.\n3. Run the new test, then the suite, lint and typecheck the rules name.\n Report a red check as information; never retry it until it passes.\n4. Refactor only with the tests green, then run them again.\n5. Report back: what you changed and why, which checks you ran with their\n results, and anything you found but did not change.\n\n## Judgment lines\n\n- When the change reaches an elevated area the task was not already tiered\n for, stop and say so before changing it.\n- When two rules in the repository conflict, stop and name both.\n- Prefer deleting code to adding it when both satisfy the test."