@gajae-code/coding-agent 0.11.11 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. package/CHANGELOG.md +131 -4
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/cli/read-cli.d.ts +1 -0
  4. package/dist/types/commands/deep-interview.d.ts +9 -0
  5. package/dist/types/commands/harness.d.ts +22 -0
  6. package/dist/types/commands/read.d.ts +6 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/provider-auth-health.d.ts +14 -0
  9. package/dist/types/config/provider-ranking.d.ts +59 -0
  10. package/dist/types/config/settings-schema.d.ts +83 -1
  11. package/dist/types/config/settings.d.ts +2 -0
  12. package/dist/types/exa/mcp-client.d.ts +10 -1
  13. package/dist/types/exec/bash-executor.d.ts +45 -12
  14. package/dist/types/extensibility/extensions/types.d.ts +38 -4
  15. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  17. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  18. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  19. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  20. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  21. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  22. package/dist/types/extensibility/shared-events.d.ts +6 -0
  23. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  24. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  25. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  26. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  27. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  28. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  29. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  30. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +70 -1
  31. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  32. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  33. package/dist/types/gjc-runtime/team-runtime.d.ts +27 -1
  34. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  35. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  36. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  37. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +17 -0
  38. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  39. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
  40. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  41. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  42. package/dist/types/main.d.ts +1 -1
  43. package/dist/types/memories/index.d.ts +2 -0
  44. package/dist/types/modes/acp/acp-agent.d.ts +10 -19
  45. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  46. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  47. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  48. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  49. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  50. package/dist/types/modes/components/session-observer-overlay.d.ts +22 -0
  51. package/dist/types/modes/components/settings-selector.d.ts +11 -0
  52. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  53. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  54. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  55. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  56. package/dist/types/modes/interactive-mode.d.ts +2 -0
  57. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  58. package/dist/types/modes/types.d.ts +2 -0
  59. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  60. package/dist/types/reminders/star-reminder.d.ts +3 -13
  61. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  62. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  63. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  64. package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
  65. package/dist/types/sdk/broker/transport.d.ts +2 -0
  66. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  67. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
  68. package/dist/types/sdk/bus/config.d.ts +6 -2
  69. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  70. package/dist/types/sdk/bus/index.d.ts +44 -1
  71. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +44 -0
  72. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  73. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  74. package/dist/types/sdk/bus/reconciliation-store.d.ts +69 -0
  75. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  76. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  77. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  78. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  79. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
  80. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  81. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  82. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  83. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  84. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  85. package/dist/types/sdk/host/host.d.ts +2 -0
  86. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  87. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  88. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  89. package/dist/types/sdk/prompt-status.d.ts +23 -3
  90. package/dist/types/sdk/session.d.ts +6 -0
  91. package/dist/types/session/agent-session.d.ts +82 -12
  92. package/dist/types/session/artifacts.d.ts +8 -0
  93. package/dist/types/session/blob-store.d.ts +57 -4
  94. package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
  95. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  96. package/dist/types/session/session-manager.d.ts +48 -19
  97. package/dist/types/session/session-storage.d.ts +4 -0
  98. package/dist/types/session/streaming-output.d.ts +96 -12
  99. package/dist/types/setup/credential-import.d.ts +12 -0
  100. package/dist/types/skill-state/active-state.d.ts +12 -4
  101. package/dist/types/skill-state/workflow-hud.d.ts +9 -0
  102. package/dist/types/task/executor.d.ts +3 -2
  103. package/dist/types/task/index.d.ts +3 -1
  104. package/dist/types/task/provider-retry-status.d.ts +8 -0
  105. package/dist/types/task/receipt.d.ts +7 -1
  106. package/dist/types/task/types.d.ts +23 -0
  107. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  108. package/dist/types/tools/bash-interactive.d.ts +4 -1
  109. package/dist/types/tools/bash.d.ts +3 -1
  110. package/dist/types/tools/browser/launch.d.ts +7 -0
  111. package/dist/types/tools/fetch.d.ts +17 -1
  112. package/dist/types/tools/image-gen.d.ts +4 -0
  113. package/dist/types/tools/index.d.ts +4 -0
  114. package/dist/types/tools/output-meta.d.ts +44 -3
  115. package/dist/types/tools/path-utils.d.ts +1 -0
  116. package/dist/types/tools/read-internals.d.ts +12 -0
  117. package/dist/types/tools/read.d.ts +45 -7
  118. package/dist/types/tools/resource-gc.d.ts +4 -0
  119. package/dist/types/tools/review.d.ts +5 -7
  120. package/dist/types/tools/subagent.d.ts +2 -0
  121. package/dist/types/tools/tool-result.d.ts +7 -1
  122. package/dist/types/utils/gh.d.ts +13 -0
  123. package/dist/types/utils/image-resize.d.ts +1 -0
  124. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  125. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  126. package/dist/types/web/search/providers/xai.d.ts +2 -0
  127. package/package.json +11 -13
  128. package/scripts/acp-conformance-agent.ts +218 -0
  129. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  130. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  131. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  132. package/scripts/generate-sdk-operation-inventory.ts +5 -0
  133. package/scripts/resident-memory-bench.ts +1341 -0
  134. package/scripts/run-acp-conformance.ts +285 -0
  135. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  136. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  137. package/src/async/job-manager.ts +78 -20
  138. package/src/capability/index.ts +8 -0
  139. package/src/cli/fast-help.ts +1 -1
  140. package/src/cli/plugin-cli.ts +176 -18
  141. package/src/cli/read-cli.ts +2 -1
  142. package/src/cli.ts +1 -0
  143. package/src/commands/deep-interview.ts +29 -9
  144. package/src/commands/harness.ts +35 -12
  145. package/src/commands/read.ts +11 -2
  146. package/src/commands/team.ts +20 -2
  147. package/src/config/file-lock.ts +11 -2
  148. package/src/config/model-registry.ts +21 -2
  149. package/src/config/provider-auth-health.ts +42 -0
  150. package/src/config/provider-ranking.ts +119 -0
  151. package/src/config/settings-schema.ts +74 -1
  152. package/src/config/settings.ts +246 -108
  153. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  154. package/src/coordinator-mcp/server.ts +5 -12
  155. package/src/defaults/gjc/skills/deep-interview/SKILL.md +230 -82
  156. package/src/defaults/gjc/skills/ralplan/SKILL.md +77 -41
  157. package/src/defaults/gjc/skills/team/SKILL.md +3 -1
  158. package/src/defaults/gjc/skills/ultragoal/SKILL.md +62 -39
  159. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  160. package/src/defaults/gjc-defaults.ts +4 -4
  161. package/src/eval/py/executor.ts +7 -2
  162. package/src/exa/mcp-client.ts +12 -3
  163. package/src/exec/bash-executor.ts +144 -27
  164. package/src/extensibility/extensions/runner.ts +15 -0
  165. package/src/extensibility/extensions/types.ts +46 -5
  166. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  167. package/src/extensibility/gjc-plugins/index.ts +19 -2
  168. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  169. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  170. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  171. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  172. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  173. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  174. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  175. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  176. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  177. package/src/extensibility/gjc-plugins/types.ts +141 -0
  178. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  179. package/src/extensibility/shared-events.ts +5 -0
  180. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
  181. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  182. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  183. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  184. package/src/gjc-runtime/deep-interview-state.ts +12 -943
  185. package/src/gjc-runtime/gc-render.ts +1 -0
  186. package/src/gjc-runtime/gc-runtime.ts +11 -1
  187. package/src/gjc-runtime/launch-tmux.ts +25 -6
  188. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  189. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  190. package/src/gjc-runtime/ralplan-runtime.ts +1149 -98
  191. package/src/gjc-runtime/state-runtime.ts +2 -1
  192. package/src/gjc-runtime/state-writer.ts +70 -222
  193. package/src/gjc-runtime/team-launch.ts +42 -5
  194. package/src/gjc-runtime/team-runtime.ts +1085 -40
  195. package/src/gjc-runtime/team-store.ts +32 -0
  196. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  197. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  198. package/src/gjc-runtime/team-workers.ts +1 -0
  199. package/src/gjc-runtime/tmux-owner-isolation.ts +28 -0
  200. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  201. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  202. package/src/gjc-runtime/ultragoal-runtime.ts +414 -618
  203. package/src/gjc-runtime/workflow-manifest.generated.json +105 -449
  204. package/src/gjc-runtime/workflow-manifest.ts +38 -247
  205. package/src/hooks/native-skill-hook.ts +17 -4
  206. package/src/hooks/skill-state.ts +1 -23
  207. package/src/internal-urls/docs-index.generated.ts +21 -22
  208. package/src/internal-urls/local-root-gc.ts +127 -0
  209. package/src/main.ts +9 -5
  210. package/src/memories/index.ts +10 -0
  211. package/src/modes/DESIGN.md +98 -0
  212. package/src/modes/acp/acp-agent.ts +339 -191
  213. package/src/modes/acp/acp-event-mapper.ts +14 -7
  214. package/src/modes/acp/acp-mode.ts +39 -3
  215. package/src/modes/components/assistant-message.ts +123 -22
  216. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  217. package/src/modes/components/irc-sidebar.ts +176 -62
  218. package/src/modes/components/model-selector.ts +61 -4
  219. package/src/modes/components/notifications-settings-editor.ts +12 -1
  220. package/src/modes/components/oauth-selector.ts +96 -28
  221. package/src/modes/components/read-tool-group.ts +82 -14
  222. package/src/modes/components/session-observer-overlay.ts +457 -83
  223. package/src/modes/components/settings-selector.ts +116 -23
  224. package/src/modes/components/status-line/gh.ts +16 -0
  225. package/src/modes/components/tool-execution.ts +138 -27
  226. package/src/modes/components/tool-status-header.ts +3 -14
  227. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  228. package/src/modes/controllers/command-controller.ts +4 -0
  229. package/src/modes/controllers/event-controller.ts +109 -27
  230. package/src/modes/controllers/extension-ui-controller.ts +26 -2
  231. package/src/modes/controllers/input-controller.ts +18 -3
  232. package/src/modes/controllers/selector-controller.ts +127 -36
  233. package/src/modes/interactive-mode.ts +62 -3
  234. package/src/modes/irc-observation-ledger.ts +71 -15
  235. package/src/modes/runtime-init.ts +22 -2
  236. package/src/modes/types.ts +2 -0
  237. package/src/modes/utils/ui-helpers.ts +94 -1
  238. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  239. package/src/prompts/agents/architect.md +9 -1
  240. package/src/prompts/agents/critic.md +9 -0
  241. package/src/prompts/system/eager-todo.md +8 -4
  242. package/src/prompts/system/system-prompt.md +11 -5
  243. package/src/prompts/tools/bash.md +5 -5
  244. package/src/prompts/tools/read.md +4 -2
  245. package/src/prompts/tools/search-tool-bm25.md +5 -0
  246. package/src/prompts/tools/task.md +1 -1
  247. package/src/reminders/star-reminder.ts +4 -42
  248. package/src/runtime-mcp/manager.ts +19 -4
  249. package/src/runtime-mcp/smithery-auth.ts +17 -2
  250. package/src/runtime-mcp/smithery-connect.ts +11 -1
  251. package/src/sdk/acp/adapter.ts +22 -6
  252. package/src/sdk/broker/lifecycle.ts +25 -6
  253. package/src/sdk/broker/transport.ts +35 -3
  254. package/src/sdk/bus/attachment-registry.ts +5 -1
  255. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  256. package/src/sdk/bus/chat-daemon-control.ts +6 -3
  257. package/src/sdk/bus/config.ts +18 -2
  258. package/src/sdk/bus/control-drain-lease.ts +104 -0
  259. package/src/sdk/bus/index.ts +984 -146
  260. package/src/sdk/bus/kind-aware-reconciliation.ts +317 -0
  261. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  262. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  263. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  264. package/src/sdk/bus/reconciliation-store.ts +303 -0
  265. package/src/sdk/bus/rich-draft.ts +1 -1
  266. package/src/sdk/bus/rich-render.ts +2 -2
  267. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  268. package/src/sdk/bus/telegram-cli.ts +33 -8
  269. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  270. package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
  271. package/src/sdk/bus/telegram-daemon.ts +916 -23
  272. package/src/sdk/bus/telegram-reference.ts +40 -3
  273. package/src/sdk/bus/threaded-render.ts +48 -3
  274. package/src/sdk/bus/topic-registry.ts +20 -0
  275. package/src/sdk/host/control/dispatch.ts +5 -1
  276. package/src/sdk/host/control/operations.ts +1 -1
  277. package/src/sdk/host/host.ts +23 -1
  278. package/src/sdk/host/query/handlers.ts +55 -0
  279. package/src/sdk/host/query/revision-store.ts +36 -2
  280. package/src/sdk/host/reverse-leases.ts +44 -7
  281. package/src/sdk/mcp/server.ts +46 -18
  282. package/src/sdk/prompt-status.ts +16 -3
  283. package/src/sdk/protocol/operation-inventory.generated.json +51 -0
  284. package/src/sdk/protocol/operation-registry.ts +7 -2
  285. package/src/sdk/session.ts +101 -10
  286. package/src/session/agent-session.ts +1286 -220
  287. package/src/session/artifacts.ts +22 -0
  288. package/src/session/auth-broker-config.ts +10 -3
  289. package/src/session/blob-store.ts +915 -24
  290. package/src/session/internal/managed-session-scope.ts +44 -6
  291. package/src/session/internal/managed-session-storage.ts +48 -6
  292. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  293. package/src/session/session-manager.ts +2364 -438
  294. package/src/session/session-storage.ts +9 -1
  295. package/src/session/streaming-output.ts +500 -111
  296. package/src/setup/credential-import.ts +75 -13
  297. package/src/setup/provider-onboarding.ts +19 -4
  298. package/src/skill-state/active-state.ts +32 -10
  299. package/src/skill-state/workflow-hud.ts +42 -2
  300. package/src/skill-state/workflow-mutation-guard.ts +560 -8
  301. package/src/task/executor.ts +122 -39
  302. package/src/task/index.ts +23 -14
  303. package/src/task/provider-retry-status.ts +16 -0
  304. package/src/task/receipt.ts +23 -7
  305. package/src/task/render.ts +6 -2
  306. package/src/task/types.ts +108 -0
  307. package/src/tools/ask-answer-registry.ts +12 -6
  308. package/src/tools/ask.ts +169 -118
  309. package/src/tools/ast-edit.ts +1 -0
  310. package/src/tools/ast-grep.ts +1 -0
  311. package/src/tools/bash-interactive.ts +8 -3
  312. package/src/tools/bash.ts +503 -60
  313. package/src/tools/browser/launch.ts +41 -6
  314. package/src/tools/fetch.ts +114 -23
  315. package/src/tools/find.ts +1 -0
  316. package/src/tools/image-gen.ts +29 -5
  317. package/src/tools/index.ts +4 -0
  318. package/src/tools/output-meta.ts +281 -20
  319. package/src/tools/path-utils.ts +8 -3
  320. package/src/tools/read-internals.ts +44 -0
  321. package/src/tools/read.ts +1560 -310
  322. package/src/tools/resource-gc.ts +182 -18
  323. package/src/tools/review.ts +5 -7
  324. package/src/tools/search.ts +1 -0
  325. package/src/tools/subagent-render.ts +6 -2
  326. package/src/tools/subagent.ts +6 -0
  327. package/src/tools/telegram-send.ts +6 -3
  328. package/src/tools/tool-result.ts +14 -1
  329. package/src/utils/gh.ts +39 -0
  330. package/src/utils/image-resize.ts +3 -0
  331. package/src/web/search/providers/anthropic.ts +4 -4
  332. package/src/web/search/providers/codex.ts +1 -1
  333. package/src/web/search/providers/kimi.ts +15 -5
  334. package/src/web/search/providers/searxng.ts +47 -4
  335. package/src/web/search/providers/xai.ts +7 -2
  336. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  337. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  338. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  339. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  340. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  341. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  342. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  343. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
  344. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -1,7 +1,8 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
+ import type { Dirent } from "node:fs";
2
3
  import * as fs from "node:fs/promises";
3
- import * as os from "node:os";
4
4
  import * as path from "node:path";
5
+ import { getConfigRootDir } from "@gajae-code/utils";
5
6
  import { syncSkillActiveState } from "../skill-state/active-state";
6
7
  import { buildRalplanHudSummary } from "../skill-state/workflow-hud";
7
8
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
@@ -22,7 +23,7 @@ import {
22
23
  RepositoryBindingError,
23
24
  } from "./repository-binding";
24
25
  import { GJC_RALPLAN_ARTIFACT_ENV, isRestrictedRoleAgentBash } from "./restricted-role-agent-bash";
25
- import { gjcRoot, modeStatePath, sessionPlansDir } from "./session-layout";
26
+ import { gjcRoot, modeStatePath, sessionIdFromDirName, sessionPlansDir } from "./session-layout";
26
27
  import { resolveGjcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
27
28
  import { migrateWorkflowState } from "./state-migrations";
28
29
  import { runNativeStateCommand } from "./state-runtime";
@@ -33,6 +34,7 @@ import {
33
34
  writeArtifact,
34
35
  writeWorkflowEnvelopeAtomic,
35
36
  } from "./state-writer";
37
+ import { probeGjcTeamAvailability } from "./team-runtime";
36
38
  import { assertSafePathComponent, CommandError, flagValue, hasFlag } from "./workflow-cli-common";
37
39
  import { getSkillManifest } from "./workflow-manifest";
38
40
 
@@ -50,7 +52,7 @@ import { getSkillManifest } from "./workflow-manifest";
50
52
  *
51
53
  * 2. **Artifact write**: `gjc ralplan --write --stage <type> --stage_n <N>
52
54
  * (--artifact <path-or-string> | --artifact-env GJC_RALPLAN_ARTIFACT)
53
- * [--run-id <id>] [--session-id <id>] [--json]` persists Planner / Architect
55
+ * [--run-id <id>] [--session-id <id>] [--lane-verdict <token>] [--json]` persists Planner / Architect
54
56
  * / Critic / revision / post-interview / ADR / final markdown under `.gjc/plans/ralplan/<run-id>/`, maintains
55
57
  * an `index.jsonl` audit log, copies `final` stages to `pending-approval.md`, and advances
56
58
  * the HUD chip to reflect the latest persisted stage.
@@ -70,9 +72,35 @@ export const RALPLAN_DEFAULT_MAX_ITERATIONS = 5;
70
72
  export const RALPLAN_MAX_ITERATIONS_LIMIT = 20;
71
73
  /** Operator-visible stuck signal for headless/CI orchestration (#3165). */
72
74
  export const PLANNING_STUCK_MARKER = "PLANNING-STUCK";
75
+ /** Default architect/critic review passes per consensus iteration. */
76
+ export const RALPLAN_DEFAULT_MAX_REVIEW_PASSES_PER_LANE = 1;
77
+ /** Inclusive upper bound for `gjc.ralplan.maxReviewPassesPerLane` settings overrides. */
78
+ export const RALPLAN_MAX_REVIEW_PASSES_PER_LANE_LIMIT = 10;
79
+ export type RalplanAutoHandoffTarget = "off" | "ultragoal" | "team";
80
+
81
+ export interface RalplanAutoHandoffResolution {
82
+ configuredTarget: RalplanAutoHandoffTarget;
83
+ effectiveTarget: RalplanAutoHandoffTarget;
84
+ degradationReason: string | null;
85
+ source: string;
86
+ }
87
+
88
+ const RALPLAN_AUTO_HANDOFF_TARGETS = new Set<RalplanAutoHandoffTarget>(["off", "ultragoal", "team"]);
73
89
 
74
90
  const RALPLAN_ITERATION_OPENER_STAGES = new Set<RalplanStage>(["planner", "revision"]);
75
91
 
92
+ /** Collapse duplicate ledger rows for the same deterministic stage artifact before review-lane budget accounting. */
93
+ function deduplicateRalplanIndexRowsByStageIdentity(rows: readonly RalplanIndexRow[]): RalplanIndexRow[] {
94
+ const seen = new Set<string>();
95
+ return rows.filter(row => {
96
+ if (typeof row.stageN !== "number") return true;
97
+ const identity = `${row.stage}\u0000${row.stageN}`;
98
+ if (seen.has(identity)) return false;
99
+ seen.add(identity);
100
+ return true;
101
+ });
102
+ }
103
+
76
104
  export type RalplanIterationCapDecision =
77
105
  | {
78
106
  allowed: true;
@@ -147,8 +175,106 @@ export function evaluateRalplanIterationCap(input: {
147
175
  };
148
176
  }
149
177
 
178
+ export type RalplanReviewLane = "architect" | "critic";
179
+
180
+ export type RalplanReviewLaneBudgetDecision =
181
+ | {
182
+ allowed: true;
183
+ lane?: RalplanReviewLane;
184
+ currentPasses: number;
185
+ projectedPasses: number;
186
+ maxReviewPassesPerLane: number;
187
+ finalSlot: boolean;
188
+ ledgerNote?: string;
189
+ }
190
+ | {
191
+ allowed: false;
192
+ lane: RalplanReviewLane;
193
+ currentPasses: number;
194
+ projectedPasses: number;
195
+ maxReviewPassesPerLane: number;
196
+ finalSlot: false;
197
+ ledgerNote?: string;
198
+ reason: string;
199
+ };
200
+
201
+ /**
202
+ * Pure per-lane review-pass budget gate. Architect and critic passes are limited
203
+ * within the current consensus iteration; all other stages remain unconditionally
204
+ * available as escalation paths.
205
+ */
206
+ export function evaluateRalplanReviewLaneBudget(input: {
207
+ rows: readonly RalplanIndexRow[];
208
+ stage: string;
209
+ maxReviewPassesPerLane?: unknown;
210
+ onDiskLaneCounts?: { architect: number; critic: number };
211
+ }): RalplanReviewLaneBudgetDecision {
212
+ const maxReviewPassesPerLane =
213
+ typeof input.maxReviewPassesPerLane === "number" &&
214
+ Number.isInteger(input.maxReviewPassesPerLane) &&
215
+ input.maxReviewPassesPerLane >= 1 &&
216
+ input.maxReviewPassesPerLane <= RALPLAN_MAX_REVIEW_PASSES_PER_LANE_LIMIT
217
+ ? input.maxReviewPassesPerLane
218
+ : RALPLAN_DEFAULT_MAX_REVIEW_PASSES_PER_LANE;
219
+ if (input.stage !== "architect" && input.stage !== "critic") {
220
+ return {
221
+ allowed: true,
222
+ currentPasses: 0,
223
+ projectedPasses: 0,
224
+ maxReviewPassesPerLane,
225
+ finalSlot: false,
226
+ };
227
+ }
228
+
229
+ const lane = input.stage as RalplanReviewLane;
230
+ const rows = deduplicateRalplanIndexRowsByStageIdentity(input.rows);
231
+ const summary = summarizeRalplanIndex(rows);
232
+ const indexCurrent = summary.currentStages.filter(stage => stage === lane).length;
233
+ const parsedTotal = rows.filter(row => row.stage === lane).length;
234
+ const onDiskRaw = input.onDiskLaneCounts?.[lane];
235
+ const onDiskTotal = typeof onDiskRaw === "number" && Number.isInteger(onDiskRaw) && onDiskRaw > 0 ? onDiskRaw : 0;
236
+ const diskExcess = Math.max(0, onDiskTotal - parsedTotal);
237
+ const currentPasses = indexCurrent + diskExcess;
238
+ const projectedPasses = currentPasses + 1;
239
+ const ledgerNote =
240
+ diskExcess > 0
241
+ ? ` (ledger under-count: parsed ${lane} rows=${parsedTotal}, on-disk ${lane} artifacts=${onDiskTotal})`
242
+ : undefined;
243
+ const finalSlot = projectedPasses === maxReviewPassesPerLane;
244
+ if (projectedPasses > maxReviewPassesPerLane) {
245
+ return {
246
+ allowed: false,
247
+ lane,
248
+ currentPasses,
249
+ projectedPasses,
250
+ maxReviewPassesPerLane,
251
+ finalSlot: false,
252
+ ledgerNote,
253
+ reason:
254
+ `ralplan review lane budget exceeded: ${lane} pass ${projectedPasses} of max ${maxReviewPassesPerLane} ` +
255
+ `in consensus iteration ${Math.max(1, summary.iteration)}${ledgerNote ?? ""}`,
256
+ };
257
+ }
258
+ return {
259
+ allowed: true,
260
+ lane,
261
+ currentPasses,
262
+ projectedPasses,
263
+ maxReviewPassesPerLane,
264
+ finalSlot,
265
+ ledgerNote,
266
+ };
267
+ }
268
+
269
+ function getErrorCode(error: unknown): string | undefined {
270
+ if (!error || typeof error !== "object" || !("code" in error)) return undefined;
271
+ const code = (error as { code?: unknown }).code;
272
+ return typeof code === "string" ? code : undefined;
273
+ }
274
+
150
275
  /** Filename pattern for persisted planner/revision stage artifacts. */
151
276
  const OPENER_ARTIFACT_RE = /^stage-\d{2,}-(planner|revision)\.md$/;
277
+ const LANE_ARTIFACT_RE = /^stage-\d{2,}-(architect|critic)\.md$/;
152
278
 
153
279
  /**
154
280
  * Count on-disk planner/revision stage artifacts for a run. Used as a floor when
@@ -168,6 +294,30 @@ export async function countRalplanOnDiskOpeners(cwd: string, sessionId: string,
168
294
  }
169
295
  }
170
296
 
297
+ /**
298
+ * Count on-disk Architect/Critic stage artifacts for a run. This is the
299
+ * fail-closed floor for a missing, truncated, or malformed `index.jsonl`.
300
+ */
301
+ export async function countRalplanOnDiskLaneArtifacts(
302
+ cwd: string,
303
+ sessionId: string,
304
+ runId: string,
305
+ ): Promise<{ architect: number; critic: number }> {
306
+ const runDir = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId);
307
+ try {
308
+ const entries = await fs.readdir(runDir);
309
+ const counts = { architect: 0, critic: 0 };
310
+ for (const name of entries) {
311
+ const match = LANE_ARTIFACT_RE.exec(name);
312
+ if (match) counts[match[1] as RalplanReviewLane] += 1;
313
+ }
314
+ return counts;
315
+ } catch (error) {
316
+ if (getErrorCode(error) === "ENOENT") return { architect: 0, critic: 0 };
317
+ throw error;
318
+ }
319
+ }
320
+
171
321
  /**
172
322
  * Load index rows for cap enforcement. Unlike HUD reads, returns structural
173
323
  * signals so callers can fail closed when the ledger is empty/malformed while
@@ -177,7 +327,13 @@ export async function loadRalplanIndexForCap(
177
327
  cwd: string,
178
328
  sessionId: string,
179
329
  runId: string,
180
- ): Promise<{ rows: RalplanIndexRow[]; indexPresent: boolean; parseableLines: number; rawLineCount: number }> {
330
+ ): Promise<{
331
+ rows: RalplanIndexRow[];
332
+ indexPresent: boolean;
333
+ parseableLines: number;
334
+ rawLineCount: number;
335
+ rawText?: string;
336
+ }> {
181
337
  const indexPath = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId, "index.jsonl");
182
338
  try {
183
339
  const text = await fs.readFile(indexPath, "utf8");
@@ -192,6 +348,7 @@ export async function loadRalplanIndexForCap(
192
348
  indexPresent: true,
193
349
  parseableLines: rows.length,
194
350
  rawLineCount: lines.length,
351
+ rawText: text,
195
352
  };
196
353
  } catch (error) {
197
354
  const code =
@@ -204,16 +361,16 @@ export async function loadRalplanIndexForCap(
204
361
  }
205
362
  }
206
363
 
207
- function parseMaxIterationsValue(value: unknown): number | null {
208
- return typeof value === "number" &&
209
- Number.isFinite(value) &&
210
- Number.isInteger(value) &&
211
- value >= 1 &&
212
- value <= RALPLAN_MAX_ITERATIONS_LIMIT
364
+ function parseBoundedPositiveInteger(value: unknown, limit: number): number | null {
365
+ return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 1 && value <= limit
213
366
  ? value
214
367
  : null;
215
368
  }
216
369
 
370
+ function parseMaxIterationsValue(value: unknown): number | null {
371
+ return parseBoundedPositiveInteger(value, RALPLAN_MAX_ITERATIONS_LIMIT);
372
+ }
373
+
217
374
  async function readSettingsMaxIterations(settingsPath: string): Promise<number | null> {
218
375
  try {
219
376
  const raw = await Bun.file(settingsPath).text();
@@ -241,12 +398,198 @@ export async function resolveRalplanMaxIterations(cwd: string): Promise<{ maxIte
241
398
  const projectPath = path.join(gjcRoot(cwd), "settings.json");
242
399
  const project = await readSettingsMaxIterations(projectPath);
243
400
  if (project !== null) return { maxIterations: project, source: projectPath };
244
- const userDir = process.env.GJC_CONFIG_DIR?.trim() || path.join(os.homedir(), ".gjc");
245
- const userPath = path.join(userDir, "settings.json");
401
+ const userPath = path.join(getConfigRootDir(), "settings.json");
246
402
  const user = await readSettingsMaxIterations(userPath);
247
403
  if (user !== null) return { maxIterations: user, source: userPath };
248
404
  return { maxIterations: RALPLAN_DEFAULT_MAX_ITERATIONS, source: "default" };
249
405
  }
406
+ function parseRalplanAutoHandoffTarget(value: unknown): RalplanAutoHandoffTarget | undefined {
407
+ return typeof value === "string" && RALPLAN_AUTO_HANDOFF_TARGETS.has(value as RalplanAutoHandoffTarget)
408
+ ? (value as RalplanAutoHandoffTarget)
409
+ : undefined;
410
+ }
411
+
412
+ type RalplanAutoHandoffOptions = {
413
+ planningStuck?: boolean;
414
+ teamAvailabilityProbe?: () => { available: true } | { available: false; reason: string };
415
+ };
416
+
417
+ type RalplanAutoHandoffSetting =
418
+ | { kind: "absent" }
419
+ | { kind: "valid"; value: RalplanAutoHandoffTarget }
420
+ | { kind: "invalid"; reason: string };
421
+
422
+ function parsePresentRalplanAutoHandoff(value: unknown): RalplanAutoHandoffSetting {
423
+ const target = parseRalplanAutoHandoffTarget(value);
424
+ return target === undefined
425
+ ? {
426
+ kind: "invalid",
427
+ reason: "expected gjc.ralplan.autoHandoff to be one of off, ultragoal, team",
428
+ }
429
+ : { kind: "valid", value: target };
430
+ }
431
+
432
+ function parseRalplanAutoHandoffSettings(parsed: unknown): RalplanAutoHandoffSetting {
433
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { kind: "absent" };
434
+ const settings = parsed as Record<string, unknown>;
435
+ if (Object.hasOwn(settings, "gjc.ralplan.autoHandoff")) {
436
+ return parsePresentRalplanAutoHandoff(settings["gjc.ralplan.autoHandoff"]);
437
+ }
438
+ const gjc = settings.gjc;
439
+ if (!gjc || typeof gjc !== "object" || Array.isArray(gjc)) return { kind: "absent" };
440
+ const ralplan = (gjc as Record<string, unknown>).ralplan;
441
+ if (!ralplan || typeof ralplan !== "object" || Array.isArray(ralplan)) return { kind: "absent" };
442
+ const ralplanSettings = ralplan as Record<string, unknown>;
443
+ if (!Object.hasOwn(ralplanSettings, "autoHandoff")) return { kind: "absent" };
444
+ return parsePresentRalplanAutoHandoff(ralplanSettings.autoHandoff);
445
+ }
446
+
447
+ async function readSettingsAutoHandoff(settingsPath: string): Promise<RalplanAutoHandoffSetting> {
448
+ let raw: string;
449
+ try {
450
+ raw = await Bun.file(settingsPath).text();
451
+ } catch (error) {
452
+ if (getErrorCode(error) === "ENOENT") return { kind: "absent" };
453
+ return {
454
+ kind: "invalid",
455
+ reason: `unable to read settings: ${error instanceof Error ? error.message : String(error)}`,
456
+ };
457
+ }
458
+ let parsed: unknown;
459
+ try {
460
+ parsed = JSON.parse(raw);
461
+ } catch (error) {
462
+ return {
463
+ kind: "invalid",
464
+ reason: `malformed JSON: ${error instanceof Error ? error.message : String(error)}`,
465
+ };
466
+ }
467
+ return parseRalplanAutoHandoffSettings(parsed);
468
+ }
469
+
470
+ export async function resolveRalplanAutoHandoff(
471
+ cwd: string,
472
+ options: RalplanAutoHandoffOptions = {},
473
+ ): Promise<RalplanAutoHandoffResolution> {
474
+ const projectPath = path.join(gjcRoot(cwd), "settings.json");
475
+ const project = await readSettingsAutoHandoff(projectPath);
476
+ if (project.kind === "invalid") {
477
+ throw new RalplanCommandError(2, `invalid ralplan settings at ${projectPath}: ${project.reason}`);
478
+ }
479
+ if (project.kind === "valid") {
480
+ return resolveRalplanAutoHandoffTarget(project.value, projectPath, options);
481
+ }
482
+ const userPath = path.join(getConfigRootDir(), "settings.json");
483
+ const user = await readSettingsAutoHandoff(userPath);
484
+ if (user.kind === "invalid") {
485
+ throw new RalplanCommandError(2, `invalid ralplan settings at ${userPath}: ${user.reason}`);
486
+ }
487
+ return resolveRalplanAutoHandoffTarget(
488
+ user.kind === "valid" ? user.value : "off",
489
+ user.kind === "valid" ? userPath : "default",
490
+ options,
491
+ );
492
+ }
493
+
494
+ function resolveRalplanAutoHandoffTarget(
495
+ configuredTarget: RalplanAutoHandoffTarget,
496
+ source: string,
497
+ options: RalplanAutoHandoffOptions,
498
+ ): RalplanAutoHandoffResolution {
499
+ if (options.planningStuck) {
500
+ return { configuredTarget, effectiveTarget: "off", degradationReason: "planning_stuck", source };
501
+ }
502
+ if (configuredTarget !== "team")
503
+ return { configuredTarget, effectiveTarget: configuredTarget, degradationReason: null, source };
504
+
505
+ const availability = (options.teamAvailabilityProbe ?? probeGjcTeamAvailability)();
506
+ return availability.available
507
+ ? { configuredTarget, effectiveTarget: "team", degradationReason: null, source }
508
+ : {
509
+ configuredTarget,
510
+ effectiveTarget: "off",
511
+ degradationReason: `team_unavailable:${availability.reason}`,
512
+ source,
513
+ };
514
+ }
515
+
516
+ function parseMaxReviewPassesPerLaneValue(value: unknown): number | null {
517
+ return parseBoundedPositiveInteger(value, RALPLAN_MAX_REVIEW_PASSES_PER_LANE_LIMIT);
518
+ }
519
+
520
+ type RalplanReviewPassesPerLaneSetting =
521
+ | { kind: "absent" }
522
+ | { kind: "valid"; value: number }
523
+ | { kind: "invalid"; reason: string };
524
+
525
+ function parsePresentMaxReviewPassesPerLane(value: unknown): RalplanReviewPassesPerLaneSetting {
526
+ const parsed = parseMaxReviewPassesPerLaneValue(value);
527
+ return parsed === null
528
+ ? {
529
+ kind: "invalid",
530
+ reason:
531
+ "expected gjc.ralplan.maxReviewPassesPerLane to be an integer between 1 and " +
532
+ RALPLAN_MAX_REVIEW_PASSES_PER_LANE_LIMIT,
533
+ }
534
+ : { kind: "valid", value: parsed };
535
+ }
536
+
537
+ function parseMaxReviewPassesPerLaneSettings(parsed: unknown): RalplanReviewPassesPerLaneSetting {
538
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { kind: "absent" };
539
+ const settings = parsed as Record<string, unknown>;
540
+ if (Object.hasOwn(settings, "gjc.ralplan.maxReviewPassesPerLane")) {
541
+ return parsePresentMaxReviewPassesPerLane(settings["gjc.ralplan.maxReviewPassesPerLane"]);
542
+ }
543
+ const gjc = settings.gjc;
544
+ if (!gjc || typeof gjc !== "object" || Array.isArray(gjc)) return { kind: "absent" };
545
+ const ralplan = (gjc as Record<string, unknown>).ralplan;
546
+ if (!ralplan || typeof ralplan !== "object" || Array.isArray(ralplan)) return { kind: "absent" };
547
+ const ralplanSettings = ralplan as Record<string, unknown>;
548
+ if (!Object.hasOwn(ralplanSettings, "maxReviewPassesPerLane")) return { kind: "absent" };
549
+ return parsePresentMaxReviewPassesPerLane(ralplanSettings.maxReviewPassesPerLane);
550
+ }
551
+
552
+ async function readSettingsMaxReviewPassesPerLane(settingsPath: string): Promise<RalplanReviewPassesPerLaneSetting> {
553
+ let raw: string;
554
+ try {
555
+ raw = await Bun.file(settingsPath).text();
556
+ } catch (error) {
557
+ if (getErrorCode(error) === "ENOENT") return { kind: "absent" };
558
+ return {
559
+ kind: "invalid",
560
+ reason: `unable to read settings: ${error instanceof Error ? error.message : String(error)}`,
561
+ };
562
+ }
563
+ let parsed: unknown;
564
+ try {
565
+ parsed = JSON.parse(raw);
566
+ } catch (error) {
567
+ return {
568
+ kind: "invalid",
569
+ reason: `malformed JSON: ${error instanceof Error ? error.message : String(error)}`,
570
+ };
571
+ }
572
+ return parseMaxReviewPassesPerLaneSettings(parsed);
573
+ }
574
+
575
+ /** Resolve the per-lane review-pass budget with project-over-user precedence. */
576
+ export async function resolveRalplanMaxReviewPassesPerLane(
577
+ cwd: string,
578
+ ): Promise<{ maxReviewPassesPerLane: number; source: string }> {
579
+ const projectPath = path.join(gjcRoot(cwd), "settings.json");
580
+ const project = await readSettingsMaxReviewPassesPerLane(projectPath);
581
+ if (project.kind === "invalid") {
582
+ throw new RalplanCommandError(2, `invalid ralplan settings at ${projectPath}: ${project.reason}`);
583
+ }
584
+ if (project.kind === "valid") return { maxReviewPassesPerLane: project.value, source: projectPath };
585
+ const userPath = path.join(getConfigRootDir(), "settings.json");
586
+ const user = await readSettingsMaxReviewPassesPerLane(userPath);
587
+ if (user.kind === "invalid") {
588
+ throw new RalplanCommandError(2, `invalid ralplan settings at ${userPath}: ${user.reason}`);
589
+ }
590
+ if (user.kind === "valid") return { maxReviewPassesPerLane: user.value, source: userPath };
591
+ return { maxReviewPassesPerLane: RALPLAN_DEFAULT_MAX_REVIEW_PASSES_PER_LANE, source: "default" };
592
+ }
250
593
 
251
594
  function buildPlanningStuckResult(input: {
252
595
  json: boolean;
@@ -290,6 +633,51 @@ function buildPlanningStuckResult(input: {
290
633
  };
291
634
  }
292
635
 
636
+ function buildLaneBudgetStuckResult(input: {
637
+ json: boolean;
638
+ stage: RalplanStage;
639
+ stageN: number;
640
+ runId: string;
641
+ decision: Extract<RalplanReviewLaneBudgetDecision, { allowed: false }>;
642
+ source: string;
643
+ }): RalplanCommandResult {
644
+ const detail =
645
+ `${PLANNING_STUCK_MARKER}: ${input.decision.reason} ` +
646
+ `(run_id=${input.runId}, stage=${input.stage}, stage_n=${input.stageN}, source=${input.source}). ` +
647
+ `Stop re-invoking the ${input.decision.lane} review lane in this consensus iteration; ` +
648
+ "route a rule-2-justified blocker through a Planner revision opener (fresh lane budget) while opener budget remains, " +
649
+ "or escalate the best existing plan via post-interview/adr/final without auto-implementation.";
650
+ if (input.json) {
651
+ return {
652
+ status: 3,
653
+ stdout: `${JSON.stringify(
654
+ {
655
+ ok: false,
656
+ planning_stuck: true,
657
+ marker: PLANNING_STUCK_MARKER,
658
+ run_id: input.runId,
659
+ stage: input.stage,
660
+ stage_n: input.stageN,
661
+ lane: input.decision.lane,
662
+ passes: input.decision.currentPasses,
663
+ projected_passes: input.decision.projectedPasses,
664
+ max_review_passes_per_lane: input.decision.maxReviewPassesPerLane,
665
+ max_review_passes_source: input.source,
666
+ reason: input.decision.reason,
667
+ },
668
+ null,
669
+ 2,
670
+ )}\n`,
671
+ stderr: `${detail}\n`,
672
+ };
673
+ }
674
+ return {
675
+ status: 3,
676
+ stdout: `${PLANNING_STUCK_MARKER}\n`,
677
+ stderr: `${detail}\n`,
678
+ };
679
+ }
680
+
293
681
  const KNOWN_ARCHITECT_KINDS = new Set(["openai-code"]);
294
682
  const KNOWN_CRITIC_KINDS = new Set(["openai-code"]);
295
683
 
@@ -322,10 +710,15 @@ const VALUE_FLAGS = new Set([
322
710
  "--critic",
323
711
  "--planner-id",
324
712
  "--planner-resumable",
713
+ "--architect-id",
714
+ "--architect-resumable",
715
+ "--critic-id",
716
+ "--critic-resumable",
325
717
  "--fallback-reason",
326
718
  "--fallback-attempted-id",
327
719
  "--fallback-stage-n",
328
720
  "--fallback-receipt-path",
721
+ "--lane-verdict",
329
722
  ]);
330
723
 
331
724
  export function isRalplanArtifactWriteInvocation(args: readonly string[]): boolean {
@@ -485,6 +878,15 @@ async function persistActiveRunId(cwd: string, sessionId: string, runId: string,
485
878
  // "complete"/"handoff" and disarm the Stop hook). PHASE_LOCK only guards same-run writes.
486
879
  const isNewRun = existing.run_id !== runId;
487
880
  const nextPhase = isNewRun ? stage : advanceCurrentPhase(existing.current_phase, stage);
881
+ if (isNewRun) {
882
+ // State writes shallow-merge, so clear both HUD verdict sources at the run boundary.
883
+ delete existing.verdict;
884
+ for (const key of Object.keys(existing)) {
885
+ if (key.startsWith("last_review_verdict")) delete existing[key];
886
+ }
887
+ delete existing.planning_stuck;
888
+ delete existing.auto_handoff;
889
+ }
488
890
  if (
489
891
  existing.run_id === runId &&
490
892
  existing.version === WORKFLOW_STATE_VERSION &&
@@ -503,6 +905,7 @@ async function persistActiveRunId(cwd: string, sessionId: string, runId: string,
503
905
  existing.updated_at = new Date().toISOString();
504
906
  await writeWorkflowEnvelopeAtomic(statePath, existing, {
505
907
  cwd,
908
+ lockHeld: true,
506
909
  receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan persist-run-id", sessionId },
507
910
  audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan", sessionId },
508
911
  });
@@ -511,9 +914,12 @@ async function persistActiveRunId(cwd: string, sessionId: string, runId: string,
511
914
  );
512
915
  }
513
916
 
514
- /* --------------------------- planner run-state --------------------------- */
917
+ /* ---------------------- persisted role-agent run-state --------------------- */
918
+
919
+ type PersistedRole = "planner" | RalplanReviewLane;
515
920
 
516
- interface PlannerStateUpdate {
921
+ interface PersistedRoleStateUpdate {
922
+ role: PersistedRole;
517
923
  subagentId?: string;
518
924
  resumable?: boolean;
519
925
  fallbackReason?: string;
@@ -522,6 +928,25 @@ interface PlannerStateUpdate {
522
928
  fallbackReceiptPath?: string;
523
929
  }
524
930
 
931
+ interface LaneVerdictUpdate {
932
+ lane: RalplanReviewLane;
933
+ verdict: string;
934
+ stageN: number;
935
+ }
936
+
937
+ const PERSISTED_ROLES = ["planner", "architect", "critic"] as const;
938
+
939
+ const PERSISTED_ROLE_FLAGS: Record<PersistedRole, { id: string; resumable: string }> = {
940
+ planner: { id: "--planner-id", resumable: "--planner-resumable" },
941
+ architect: { id: "--architect-id", resumable: "--architect-resumable" },
942
+ critic: { id: "--critic-id", resumable: "--critic-resumable" },
943
+ };
944
+
945
+ const LANE_VERDICTS: Record<RalplanReviewLane, ReadonlySet<string>> = {
946
+ architect: new Set(["CLEAR", "WATCH", "BLOCK"]),
947
+ critic: new Set(["OKAY", "ITERATE", "REJECT"]),
948
+ };
949
+
525
950
  function parseBooleanFlag(raw: string, flag: string): boolean {
526
951
  if (raw === "true") return true;
527
952
  if (raw === "false") return false;
@@ -534,7 +959,7 @@ function assertSubagentId(value: string, label: string): void {
534
959
  }
535
960
  }
536
961
 
537
- function plannerFlagValue(args: readonly string[], flag: string): string | undefined {
962
+ function roleStateFlagValue(args: readonly string[], flag: string): string | undefined {
538
963
  const value = flagValue(args, flag);
539
964
  if (value === undefined && hasFlag(args, flag)) {
540
965
  throw new RalplanCommandError(2, `missing value for ${flag}.`);
@@ -542,19 +967,64 @@ function plannerFlagValue(args: readonly string[], flag: string): string | undef
542
967
  return value;
543
968
  }
544
969
 
970
+ function persistedRoleForStage(stage: RalplanStage): PersistedRole | undefined {
971
+ if (stage === "planner" || stage === "revision") return "planner";
972
+ if (stage === "architect" || stage === "critic") return stage;
973
+ return undefined;
974
+ }
975
+
976
+ function suppliedPersistedRoleFlag(args: readonly string[], role: PersistedRole): string | undefined {
977
+ const flags = PERSISTED_ROLE_FLAGS[role];
978
+ if (hasFlag(args, flags.id)) return flags.id;
979
+ if (hasFlag(args, flags.resumable)) return flags.resumable;
980
+ return undefined;
981
+ }
982
+
545
983
  /**
546
- * Parse the optional persisted-Planner metadata flags that may ride alongside a
547
- * `--write`. Returns `undefined` when none are present so existing writes are
548
- * unaffected. Throws `RalplanCommandError` on any malformed value. This records
549
- * a same-session audit/routing hint, not a durable subagent registry.
984
+ * Parse the optional same-session persisted role-agent metadata that may ride
985
+ * alongside a `--write`. Planner metadata rides planner/revision stages;
986
+ * Architect and Critic metadata ride only their matching review lane. This is
987
+ * an audit/routing hint, not a durable subagent registry.
550
988
  */
551
- function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | undefined {
552
- const subagentId = plannerFlagValue(args, "--planner-id");
553
- const resumableRaw = plannerFlagValue(args, "--planner-resumable");
554
- const fallbackReason = plannerFlagValue(args, "--fallback-reason");
555
- const fallbackAttemptedId = plannerFlagValue(args, "--fallback-attempted-id");
556
- const fallbackStageNRaw = plannerFlagValue(args, "--fallback-stage-n");
557
- const fallbackReceiptPath = plannerFlagValue(args, "--fallback-receipt-path");
989
+ function parsePersistedRoleStateArgs(
990
+ args: readonly string[],
991
+ stage: RalplanStage,
992
+ ): PersistedRoleStateUpdate | undefined {
993
+ const role = persistedRoleForStage(stage);
994
+ for (const candidate of PERSISTED_ROLES) {
995
+ const suppliedFlag = suppliedPersistedRoleFlag(args, candidate);
996
+ if (suppliedFlag && candidate !== role) {
997
+ const expectedStages = candidate === "planner" ? "planner or revision" : candidate;
998
+ throw new RalplanCommandError(
999
+ 2,
1000
+ `${suppliedFlag} is only valid with --stage ${expectedStages} (received ${stage}).`,
1001
+ );
1002
+ }
1003
+ }
1004
+
1005
+ const fallbackFlagsPresent = [
1006
+ "--fallback-reason",
1007
+ "--fallback-attempted-id",
1008
+ "--fallback-stage-n",
1009
+ "--fallback-receipt-path",
1010
+ ].some(flag => hasFlag(args, flag));
1011
+ if (!role) {
1012
+ if (fallbackFlagsPresent) {
1013
+ throw new RalplanCommandError(
1014
+ 2,
1015
+ `--fallback-reason is only valid with --stage planner, revision, architect, or critic (received ${stage}).`,
1016
+ );
1017
+ }
1018
+ return undefined;
1019
+ }
1020
+
1021
+ const flags = PERSISTED_ROLE_FLAGS[role];
1022
+ const subagentId = roleStateFlagValue(args, flags.id);
1023
+ const resumableRaw = roleStateFlagValue(args, flags.resumable);
1024
+ const fallbackReason = roleStateFlagValue(args, "--fallback-reason");
1025
+ const fallbackAttemptedId = roleStateFlagValue(args, "--fallback-attempted-id");
1026
+ const fallbackStageNRaw = roleStateFlagValue(args, "--fallback-stage-n");
1027
+ const fallbackReceiptPath = roleStateFlagValue(args, "--fallback-receipt-path");
558
1028
 
559
1029
  const anyPresent = [
560
1030
  subagentId,
@@ -566,14 +1036,13 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
566
1036
  ].some(value => value !== undefined);
567
1037
  if (!anyPresent) return undefined;
568
1038
 
569
- const update: PlannerStateUpdate = {};
570
-
1039
+ const update: PersistedRoleStateUpdate = { role };
571
1040
  if (subagentId !== undefined) {
572
- assertSubagentId(subagentId, "--planner-id");
1041
+ assertSubagentId(subagentId, flags.id);
573
1042
  update.subagentId = subagentId;
574
1043
  }
575
1044
  if (resumableRaw !== undefined) {
576
- update.resumable = parseBooleanFlag(resumableRaw, "--planner-resumable");
1045
+ update.resumable = parseBooleanFlag(resumableRaw, flags.resumable);
577
1046
  }
578
1047
 
579
1048
  const anyFallback = [fallbackReason, fallbackAttemptedId, fallbackStageNRaw, fallbackReceiptPath].some(
@@ -581,7 +1050,7 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
581
1050
  );
582
1051
  if (anyFallback) {
583
1052
  if (!fallbackReason) {
584
- throw new RalplanCommandError(2, "--fallback-reason is required when recording planner fallback metadata.");
1053
+ throw new RalplanCommandError(2, `--fallback-reason is required when recording ${role} fallback metadata.`);
585
1054
  }
586
1055
  if (!KNOWN_FALLBACK_REASONS.has(fallbackReason)) {
587
1056
  throw new RalplanCommandError(
@@ -593,13 +1062,13 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
593
1062
  if (fallbackAttemptedId === undefined) {
594
1063
  throw new RalplanCommandError(
595
1064
  2,
596
- "--fallback-attempted-id is required when recording planner fallback metadata.",
1065
+ `--fallback-attempted-id is required when recording ${role} fallback metadata.`,
597
1066
  );
598
1067
  }
599
1068
  assertSubagentId(fallbackAttemptedId, "--fallback-attempted-id");
600
1069
  update.fallbackAttemptedId = fallbackAttemptedId;
601
1070
  if (fallbackStageNRaw === undefined) {
602
- throw new RalplanCommandError(2, "--fallback-stage-n is required when recording planner fallback metadata.");
1071
+ throw new RalplanCommandError(2, `--fallback-stage-n is required when recording ${role} fallback metadata.`);
603
1072
  }
604
1073
  update.fallbackStageN = parseStageN(fallbackStageNRaw);
605
1074
  if (fallbackReceiptPath !== undefined) {
@@ -613,24 +1082,107 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
613
1082
  return update;
614
1083
  }
615
1084
 
616
- /** Snake-case projection of a PlannerStateUpdate for state JSON + receipts. Omitted fields stay absent — an unknown `planner_resumable` is encoded by omission, never literal null. */
617
- function plannerStatePayload(update: PlannerStateUpdate): Record<string, unknown> {
1085
+ /** Parse the self-reported review verdict carried by a lane artifact write. */
1086
+ function parseLaneVerdictArgs(
1087
+ args: readonly string[],
1088
+ stage: RalplanStage,
1089
+ stageN: number,
1090
+ ): LaneVerdictUpdate | undefined {
1091
+ const rawVerdict = roleStateFlagValue(args, "--lane-verdict");
1092
+ if (rawVerdict === undefined) return undefined;
1093
+ if (stage !== "architect" && stage !== "critic") {
1094
+ throw new RalplanCommandError(
1095
+ 2,
1096
+ `--lane-verdict is only valid with --stage architect or critic (received ${stage}).`,
1097
+ );
1098
+ }
1099
+ const verdict = rawVerdict.trim().toUpperCase();
1100
+ if (!LANE_VERDICTS[stage].has(verdict)) {
1101
+ throw new RalplanCommandError(
1102
+ 2,
1103
+ `invalid --lane-verdict for ${stage}: ${rawVerdict}. Expected one of: ${[...LANE_VERDICTS[stage]].join(", ")}.`,
1104
+ );
1105
+ }
1106
+ return { lane: stage, verdict, stageN };
1107
+ }
1108
+
1109
+ /**
1110
+ * Snake-case projection of persisted role metadata for state JSON + receipts.
1111
+ * Omitted fields stay absent — an unknown resumability value is never encoded
1112
+ * as literal null.
1113
+ */
1114
+ function persistedRoleStatePayload(update: PersistedRoleStateUpdate): Record<string, unknown> {
618
1115
  const payload: Record<string, unknown> = {};
619
- if (update.subagentId !== undefined) payload.planner_subagent_id = update.subagentId;
620
- if (update.resumable !== undefined) payload.planner_resumable = update.resumable;
621
- if (update.fallbackReason !== undefined) payload.planner_fallback_reason = update.fallbackReason;
622
- if (update.fallbackAttemptedId !== undefined) payload.planner_fallback_attempted_id = update.fallbackAttemptedId;
623
- if (update.fallbackStageN !== undefined) payload.planner_fallback_stage_n = update.fallbackStageN;
624
- if (update.fallbackReceiptPath !== undefined) payload.planner_fallback_receipt_path = update.fallbackReceiptPath;
1116
+ const prefix = update.role;
1117
+ if (update.subagentId !== undefined) {
1118
+ payload[prefix === "planner" ? "planner_subagent_id" : `${prefix}_id`] = update.subagentId;
1119
+ }
1120
+ if (update.resumable !== undefined) payload[`${prefix}_resumable`] = update.resumable;
1121
+ if (update.fallbackReason !== undefined) payload[`${prefix}_fallback_reason`] = update.fallbackReason;
1122
+ if (update.fallbackAttemptedId !== undefined)
1123
+ payload[`${prefix}_fallback_attempted_id`] = update.fallbackAttemptedId;
1124
+ if (update.fallbackStageN !== undefined) payload[`${prefix}_fallback_stage_n`] = update.fallbackStageN;
1125
+ if (update.fallbackReceiptPath !== undefined) {
1126
+ payload[`${prefix}_fallback_receipt_path`] = update.fallbackReceiptPath;
1127
+ }
625
1128
  return payload;
626
1129
  }
627
1130
 
628
1131
  /**
629
- * Merge persisted-Planner metadata into the ralplan run-state JSON. Same-session
630
- * audit/routing hint only — it records what the caller has already proven and is
631
- * NOT a durable cross-process subagent registry.
1132
+ * Merge persisted role-agent metadata into the ralplan run-state JSON, optionally only for its active run.
1133
+ * This is a same-session audit/routing hint only; it does not create a durable
1134
+ * cross-process subagent registry, ledger entry, or additional state file.
632
1135
  */
633
- async function applyPlannerStateUpdate(cwd: string, sessionId: string, update: PlannerStateUpdate): Promise<void> {
1136
+ async function applyPersistedRoleStateUpdate(
1137
+ cwd: string,
1138
+ sessionId: string,
1139
+ update: PersistedRoleStateUpdate,
1140
+ expectedActiveRunId?: string,
1141
+ ): Promise<boolean> {
1142
+ const statePath = ralplanStatePath(cwd, sessionId);
1143
+ return await withWorkflowStateLock(
1144
+ statePath,
1145
+ async () => {
1146
+ const existingRead = await readExistingStateForMutation(statePath);
1147
+ if (existingRead.kind === "corrupt") {
1148
+ throw new RalplanCommandError(
1149
+ 2,
1150
+ `existing ralplan state is corrupt or tampered (${existingRead.error}); refusing to overwrite ${statePath}`,
1151
+ );
1152
+ }
1153
+ let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
1154
+ if (expectedActiveRunId !== undefined && existing.run_id !== expectedActiveRunId) return false;
1155
+ Object.assign(existing, persistedRoleStatePayload(update));
1156
+ if (typeof existing.skill !== "string") existing.skill = "ralplan";
1157
+ if (typeof existing.active !== "boolean") existing.active = true;
1158
+ if (typeof existing.current_phase !== "string") existing.current_phase = "planner";
1159
+ existing = migrateWorkflowState(existing, "ralplan").state;
1160
+ existing.updated_at = new Date().toISOString();
1161
+ await writeWorkflowEnvelopeAtomic(statePath, existing, {
1162
+ cwd,
1163
+ lockHeld: true,
1164
+ receipt: {
1165
+ cwd,
1166
+ skill: "ralplan",
1167
+ owner: "gjc-runtime",
1168
+ command: `gjc ralplan ${update.role}-state`,
1169
+ sessionId,
1170
+ },
1171
+ audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan", sessionId },
1172
+ });
1173
+ return true;
1174
+ },
1175
+ { cwd },
1176
+ );
1177
+ }
1178
+
1179
+ /** Merge a lane's self-reported verdict into the same-session ralplan run state, optionally only for its active run. */
1180
+ async function applyLaneVerdictUpdate(
1181
+ cwd: string,
1182
+ sessionId: string,
1183
+ update: LaneVerdictUpdate,
1184
+ expectedActiveRunId?: string,
1185
+ ): Promise<boolean> {
634
1186
  const statePath = ralplanStatePath(cwd, sessionId);
635
1187
  return await withWorkflowStateLock(
636
1188
  statePath,
@@ -643,7 +1195,12 @@ async function applyPlannerStateUpdate(cwd: string, sessionId: string, update: P
643
1195
  );
644
1196
  }
645
1197
  let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
646
- Object.assign(existing, plannerStatePayload(update));
1198
+ if (expectedActiveRunId !== undefined && existing.run_id !== expectedActiveRunId) return false;
1199
+ Object.assign(existing, {
1200
+ last_review_verdict: update.verdict,
1201
+ last_review_verdict_lane: update.lane,
1202
+ last_review_verdict_stage_n: update.stageN,
1203
+ });
647
1204
  if (typeof existing.skill !== "string") existing.skill = "ralplan";
648
1205
  if (typeof existing.active !== "boolean") existing.active = true;
649
1206
  if (typeof existing.current_phase !== "string") existing.current_phase = "planner";
@@ -651,13 +1208,203 @@ async function applyPlannerStateUpdate(cwd: string, sessionId: string, update: P
651
1208
  existing.updated_at = new Date().toISOString();
652
1209
  await writeWorkflowEnvelopeAtomic(statePath, existing, {
653
1210
  cwd,
654
- receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan planner-state", sessionId },
1211
+ lockHeld: true,
1212
+ receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan lane-verdict", sessionId },
655
1213
  audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan", sessionId },
656
1214
  });
1215
+ return true;
657
1216
  },
658
1217
  { cwd },
659
1218
  );
660
1219
  }
1220
+ function ralplanPlanningStuckIndexKey(entry: unknown): string | undefined {
1221
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
1222
+ const record = entry as Record<string, unknown>;
1223
+ return record.planning_stuck === true ? "planning_stuck" : undefined;
1224
+ }
1225
+
1226
+ async function recordRalplanPlanningStuck(
1227
+ cwd: string,
1228
+ sessionId: string,
1229
+ runId: string,
1230
+ reason: string,
1231
+ ): Promise<void> {
1232
+ const runDir = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId);
1233
+ await appendJsonlIdempotent(
1234
+ path.join(runDir, "index.jsonl"),
1235
+ {
1236
+ event: "planning_stuck",
1237
+ planning_stuck: true,
1238
+ marker: PLANNING_STUCK_MARKER,
1239
+ reason,
1240
+ created_at: new Date().toISOString(),
1241
+ },
1242
+ {
1243
+ cwd,
1244
+ audit: {
1245
+ category: "ledger",
1246
+ verb: "append",
1247
+ owner: "gjc-runtime",
1248
+ skill: "ralplan",
1249
+ sessionId,
1250
+ },
1251
+ key: ralplanPlanningStuckIndexKey,
1252
+ },
1253
+ );
1254
+ const statePath = ralplanStatePath(cwd, sessionId);
1255
+ await withWorkflowStateLock(
1256
+ statePath,
1257
+ async () => {
1258
+ const existingRead = await readExistingStateForMutation(statePath);
1259
+ if (existingRead.kind === "corrupt") return;
1260
+ let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
1261
+ if (existing.run_id !== runId) return;
1262
+ existing.planning_stuck = { marker: PLANNING_STUCK_MARKER, reason };
1263
+ existing = migrateWorkflowState(existing, "ralplan").state;
1264
+ existing.updated_at = new Date().toISOString();
1265
+ await writeWorkflowEnvelopeAtomic(statePath, existing, {
1266
+ cwd,
1267
+ lockHeld: true,
1268
+ receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan planning-stuck", sessionId },
1269
+ audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan", sessionId },
1270
+ });
1271
+ },
1272
+ { cwd },
1273
+ );
1274
+ }
1275
+
1276
+ async function readRalplanFinalAdmission(
1277
+ cwd: string,
1278
+ sessionId: string,
1279
+ runId: string,
1280
+ ): Promise<RalplanAutoHandoffResolution | undefined> {
1281
+ const index = await loadRalplanIndexForCap(cwd, sessionId, runId);
1282
+ if (index.rawText === undefined) return undefined;
1283
+ let finalAdmission: RalplanAutoHandoffResolution | undefined;
1284
+ for (const line of index.rawText.split(/\r?\n/)) {
1285
+ try {
1286
+ const row = JSON.parse(line) as Record<string, unknown>;
1287
+ if (row.stage === "final" && typeof row.path === "string" && typeof row.sha256 === "string") {
1288
+ finalAdmission = parseRalplanFinalAdmission(row.auto_handoff) ?? unavailableRalplanFinalAdmission();
1289
+ }
1290
+ } catch {
1291
+ // The artifact dedupe guard handles malformed ledger records separately.
1292
+ }
1293
+ }
1294
+ return finalAdmission;
1295
+ }
1296
+
1297
+ async function readRalplanPlanningStuck(cwd: string, sessionId: string, runId: string): Promise<boolean> {
1298
+ const index = await loadRalplanIndexForCap(cwd, sessionId, runId);
1299
+ if (index.rawText === undefined) return index.indexPresent;
1300
+ if (index.indexPresent && index.rawLineCount > 0 && index.parseableLines === 0) return true;
1301
+ for (const line of index.rawText.split(/\r?\n/)) {
1302
+ if (!line.trim()) continue;
1303
+ try {
1304
+ const row = JSON.parse(line) as Record<string, unknown>;
1305
+ if (row.planning_stuck === true) return true;
1306
+ } catch {
1307
+ return true;
1308
+ }
1309
+ }
1310
+ return false;
1311
+ }
1312
+
1313
+ async function persistRalplanFinalAdmission(
1314
+ cwd: string,
1315
+ sessionId: string,
1316
+ runId: string,
1317
+ admission: RalplanAutoHandoffResolution,
1318
+ ): Promise<void> {
1319
+ const statePath = ralplanStatePath(cwd, sessionId);
1320
+ await withWorkflowStateLock(
1321
+ statePath,
1322
+ async () => {
1323
+ const existingRead = await readExistingStateForMutation(statePath);
1324
+ if (existingRead.kind === "corrupt") {
1325
+ throw new RalplanCommandError(
1326
+ 2,
1327
+ `existing ralplan state is corrupt or tampered (${existingRead.error}); refusing to record final admission`,
1328
+ );
1329
+ }
1330
+ let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
1331
+ if (existing.run_id !== runId) return;
1332
+ existing.auto_handoff = admission;
1333
+ existing = migrateWorkflowState(existing, "ralplan").state;
1334
+ existing.updated_at = new Date().toISOString();
1335
+ await writeWorkflowEnvelopeAtomic(statePath, existing, {
1336
+ cwd,
1337
+ lockHeld: true,
1338
+ receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan final-admission", sessionId },
1339
+ audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan", sessionId },
1340
+ });
1341
+ },
1342
+ { cwd },
1343
+ );
1344
+ }
1345
+
1346
+ async function findExistingRalplanRunOwners(cwd: string, runId: string): Promise<string[]> {
1347
+ let entries: Dirent<string>[];
1348
+ try {
1349
+ entries = await fs.readdir(gjcRoot(cwd), { withFileTypes: true });
1350
+ } catch (error) {
1351
+ const err = error as NodeJS.ErrnoException;
1352
+ if (err.code === "ENOENT" || err.code === "ENOTDIR") return [];
1353
+ throw error;
1354
+ }
1355
+
1356
+ const owners = await Promise.all(
1357
+ entries.map(async entry => {
1358
+ if (!entry.isDirectory()) return undefined;
1359
+ const sessionId = sessionIdFromDirName(entry.name);
1360
+ if (!sessionId) return undefined;
1361
+
1362
+ const stateRead = await readExistingStateForMutation(ralplanStatePath(cwd, sessionId));
1363
+ const stateOwnsRun =
1364
+ stateRead.kind === "valid" &&
1365
+ typeof stateRead.value.run_id === "string" &&
1366
+ stateRead.value.run_id.trim() === runId;
1367
+ if (stateOwnsRun) return sessionId;
1368
+
1369
+ try {
1370
+ const stat = await fs.stat(path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId));
1371
+ return stat.isDirectory() ? sessionId : undefined;
1372
+ } catch (error) {
1373
+ const err = error as NodeJS.ErrnoException;
1374
+ if (err.code === "ENOENT" || err.code === "ENOTDIR") return undefined;
1375
+ throw error;
1376
+ }
1377
+ }),
1378
+ );
1379
+ return owners.filter((owner): owner is string => owner !== undefined).sort();
1380
+ }
1381
+
1382
+ async function resolveArtifactSessionId(args: readonly string[], cwd: string, explicitRunId: string | undefined) {
1383
+ const flagSessionId = flagValue(args, "--session-id");
1384
+ const currentSession = resolveGjcSessionForWrite(cwd, {
1385
+ flagValue: flagSessionId,
1386
+ envSessionId: process.env.GJC_SESSION_ID,
1387
+ });
1388
+ if (!explicitRunId) return currentSession.gjcSessionId;
1389
+
1390
+ const owners = await findExistingRalplanRunOwners(cwd, explicitRunId);
1391
+ if (owners.length === 0) return currentSession.gjcSessionId;
1392
+ if (owners.length > 1) {
1393
+ throw new RalplanCommandError(
1394
+ 2,
1395
+ `ralplan run ${explicitRunId} has multiple owner sessions (${owners.join(", ")}); repair the fragmented run before writing`,
1396
+ );
1397
+ }
1398
+
1399
+ const ownerSessionId = owners[0]!;
1400
+ if (flagSessionId !== undefined && currentSession.gjcSessionId !== ownerSessionId) {
1401
+ throw new RalplanCommandError(
1402
+ 2,
1403
+ `ralplan run ${explicitRunId} is owned by session ${ownerSessionId}, not ${currentSession.gjcSessionId}`,
1404
+ );
1405
+ }
1406
+ return ownerSessionId;
1407
+ }
661
1408
 
662
1409
  async function resolveArtifactArgs(args: readonly string[], cwd: string): Promise<ResolvedArtifactArgs> {
663
1410
  const stage = flagValue(args, "--stage");
@@ -679,20 +1426,18 @@ async function resolveArtifactArgs(args: readonly string[], cwd: string): Promis
679
1426
  throw new RalplanCommandError(2, `--artifact-env must be ${GJC_RALPLAN_ARTIFACT_ENV}`);
680
1427
  }
681
1428
 
682
- const session = resolveGjcSessionForWrite(cwd, {
683
- flagValue: flagValue(args, "--session-id"),
684
- envSessionId: process.env.GJC_SESSION_ID,
685
- });
686
- const sessionId = session.gjcSessionId;
1429
+ const explicitRunId = flagValue(args, "--run-id")?.trim();
1430
+ if (explicitRunId) assertSafePathComponent(explicitRunId, "run-id");
1431
+
1432
+ const sessionId = await resolveArtifactSessionId(args, cwd, explicitRunId);
687
1433
  assertSafePathComponent(sessionId, "session-id");
688
1434
  const sessionIdRaw = sessionId;
689
1435
 
690
1436
  // Precedence for run_id:
691
1437
  // 1. explicit --run-id flag
692
- // 2. existing run_id field in .gjc/state[/sessions/<id>]/ralplan-state.json
693
- // 3. explicit --session-id flag (use as run id)
1438
+ // 2. existing run_id field in the resolved owner session's ralplan state
1439
+ // 3. resolved owner session id
694
1440
  // 4. freshly generated default run id
695
- const explicitRunId = flagValue(args, "--run-id")?.trim();
696
1441
  const runId = explicitRunId || (await readActiveRunId(cwd, sessionId)) || sessionIdRaw || defaultRunId();
697
1442
  assertSafePathComponent(runId, "run-id");
698
1443
 
@@ -734,6 +1479,7 @@ async function persistArtifact(
734
1479
  cwd: string,
735
1480
  content: string,
736
1481
  sha256: string,
1482
+ finalAdmission?: RalplanAutoHandoffResolution,
737
1483
  ): Promise<PersistedArtifact> {
738
1484
  const runDir = path.join(sessionPlansDir(cwd, resolved.sessionId), "ralplan", resolved.runId);
739
1485
 
@@ -757,6 +1503,7 @@ async function persistArtifact(
757
1503
  path: filePath,
758
1504
  created_at: createdAt,
759
1505
  sha256,
1506
+ ...(finalAdmission ? { auto_handoff: finalAdmission } : {}),
760
1507
  };
761
1508
  await appendJsonlIdempotent(path.join(runDir, "index.jsonl"), indexEntry, {
762
1509
  cwd,
@@ -801,31 +1548,51 @@ interface ExistingStageArtifact {
801
1548
  path: string;
802
1549
  sha256: string;
803
1550
  createdAt: string;
1551
+ autoHandoff?: RalplanAutoHandoffResolution;
1552
+ }
1553
+
1554
+ function parseRalplanFinalAdmission(value: unknown): RalplanAutoHandoffResolution | undefined {
1555
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1556
+ const admission = value as Record<string, unknown>;
1557
+ if (
1558
+ !RALPLAN_AUTO_HANDOFF_TARGETS.has(admission.configuredTarget as RalplanAutoHandoffTarget) ||
1559
+ !RALPLAN_AUTO_HANDOFF_TARGETS.has(admission.effectiveTarget as RalplanAutoHandoffTarget) ||
1560
+ (typeof admission.degradationReason !== "string" && admission.degradationReason !== null) ||
1561
+ typeof admission.source !== "string"
1562
+ ) {
1563
+ return undefined;
1564
+ }
1565
+ return {
1566
+ configuredTarget: admission.configuredTarget as RalplanAutoHandoffTarget,
1567
+ effectiveTarget: admission.effectiveTarget as RalplanAutoHandoffTarget,
1568
+ degradationReason: typeof admission.degradationReason === "string" ? admission.degradationReason : null,
1569
+ source: admission.source,
1570
+ };
1571
+ }
1572
+
1573
+ function unavailableRalplanFinalAdmission(): RalplanAutoHandoffResolution {
1574
+ return {
1575
+ configuredTarget: "off",
1576
+ effectiveTarget: "off",
1577
+ degradationReason: "admission_unavailable",
1578
+ source: "ledger",
1579
+ };
804
1580
  }
805
1581
 
806
1582
  /**
807
- * Find the most recent `index.jsonl` row for a `(stage, stage_n)` pair so a
808
- * repeated `--write` can dedupe instead of silently clobbering the artifact and
809
- * appending a duplicate ledger row. Best-effort: a missing or unreadable index
810
- * yields `undefined`, treated as "no prior artifact". The ledger is the source of
811
- * truth for dedup because it is exactly what a duplicate write would corrupt.
1583
+ * Find the most recent complete `index.jsonl` row for a `(stage, stage_n)` pair
1584
+ * in the single ledger snapshot used by the dedupe guard and both budget gates.
1585
+ * A parseable row missing `path` or `sha256` is intentionally treated as missing
1586
+ * so the deterministic on-disk probe can repair the crash gap.
812
1587
  */
813
- async function findExistingStageArtifact(
814
- cwd: string,
815
- sessionId: string,
816
- runId: string,
1588
+ function findExistingStageArtifact(
1589
+ indexText: string | undefined,
817
1590
  stage: RalplanStage,
818
1591
  stageN: number,
819
- ): Promise<ExistingStageArtifact | undefined> {
820
- const indexPath = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId, "index.jsonl");
821
- let text: string;
822
- try {
823
- text = await fs.readFile(indexPath, "utf8");
824
- } catch {
825
- return undefined;
826
- }
1592
+ ): ExistingStageArtifact | undefined {
1593
+ if (indexText === undefined) return undefined;
827
1594
  let match: ExistingStageArtifact | undefined;
828
- for (const line of text.split(/\r?\n/)) {
1595
+ for (const line of indexText.split(/\r?\n/)) {
829
1596
  const trimmed = line.trim();
830
1597
  if (!trimmed) continue;
831
1598
  let row: unknown;
@@ -842,11 +1609,140 @@ async function findExistingStageArtifact(
842
1609
  path: record.path,
843
1610
  sha256: record.sha256,
844
1611
  createdAt: typeof record.created_at === "string" ? record.created_at : "",
1612
+ ...(stage === "final" ? { autoHandoff: parseRalplanFinalAdmission(record.auto_handoff) } : {}),
845
1613
  };
846
1614
  }
847
1615
  return match;
848
1616
  }
849
1617
 
1618
+ interface OnDiskStageArtifact {
1619
+ path: string;
1620
+ sha256: string;
1621
+ }
1622
+
1623
+ /** Probe the deterministic artifact path left by a crash before ledger append. */
1624
+ async function findOnDiskStageArtifact(
1625
+ cwd: string,
1626
+ resolved: Pick<ResolvedArtifactArgs, "sessionId" | "runId" | "stage" | "stageN">,
1627
+ ): Promise<OnDiskStageArtifact | undefined> {
1628
+ const filePath = path.join(
1629
+ sessionPlansDir(cwd, resolved.sessionId),
1630
+ "ralplan",
1631
+ resolved.runId,
1632
+ `stage-${pad2(resolved.stageN)}-${resolved.stage}.md`,
1633
+ );
1634
+ try {
1635
+ const content = await fs.readFile(filePath, "utf8");
1636
+ return { path: filePath, sha256: createHash("sha256").update(content).digest("hex") };
1637
+ } catch (error) {
1638
+ const code = getErrorCode(error);
1639
+ if (code === "ENOENT" || code === "ENOTDIR") return undefined;
1640
+ throw error;
1641
+ }
1642
+ }
1643
+
1644
+ /** Ensure a deduplicated final stage still has its byte-identical pending-approval copy. */
1645
+ async function ensureFinalPendingApproval(
1646
+ cwd: string,
1647
+ resolved: Pick<ResolvedArtifactArgs, "sessionId" | "runId" | "stageN">,
1648
+ stageArtifact: Pick<OnDiskStageArtifact, "path" | "sha256">,
1649
+ ): Promise<string> {
1650
+ const pendingApprovalPath = path.join(
1651
+ sessionPlansDir(cwd, resolved.sessionId),
1652
+ "ralplan",
1653
+ resolved.runId,
1654
+ "pending-approval.md",
1655
+ );
1656
+ const stageContent = await fs.readFile(stageArtifact.path);
1657
+ const stageSha256 = createHash("sha256").update(stageContent).digest("hex");
1658
+ if (stageSha256 !== stageArtifact.sha256) {
1659
+ throw new RalplanCommandError(
1660
+ 2,
1661
+ `refusing to deduplicate ralplan final stage ${resolved.stageN}: stage artifact sha256 mismatch at ${stageArtifact.path} (ledger sha256=${stageArtifact.sha256}, artifact sha256=${stageSha256}).`,
1662
+ );
1663
+ }
1664
+
1665
+ let pendingContent: Buffer;
1666
+ try {
1667
+ pendingContent = await fs.readFile(pendingApprovalPath);
1668
+ } catch (error) {
1669
+ if (getErrorCode(error) !== "ENOENT") throw error;
1670
+ await writeArtifact(pendingApprovalPath, stageContent.toString("utf8"), {
1671
+ cwd,
1672
+ audit: {
1673
+ category: "artifact",
1674
+ verb: "write",
1675
+ owner: "gjc-runtime",
1676
+ skill: "ralplan",
1677
+ sessionId: resolved.sessionId,
1678
+ },
1679
+ });
1680
+ return pendingApprovalPath;
1681
+ }
1682
+
1683
+ const pendingSha256 = createHash("sha256").update(pendingContent).digest("hex");
1684
+ if (!pendingContent.equals(stageContent) || pendingSha256 !== stageSha256) {
1685
+ throw new RalplanCommandError(
1686
+ 2,
1687
+ `refusing to deduplicate ralplan final stage ${resolved.stageN}: pending approval content mismatch at ${pendingApprovalPath} (stage sha256=${stageSha256}, pending sha256=${pendingSha256}).`,
1688
+ );
1689
+ }
1690
+ return pendingApprovalPath;
1691
+ }
1692
+
1693
+ /** Append the missing row for a deterministic artifact that survived a crash gap. */
1694
+ async function repairMissingStageArtifactLedger(
1695
+ cwd: string,
1696
+ resolved: Pick<ResolvedArtifactArgs, "sessionId" | "runId" | "stage" | "stageN">,
1697
+ onDisk: OnDiskStageArtifact,
1698
+ finalAdmission?: RalplanAutoHandoffResolution,
1699
+ ): Promise<ExistingStageArtifact> {
1700
+ const createdAt = new Date().toISOString();
1701
+ const indexEntry = {
1702
+ stage: resolved.stage,
1703
+ stage_n: resolved.stageN,
1704
+ path: onDisk.path,
1705
+ created_at: createdAt,
1706
+ sha256: onDisk.sha256,
1707
+ ...(finalAdmission ? { auto_handoff: finalAdmission } : {}),
1708
+ };
1709
+ const result = await appendJsonlIdempotent(
1710
+ path.join(sessionPlansDir(cwd, resolved.sessionId), "ralplan", resolved.runId, "index.jsonl"),
1711
+ indexEntry,
1712
+ {
1713
+ cwd,
1714
+ audit: {
1715
+ category: "ledger",
1716
+ verb: "append",
1717
+ owner: "gjc-runtime",
1718
+ skill: "ralplan",
1719
+ sessionId: resolved.sessionId,
1720
+ },
1721
+ key: ralplanIndexKey,
1722
+ },
1723
+ );
1724
+ const duplicate = result.duplicate;
1725
+ if (duplicate && typeof duplicate === "object" && !Array.isArray(duplicate)) {
1726
+ const record = duplicate as Record<string, unknown>;
1727
+ if (typeof record.path === "string" && typeof record.sha256 === "string") {
1728
+ return {
1729
+ path: record.path,
1730
+ sha256: record.sha256,
1731
+ createdAt: typeof record.created_at === "string" ? record.created_at : createdAt,
1732
+ ...(resolved.stage === "final"
1733
+ ? { autoHandoff: parseRalplanFinalAdmission(record.auto_handoff) ?? unavailableRalplanFinalAdmission() }
1734
+ : {}),
1735
+ };
1736
+ }
1737
+ }
1738
+ return {
1739
+ path: onDisk.path,
1740
+ sha256: onDisk.sha256,
1741
+ createdAt,
1742
+ ...(resolved.stage === "final" ? { autoHandoff: finalAdmission ?? unavailableRalplanFinalAdmission() } : {}),
1743
+ };
1744
+ }
1745
+
850
1746
  /**
851
1747
  * Read and parse the run's `index.jsonl` rows. Best-effort: returns [] when the
852
1748
  * file is absent or unreadable so HUD sync never fails on a missing index.
@@ -866,6 +1762,18 @@ async function readRalplanIndexRows(cwd: string, sessionId: string, runId: strin
866
1762
  }
867
1763
  }
868
1764
 
1765
+ /** Read the lane verdict from ralplan run state without making HUD sync fail on state read errors. */
1766
+ async function readRalplanLastReviewVerdict(cwd: string, sessionId: string): Promise<string | undefined> {
1767
+ try {
1768
+ const existingRead = await readExistingStateForMutation(ralplanStatePath(cwd, sessionId));
1769
+ return existingRead.kind === "valid" && typeof existingRead.value.last_review_verdict === "string"
1770
+ ? existingRead.value.last_review_verdict
1771
+ : undefined;
1772
+ } catch {
1773
+ return undefined;
1774
+ }
1775
+ }
1776
+
869
1777
  async function syncRalplanHud(options: {
870
1778
  cwd: string;
871
1779
  sessionId: string;
@@ -873,6 +1781,7 @@ async function syncRalplanHud(options: {
873
1781
  pendingApproval: boolean;
874
1782
  iteration?: number;
875
1783
  runId?: string;
1784
+ reviewPassBudget?: number;
876
1785
  latestSummary?: string;
877
1786
  }): Promise<void> {
878
1787
  try {
@@ -898,15 +1807,31 @@ async function buildRalplanHud(options: {
898
1807
  latestSummary?: string;
899
1808
  runId?: string;
900
1809
  sessionId?: string;
1810
+ reviewPassBudget?: number;
901
1811
  }) {
902
1812
  let iterationFromIndex: number | undefined;
903
1813
  let stages: string | undefined;
1814
+ let architectPasses: number | undefined;
1815
+ let criticPasses: number | undefined;
1816
+ let verdict: string | undefined;
1817
+ let autoHandoff: RalplanAutoHandoffResolution | undefined;
1818
+ let planningStuck = false;
904
1819
  if (options.runId && options.sessionId) {
905
- const rows = await readRalplanIndexRows(options.cwd, options.sessionId, options.runId);
1820
+ const [rows, lastReviewVerdict, persistedAutoHandoff, persistedPlanningStuck] = await Promise.all([
1821
+ readRalplanIndexRows(options.cwd, options.sessionId, options.runId),
1822
+ readRalplanLastReviewVerdict(options.cwd, options.sessionId),
1823
+ readRalplanFinalAdmission(options.cwd, options.sessionId, options.runId),
1824
+ readRalplanPlanningStuck(options.cwd, options.sessionId, options.runId),
1825
+ ]);
1826
+ verdict = lastReviewVerdict;
1827
+ autoHandoff = persistedAutoHandoff;
1828
+ planningStuck = persistedPlanningStuck;
906
1829
  if (rows.length > 0) {
907
1830
  const summary = summarizeRalplanIndex(rows);
908
1831
  iterationFromIndex = summary.iteration;
909
1832
  stages = formatRalplanStagePresence(summary.currentStages);
1833
+ architectPasses = summary.currentStages.filter(stage => stage === "architect").length;
1834
+ criticPasses = summary.currentStages.filter(stage => stage === "critic").length;
910
1835
  }
911
1836
  }
912
1837
  return buildRalplanHudSummary({
@@ -914,6 +1839,12 @@ async function buildRalplanHud(options: {
914
1839
  iteration: options.iteration,
915
1840
  iterationFromIndex,
916
1841
  stages,
1842
+ architectPasses,
1843
+ criticPasses,
1844
+ reviewPassBudget: options.reviewPassBudget,
1845
+ verdict,
1846
+ autoHandoff,
1847
+ planningStuck,
917
1848
  pendingApproval: options.pendingApproval,
918
1849
  latestSummary: options.latestSummary,
919
1850
  updatedAt: new Date().toISOString(),
@@ -921,8 +1852,9 @@ async function buildRalplanHud(options: {
921
1852
  }
922
1853
 
923
1854
  async function handleArtifactWrite(args: readonly string[], cwd: string): Promise<RalplanCommandResult> {
924
- const plannerState = parsePlannerStateArgs(args);
925
1855
  const resolved = await resolveArtifactArgs(args, cwd);
1856
+ const persistedRoleState = parsePersistedRoleStateArgs(args, resolved.stage);
1857
+ const laneVerdict = parseLaneVerdictArgs(args, resolved.stage, resolved.stageN);
926
1858
  // Fail closed before stage persistence / path writes when cwd drifted to a sibling repo.
927
1859
  const repositoryBinding = await enforceRalplanRepositoryBinding(cwd, resolved.sessionId);
928
1860
  // Artifact file paths (when --artifact points at a file) must stay under the bound root.
@@ -944,16 +1876,15 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
944
1876
  const content = resolved.artifact.endsWith("\n") ? resolved.artifact : `${resolved.artifact}\n`;
945
1877
  const sha256 = createHash("sha256").update(content).digest("hex");
946
1878
 
1879
+ // Read the ledger once before persistence. The dedupe guard and both gates
1880
+ // consume this same snapshot so no additional ledger read can slip between gate
1881
+ // evaluation and persistence.
1882
+ const indexLoad = await loadRalplanIndexForCap(cwd, resolved.sessionId, resolved.runId);
1883
+
947
1884
  // Duplicate-write guard: a second `--write` for the same (stage, stage_n) must not
948
1885
  // silently clobber the artifact or append a duplicate ledger row. Classify before any
949
1886
  // state mutation so a conflict never regresses run-state phase.
950
- const existingArtifact = await findExistingStageArtifact(
951
- cwd,
952
- resolved.sessionId,
953
- resolved.runId,
954
- resolved.stage,
955
- resolved.stageN,
956
- );
1887
+ const existingArtifact = findExistingStageArtifact(indexLoad.rawText, resolved.stage, resolved.stageN);
957
1888
  if (existingArtifact) {
958
1889
  if (existingArtifact.sha256 !== sha256) {
959
1890
  throw new RalplanCommandError(
@@ -961,7 +1892,48 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
961
1892
  `refusing to overwrite ralplan ${resolved.stage} stage ${resolved.stageN} at ${existingArtifact.path}: an artifact with different content already exists (existing sha256=${existingArtifact.sha256}, new sha256=${sha256}). Use a new --stage_n to record another pass.`,
962
1893
  );
963
1894
  }
964
- return buildDeduplicatedResult(resolved, existingArtifact, sha256, cwd, repositoryBinding);
1895
+ if (resolved.stage === "final") await ensureFinalPendingApproval(cwd, resolved, existingArtifact);
1896
+ return await buildDeduplicatedResult(resolved, existingArtifact, sha256, cwd, repositoryBinding);
1897
+ }
1898
+
1899
+ // `persistArtifact` writes the artifact before its ledger row. If a process crashed
1900
+ // in that gap, an identical retry repairs the row and remains a normal deduplicated
1901
+ // receipt; a differing retry retains the ordinary no-overwrite refusal.
1902
+ const onDiskArtifact = await findOnDiskStageArtifact(cwd, resolved);
1903
+ if (onDiskArtifact) {
1904
+ if (onDiskArtifact.sha256 !== sha256) {
1905
+ throw new RalplanCommandError(
1906
+ 2,
1907
+ `refusing to overwrite ralplan ${resolved.stage} stage ${resolved.stageN} at ${onDiskArtifact.path}: an artifact with different content already exists (existing sha256=${onDiskArtifact.sha256}, new sha256=${sha256}). Use a new --stage_n to record another pass.`,
1908
+ );
1909
+ }
1910
+ if (resolved.stage === "final") await ensureFinalPendingApproval(cwd, resolved, onDiskArtifact);
1911
+ const repairedArtifact = await repairMissingStageArtifactLedger(
1912
+ cwd,
1913
+ resolved,
1914
+ onDiskArtifact,
1915
+ resolved.stage === "final" ? unavailableRalplanFinalAdmission() : undefined,
1916
+ );
1917
+ let appliedPersistedRoleState: PersistedRoleStateUpdate | undefined;
1918
+ if (
1919
+ persistedRoleState &&
1920
+ (await applyPersistedRoleStateUpdate(cwd, resolved.sessionId, persistedRoleState, resolved.runId))
1921
+ ) {
1922
+ appliedPersistedRoleState = persistedRoleState;
1923
+ }
1924
+ let appliedLaneVerdict: LaneVerdictUpdate | undefined;
1925
+ if (laneVerdict && (await applyLaneVerdictUpdate(cwd, resolved.sessionId, laneVerdict, resolved.runId))) {
1926
+ appliedLaneVerdict = laneVerdict;
1927
+ }
1928
+ return await buildDeduplicatedResult(
1929
+ resolved,
1930
+ repairedArtifact,
1931
+ sha256,
1932
+ cwd,
1933
+ repositoryBinding,
1934
+ appliedLaneVerdict,
1935
+ appliedPersistedRoleState,
1936
+ );
965
1937
  }
966
1938
 
967
1939
  // Consensus iteration budget (#3165): refuse new planner/revision openers past the cap.
@@ -969,31 +1941,72 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
969
1941
  // critic, final, …) remain allowed so operators can escalate without auto-implementation.
970
1942
  // On-disk opener artifacts floor the count so a wiped/truncated/malformed index.jsonl cannot
971
1943
  // under-count and fail open after prior planner/revision writes.
972
- const indexLoad = await loadRalplanIndexForCap(cwd, resolved.sessionId, resolved.runId);
973
- const onDiskOpeners = await countRalplanOnDiskOpeners(cwd, resolved.sessionId, resolved.runId);
974
- const { maxIterations, source: maxIterationsSource } = await resolveRalplanMaxIterations(cwd);
1944
+ //
1945
+ // Gate evaluation, artifact write, and ledger append are sequential within one
1946
+ // `gjc ralplan --write` invocation. This is NOT exclusive across processes: no
1947
+ // run-scoped lock or CAS admission exists, intentionally matching #3165's
1948
+ // check-then-persist exposure.
1949
+ const [onDiskOpeners, onDiskLaneArtifacts, iterationLimit, laneLimit] = await Promise.all([
1950
+ countRalplanOnDiskOpeners(cwd, resolved.sessionId, resolved.runId),
1951
+ countRalplanOnDiskLaneArtifacts(cwd, resolved.sessionId, resolved.runId),
1952
+ resolveRalplanMaxIterations(cwd),
1953
+ resolveRalplanMaxReviewPassesPerLane(cwd),
1954
+ ]);
975
1955
  const capDecision = evaluateRalplanIterationCap({
976
1956
  rows: indexLoad.rows,
977
1957
  stage: resolved.stage,
978
- maxIterations,
1958
+ maxIterations: iterationLimit.maxIterations,
979
1959
  iterationFloor: onDiskOpeners,
980
1960
  });
981
1961
  if (!capDecision.allowed) {
1962
+ await recordRalplanPlanningStuck(cwd, resolved.sessionId, resolved.runId, capDecision.reason);
982
1963
  return buildPlanningStuckResult({
983
1964
  json: resolved.json,
984
1965
  stage: resolved.stage,
985
1966
  stageN: resolved.stageN,
986
1967
  runId: resolved.runId,
987
1968
  decision: capDecision,
988
- source: maxIterationsSource,
1969
+ source: iterationLimit.source,
1970
+ });
1971
+ }
1972
+ const laneBudgetDecision = evaluateRalplanReviewLaneBudget({
1973
+ rows: indexLoad.rows,
1974
+ stage: resolved.stage,
1975
+ maxReviewPassesPerLane: laneLimit.maxReviewPassesPerLane,
1976
+ onDiskLaneCounts: onDiskLaneArtifacts,
1977
+ });
1978
+ if (!laneBudgetDecision.allowed) {
1979
+ await recordRalplanPlanningStuck(cwd, resolved.sessionId, resolved.runId, laneBudgetDecision.reason);
1980
+ return buildLaneBudgetStuckResult({
1981
+ json: resolved.json,
1982
+ stage: resolved.stage,
1983
+ stageN: resolved.stageN,
1984
+ runId: resolved.runId,
1985
+ decision: laneBudgetDecision,
1986
+ source: laneLimit.source,
989
1987
  });
990
1988
  }
991
1989
 
1990
+ let autoHandoff: RalplanAutoHandoffResolution | undefined;
1991
+ if (resolved.stage === "final") {
1992
+ // Resolve and validate the configured admission before any final artifact or
1993
+ // state write. The ledger row below is the durable receipt for deduplicated
1994
+ // retries; state is only a current-session projection.
1995
+ autoHandoff = await resolveRalplanAutoHandoff(cwd, {
1996
+ planningStuck: await readRalplanPlanningStuck(cwd, resolved.sessionId, resolved.runId),
1997
+ });
1998
+ }
992
1999
  // Keep run-state `current_phase` coherent with the stage being persisted.
993
2000
  await persistActiveRunId(cwd, resolved.sessionId, resolved.runId, resolved.stage);
994
- const persisted = await persistArtifact(resolved, cwd, content, sha256);
995
- if (plannerState) {
996
- await applyPlannerStateUpdate(cwd, resolved.sessionId, plannerState);
2001
+ const persisted = await persistArtifact(resolved, cwd, content, sha256, autoHandoff);
2002
+ if (persistedRoleState) {
2003
+ await applyPersistedRoleStateUpdate(cwd, resolved.sessionId, persistedRoleState);
2004
+ }
2005
+ if (laneVerdict) {
2006
+ await applyLaneVerdictUpdate(cwd, resolved.sessionId, laneVerdict);
2007
+ }
2008
+ if (autoHandoff) {
2009
+ await persistRalplanFinalAdmission(cwd, resolved.sessionId, resolved.runId, autoHandoff);
997
2010
  }
998
2011
  await writeSessionActivityMarker(cwd, resolved.sessionId, { writer: "ralplan-runtime", path: persisted.path });
999
2012
  await syncRalplanHud({
@@ -1003,9 +2016,20 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1003
2016
  runId: persisted.runId,
1004
2017
  pendingApproval: persisted.stage === "final",
1005
2018
  iteration: persisted.stageN,
2019
+ reviewPassBudget: laneLimit.maxReviewPassesPerLane,
1006
2020
  latestSummary: `persisted ${persisted.stage} stage ${persisted.stageN}`,
1007
2021
  });
2022
+ const reviewBudgetWarning =
2023
+ laneBudgetDecision.lane && laneBudgetDecision.finalSlot && laneBudgetDecision.maxReviewPassesPerLane > 1
2024
+ ? {
2025
+ lane: laneBudgetDecision.lane,
2026
+ passes: laneBudgetDecision.projectedPasses,
2027
+ max: laneBudgetDecision.maxReviewPassesPerLane,
2028
+ }
2029
+ : undefined;
2030
+
1008
2031
  const payload: Record<string, unknown> = {
2032
+ session_id: resolved.sessionId,
1009
2033
  run_id: persisted.runId,
1010
2034
  path: persisted.path,
1011
2035
  stage: persisted.stage,
@@ -1015,26 +2039,40 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1015
2039
  created_at: persisted.createdAt,
1016
2040
  };
1017
2041
  if (persisted.pendingApprovalPath) payload.pending_approval_path = persisted.pendingApprovalPath;
1018
- if (plannerState) payload.planner_state = plannerStatePayload(plannerState);
2042
+ if (persistedRoleState) payload[`${persistedRoleState.role}_state`] = persistedRoleStatePayload(persistedRoleState);
2043
+ if (reviewBudgetWarning) payload.review_budget_warning = reviewBudgetWarning;
2044
+ if (laneVerdict) payload.lane_verdict = { lane: laneVerdict.lane, verdict: laneVerdict.verdict };
2045
+ if (autoHandoff) payload.auto_handoff = autoHandoff;
2046
+
1019
2047
  const stdout = resolved.json
1020
2048
  ? `${JSON.stringify(payload, null, 2)}\n`
1021
- : `Persisted ralplan ${persisted.stage} stage ${persisted.stageN} at ${persisted.path}.\n`;
2049
+ : `${reviewBudgetWarning ? `Warning: ralplan ${reviewBudgetWarning.lane} review budget final slot used (${reviewBudgetWarning.passes}/${reviewBudgetWarning.max}).\n` : ""}Persisted ralplan ${persisted.stage} stage ${persisted.stageN} at ${persisted.path}.\n`;
1022
2050
  return { status: 0, stdout };
1023
2051
  }
1024
2052
 
1025
2053
  /**
1026
- * Deterministic no-op receipt for an identical repeated `--write`: report the
1027
- * already-persisted artifact without rewriting the file, appending a ledger row, or
1028
- * churning run-state. `deduplicated: true` lets callers distinguish it from a fresh write.
2054
+ * Deterministic receipt for an identical repeated `--write`. Ledger-backed duplicates
2055
+ * do not rewrite artifacts, append rows, or churn run state; a crash-gap repair may
2056
+ * complete riding persisted role/lane metadata before returning this receipt.
1029
2057
  */
1030
- function buildDeduplicatedResult(
2058
+ function applyRalplanPlanningStuckOverride(
2059
+ admission: RalplanAutoHandoffResolution,
2060
+ planningStuck: boolean,
2061
+ ): RalplanAutoHandoffResolution {
2062
+ return planningStuck ? { ...admission, effectiveTarget: "off", degradationReason: "planning_stuck" } : admission;
2063
+ }
2064
+
2065
+ async function buildDeduplicatedResult(
1031
2066
  resolved: ResolvedArtifactArgs,
1032
2067
  existing: ExistingStageArtifact,
1033
2068
  sha256: string,
1034
2069
  cwd: string,
1035
2070
  repositoryBinding: RepositoryBinding,
1036
- ): RalplanCommandResult {
2071
+ laneVerdict?: LaneVerdictUpdate,
2072
+ persistedRoleState?: PersistedRoleStateUpdate,
2073
+ ): Promise<RalplanCommandResult> {
1037
2074
  const payload: Record<string, unknown> = {
2075
+ session_id: resolved.sessionId,
1038
2076
  run_id: resolved.runId,
1039
2077
  path: existing.path,
1040
2078
  stage: resolved.stage,
@@ -1044,6 +2082,10 @@ function buildDeduplicatedResult(
1044
2082
  created_at: existing.createdAt,
1045
2083
  deduplicated: true,
1046
2084
  };
2085
+ if (laneVerdict) payload.lane_verdict = { lane: laneVerdict.lane, verdict: laneVerdict.verdict };
2086
+ if (persistedRoleState) {
2087
+ payload[`${persistedRoleState.role}_state`] = persistedRoleStatePayload(persistedRoleState);
2088
+ }
1047
2089
  if (resolved.stage === "final") {
1048
2090
  payload.pending_approval_path = path.join(
1049
2091
  sessionPlansDir(cwd, resolved.sessionId),
@@ -1051,6 +2093,11 @@ function buildDeduplicatedResult(
1051
2093
  resolved.runId,
1052
2094
  "pending-approval.md",
1053
2095
  );
2096
+ const planningStuck = await readRalplanPlanningStuck(cwd, resolved.sessionId, resolved.runId);
2097
+ payload.auto_handoff = applyRalplanPlanningStuckOverride(
2098
+ existing.autoHandoff ?? unavailableRalplanFinalAdmission(),
2099
+ planningStuck,
2100
+ );
1054
2101
  }
1055
2102
  const stdout = resolved.json
1056
2103
  ? `${JSON.stringify(payload, null, 2)}\n`
@@ -1092,6 +2139,9 @@ function extractPositionalTask(args: readonly string[]): string {
1092
2139
  }
1093
2140
 
1094
2141
  function resolveConsensusArgs(args: readonly string[], cwd: string): ConsensusHandoffArgs {
2142
+ if (hasFlag(args, "--lane-verdict")) {
2143
+ throw new RalplanCommandError(2, "--lane-verdict is only supported with gjc ralplan --write.");
2144
+ }
1095
2145
  const architectKind = flagValue(args, "--architect")?.trim() || undefined;
1096
2146
  if (architectKind && !KNOWN_ARCHITECT_KINDS.has(architectKind)) {
1097
2147
  throw new RalplanCommandError(
@@ -1194,6 +2244,7 @@ async function handleConsensusHandoff(args: readonly string[], cwd: string): Pro
1194
2244
  });
1195
2245
 
1196
2246
  const summary = {
2247
+ session_id: resolved.sessionId,
1197
2248
  skill: "ralplan",
1198
2249
  mode,
1199
2250
  state_path: statePath,