@sayknow-cli/coding-agent 0.4.7 → 0.5.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 (428) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +0 -2
  5. package/dist/types/cli/read-cli.d.ts +1 -0
  6. package/dist/types/cli.d.ts +9 -1
  7. package/dist/types/commands/daemon.d.ts +2 -2
  8. package/dist/types/commands/deep-interview.d.ts +9 -0
  9. package/dist/types/commands/harness.d.ts +22 -0
  10. package/dist/types/commands/read.d.ts +6 -0
  11. package/dist/types/commands/sdk.d.ts +20 -2
  12. package/dist/types/config/keybindings.d.ts +34 -4
  13. package/dist/types/config/model-profile-contract.d.ts +40 -0
  14. package/dist/types/config/model-profiles.d.ts +2 -3
  15. package/dist/types/config/model-registry.d.ts +5 -0
  16. package/dist/types/config/provider-auth-health.d.ts +14 -0
  17. package/dist/types/config/provider-ranking.d.ts +59 -0
  18. package/dist/types/config/settings-schema.d.ts +156 -10
  19. package/dist/types/config/settings.d.ts +3 -0
  20. package/dist/types/edit/modes/patch.d.ts +13 -0
  21. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  22. package/dist/types/eval/py/executor.d.ts +1 -1
  23. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  24. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  25. package/dist/types/extensibility/extensions/types.d.ts +43 -2
  26. package/dist/types/extensibility/shared-events.d.ts +6 -0
  27. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  28. package/dist/types/main.d.ts +33 -2
  29. package/dist/types/memories/index.d.ts +2 -0
  30. package/dist/types/modes/acp/acp-agent.d.ts +22 -9
  31. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  32. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  33. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  34. package/dist/types/modes/components/queue-pane.d.ts +8 -0
  35. package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
  36. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  37. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  38. package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
  39. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  40. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  41. package/dist/types/modes/components/tool-status-header.d.ts +4 -2
  42. package/dist/types/modes/components/welcome.d.ts +5 -0
  43. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  44. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  45. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  46. package/dist/types/modes/interactive-mode.d.ts +13 -2
  47. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  48. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  49. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  50. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  51. package/dist/types/modes/types.d.ts +6 -0
  52. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  53. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  54. package/dist/types/runtime/memory-domain.d.ts +9 -0
  55. package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
  56. package/dist/types/runtime/memory-guard.d.ts +39 -0
  57. package/dist/types/runtime/memory-limit.d.ts +11 -0
  58. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  59. package/dist/types/runtime-mcp/discoverable-tool-metadata.d.ts +1 -1
  60. package/dist/types/runtime-mcp/manager.d.ts +9 -1
  61. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  62. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  63. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  64. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  65. package/dist/types/sdk/acp/final-text.d.ts +19 -0
  66. package/dist/types/sdk/acp/mcp.d.ts +23 -0
  67. package/dist/types/sdk/broker/broker.d.ts +2 -0
  68. package/dist/types/sdk/broker/lifecycle.d.ts +13 -0
  69. package/dist/types/sdk/bus/chat-daemon-control.d.ts +9 -1
  70. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  71. package/dist/types/sdk/bus/config.d.ts +2 -0
  72. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  73. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  74. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  75. package/dist/types/sdk/bus/index.d.ts +52 -4
  76. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
  77. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  78. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  79. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  80. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  81. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +106 -0
  82. package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
  83. package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
  84. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +33 -9
  85. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  86. package/dist/types/sdk/bus/telegram-daemon.d.ts +286 -12
  87. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  88. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  89. package/dist/types/sdk/client/discovery.d.ts +2 -0
  90. package/dist/types/sdk/client/liveness.d.ts +7 -0
  91. package/dist/types/sdk/host/control/operations.d.ts +3 -2
  92. package/dist/types/sdk/host/host.d.ts +3 -1
  93. package/dist/types/sdk/host/query/handlers.d.ts +14 -0
  94. package/dist/types/sdk/index.d.ts +2 -0
  95. package/dist/types/sdk/lifecycle-session.d.ts +10 -1
  96. package/dist/types/sdk/prompt-status.d.ts +88 -0
  97. package/dist/types/sdk/session-directory.d.ts +1 -1
  98. package/dist/types/sdk/session.d.ts +7 -0
  99. package/dist/types/sdk/startup-capability.d.ts +9 -0
  100. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  101. package/dist/types/sdk/transport/index.d.ts +12 -0
  102. package/dist/types/sdk/transport/relay.d.ts +43 -0
  103. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  104. package/dist/types/sdk/transport/socket.d.ts +7 -0
  105. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  106. package/dist/types/session/agent-session.d.ts +83 -24
  107. package/dist/types/session/btw-contract.d.ts +16 -0
  108. package/dist/types/session/fallback-chain-controller.d.ts +18 -2
  109. package/dist/types/session/internal/managed-session-scope.d.ts +70 -10
  110. package/dist/types/session/internal/managed-session-storage.d.ts +34 -12
  111. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  112. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  113. package/dist/types/session/session-manager.d.ts +89 -18
  114. package/dist/types/session/session-storage.d.ts +31 -0
  115. package/dist/types/session/streaming-output.d.ts +60 -5
  116. package/dist/types/setup/credential-import.d.ts +5 -1
  117. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  118. package/dist/types/skc-runtime/deep-interview-runtime.d.ts +1 -1
  119. package/dist/types/skc-runtime/deep-interview-stage.d.ts +37 -0
  120. package/dist/types/skc-runtime/gc-runtime.d.ts +1 -1
  121. package/dist/types/skc-runtime/linux-proc.d.ts +22 -14
  122. package/dist/types/skc-runtime/memory-guard-owner-claims.d.ts +47 -0
  123. package/dist/types/skc-runtime/ralplan-runtime.d.ts +112 -1
  124. package/dist/types/skc-runtime/repository-binding.d.ts +63 -0
  125. package/dist/types/skc-runtime/state-runtime.d.ts +3 -1
  126. package/dist/types/skc-runtime/state-writer.d.ts +5 -1
  127. package/dist/types/skc-runtime/team-launch.d.ts +1 -1
  128. package/dist/types/skc-runtime/team-runtime.d.ts +9 -2
  129. package/dist/types/skc-runtime/team-store.d.ts +12 -0
  130. package/dist/types/skc-runtime/team-worker-memory-guard.d.ts +92 -0
  131. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +7 -0
  132. package/dist/types/skc-runtime/ultragoal-guard.d.ts +6 -5
  133. package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +28 -0
  134. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +43 -5
  135. package/dist/types/skill-state/workflow-hud.d.ts +3 -0
  136. package/dist/types/ssh/utils.d.ts +1 -0
  137. package/dist/types/task/discovery.d.ts +3 -1
  138. package/dist/types/task/executor.d.ts +6 -0
  139. package/dist/types/task/index.d.ts +3 -1
  140. package/dist/types/task/provider-retry-status.d.ts +13 -0
  141. package/dist/types/task/receipt.d.ts +2 -0
  142. package/dist/types/task/types.d.ts +133 -0
  143. package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
  144. package/dist/types/tools/browser/launch.d.ts +7 -0
  145. package/dist/types/tools/fetch.d.ts +22 -9
  146. package/dist/types/tools/image-gen.d.ts +63 -1
  147. package/dist/types/tools/index.d.ts +7 -1
  148. package/dist/types/tools/output-meta.d.ts +21 -1
  149. package/dist/types/tools/read-internals.d.ts +12 -0
  150. package/dist/types/tools/read.d.ts +45 -1
  151. package/dist/types/tools/resource-gc.d.ts +23 -0
  152. package/dist/types/tools/skill-discovery.d.ts +7 -0
  153. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  154. package/dist/types/tools/tool-result.d.ts +7 -1
  155. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  156. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  157. package/dist/types/web/insane/bridge.d.ts +1 -0
  158. package/dist/types/web/insane/url-guard.d.ts +17 -0
  159. package/package.json +8 -12
  160. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  161. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  162. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  163. package/scripts/compile-args.ts +0 -1
  164. package/scripts/dogfood-repository-binding.ts +209 -0
  165. package/scripts/generate-docs-index.ts +4 -1
  166. package/scripts/generate-sdk-operation-inventory.ts +9 -0
  167. package/scripts/run-test-manifest.ts +6 -4
  168. package/scripts/verify-skc-sdk-canonicalization.ts +122 -18
  169. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  170. package/src/capability/index.ts +58 -42
  171. package/src/capability/types.ts +3 -0
  172. package/src/cli/args.ts +16 -100
  173. package/src/cli/fast-help.ts +1 -1
  174. package/src/cli/mcp-cli.ts +4 -21
  175. package/src/cli/notify-cli.ts +6 -38
  176. package/src/cli/read-cli.ts +2 -1
  177. package/src/cli/setup-cli.ts +10 -4
  178. package/src/cli.ts +133 -11
  179. package/src/commands/daemon.ts +22 -5
  180. package/src/commands/deep-interview.ts +26 -1
  181. package/src/commands/harness.ts +35 -12
  182. package/src/commands/read.ts +11 -2
  183. package/src/commands/sdk.ts +134 -17
  184. package/src/commands/team.ts +1 -1
  185. package/src/commit/agentic/index.ts +3 -3
  186. package/src/commit/map-reduce/index.ts +2 -2
  187. package/src/config/keybindings.ts +158 -25
  188. package/src/config/model-profile-contract.ts +174 -0
  189. package/src/config/model-profiles.ts +51 -10
  190. package/src/config/model-registry.ts +24 -4
  191. package/src/config/model-resolver.ts +2 -2
  192. package/src/config/provider-auth-health.ts +42 -0
  193. package/src/config/provider-ranking.ts +119 -0
  194. package/src/config/settings-schema.ts +157 -10
  195. package/src/config/settings.ts +254 -108
  196. package/src/coordinator-mcp/model-preset.ts +21 -74
  197. package/src/defaults/skc/skills/deep-interview/SKILL.md +147 -24
  198. package/src/defaults/skc/skills/ralplan/SKILL.md +91 -34
  199. package/src/defaults/skc/skills/team/SKILL.md +1 -1
  200. package/src/defaults/skc/skills/ultragoal/SKILL.md +68 -17
  201. package/src/discovery/ssh.ts +12 -1
  202. package/src/edit/index.ts +3 -6
  203. package/src/edit/modes/patch.ts +38 -1
  204. package/src/edit/modes/replace.ts +42 -0
  205. package/src/edit/path-mutation-lock.ts +112 -0
  206. package/src/eval/js/tool-bridge.ts +1 -1
  207. package/src/eval/py/executor.ts +37 -13
  208. package/src/eval/py/kernel.ts +1 -21
  209. package/src/eval/py/prelude.py +2 -2
  210. package/src/eval/py/runner-artifact.ts +84 -0
  211. package/src/eval/py/tool-bridge.ts +34 -18
  212. package/src/extensibility/extensions/runner.ts +4 -0
  213. package/src/extensibility/extensions/types.ts +42 -4
  214. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  215. package/src/extensibility/shared-events.ts +5 -0
  216. package/src/extensibility/skc-plugins/runtime-adapters.ts +2 -1
  217. package/src/hooks/native-skill-hook.ts +17 -4
  218. package/src/internal-urls/docs-index.generated.ts +17 -16
  219. package/src/internal-urls/local-protocol.ts +87 -25
  220. package/src/internal-urls/local-root-gc.ts +127 -0
  221. package/src/internal-urls/mcp-protocol.ts +73 -11
  222. package/src/main.ts +120 -42
  223. package/src/memories/index.ts +10 -0
  224. package/src/modes/DESIGN.md +52 -0
  225. package/src/modes/acp/acp-agent.ts +647 -83
  226. package/src/modes/acp/acp-event-mapper.ts +106 -12
  227. package/src/modes/acp/acp-mode.ts +39 -3
  228. package/src/modes/action-registry.ts +1 -1
  229. package/src/modes/components/assistant-message.ts +123 -22
  230. package/src/modes/components/btw-panel.ts +82 -16
  231. package/src/modes/components/irc-sidebar.ts +176 -62
  232. package/src/modes/components/model-selector.ts +61 -4
  233. package/src/modes/components/notifications-settings-editor.ts +1 -1
  234. package/src/modes/components/oauth-selector.ts +42 -12
  235. package/src/modes/components/plan-preview-overlay.ts +1 -1
  236. package/src/modes/components/queue-pane.ts +55 -9
  237. package/src/modes/components/queued-message-selector.ts +53 -10
  238. package/src/modes/components/read-tool-group.ts +82 -14
  239. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  240. package/src/modes/components/sayknow-pet-widget.ts +20 -8
  241. package/src/modes/components/session-observer-overlay.ts +187 -32
  242. package/src/modes/components/settings-selector.ts +60 -18
  243. package/src/modes/components/tool-execution.ts +138 -27
  244. package/src/modes/components/tool-status-header.ts +26 -8
  245. package/src/modes/components/welcome.ts +50 -23
  246. package/src/modes/controllers/btw-controller.ts +119 -36
  247. package/src/modes/controllers/command-controller.ts +101 -54
  248. package/src/modes/controllers/event-controller.ts +119 -27
  249. package/src/modes/controllers/extension-ui-controller.ts +23 -12
  250. package/src/modes/controllers/input-controller.ts +100 -8
  251. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  252. package/src/modes/controllers/selector-controller.ts +4 -1
  253. package/src/modes/interactive-mode.ts +201 -60
  254. package/src/modes/irc-observation-ledger.ts +71 -15
  255. package/src/modes/prompt-action-autocomplete.ts +32 -13
  256. package/src/modes/prompt-suggestion-controller.ts +96 -0
  257. package/src/modes/runtime-init.ts +20 -2
  258. package/src/modes/shared/agent-wire/command-dispatch.ts +3 -3
  259. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  260. package/src/modes/types.ts +6 -0
  261. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  262. package/src/modes/utils/ui-helpers.ts +99 -4
  263. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  264. package/src/prompts/agents/architect.md +17 -3
  265. package/src/prompts/agents/critic.md +17 -2
  266. package/src/prompts/agents/executor.md +1 -1
  267. package/src/prompts/agents/planner.md +8 -1
  268. package/src/prompts/system/btw-user.md +3 -8
  269. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  270. package/src/prompts/system/system-prompt.md +6 -3
  271. package/src/prompts/tools/cron.md +3 -1
  272. package/src/prompts/tools/read.md +4 -2
  273. package/src/prompts/tools/skill-discovery.md +1 -0
  274. package/src/runtime/memory-domain.ts +66 -0
  275. package/src/runtime/memory-guard-contract.ts +73 -0
  276. package/src/runtime/memory-guard.ts +292 -0
  277. package/src/runtime/memory-limit.ts +50 -0
  278. package/src/runtime-mcp/client.ts +41 -55
  279. package/src/runtime-mcp/content-limits.ts +71 -0
  280. package/src/runtime-mcp/discoverable-tool-metadata.ts +1 -1
  281. package/src/runtime-mcp/json-rpc.ts +28 -11
  282. package/src/runtime-mcp/manager.ts +77 -59
  283. package/src/runtime-mcp/oauth-flow.ts +0 -72
  284. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  285. package/src/runtime-mcp/redaction.ts +30 -0
  286. package/src/runtime-mcp/smithery-auth.ts +17 -2
  287. package/src/runtime-mcp/smithery-connect.ts +11 -1
  288. package/src/runtime-mcp/transports/http.ts +63 -22
  289. package/src/sdk/acp/adapter.ts +8 -1
  290. package/src/sdk/acp/final-text.ts +40 -0
  291. package/src/sdk/acp/mcp.ts +26 -0
  292. package/src/sdk/broker/broker.ts +34 -20
  293. package/src/sdk/broker/identity.ts +22 -12
  294. package/src/sdk/broker/lifecycle.ts +293 -43
  295. package/src/sdk/broker/session-index.ts +11 -1
  296. package/src/sdk/bus/chat-daemon-cli.ts +2 -2
  297. package/src/sdk/bus/chat-daemon-control.ts +22 -5
  298. package/src/sdk/bus/config-commands.ts +2 -2
  299. package/src/sdk/bus/config.ts +10 -2
  300. package/src/sdk/bus/control-drain-lease.ts +104 -0
  301. package/src/sdk/bus/conversation-store.ts +40 -6
  302. package/src/sdk/bus/daemon-paths.ts +2 -0
  303. package/src/sdk/bus/index.ts +793 -121
  304. package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
  305. package/src/sdk/bus/lifecycle-commands.ts +63 -10
  306. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  307. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  308. package/src/sdk/bus/notification-orchestration.ts +12 -0
  309. package/src/sdk/bus/notification-service.ts +87 -25
  310. package/src/sdk/bus/operator-runtime.ts +31 -5
  311. package/src/sdk/bus/prompt-reconciliation.ts +240 -0
  312. package/src/sdk/bus/rate-limit-pool.ts +8 -0
  313. package/src/sdk/bus/recent-activity.ts +90 -26
  314. package/src/sdk/bus/reconciliation-store.ts +224 -0
  315. package/src/sdk/bus/telegram-cli.ts +6 -13
  316. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  317. package/src/sdk/bus/telegram-daemon-contract.ts +34 -9
  318. package/src/sdk/bus/telegram-daemon-control.ts +161 -40
  319. package/src/sdk/bus/telegram-daemon.ts +3649 -593
  320. package/src/sdk/bus/telegram-reference.ts +20 -2
  321. package/src/sdk/bus/topic-registry.ts +451 -12
  322. package/src/sdk/cli/session-cli.ts +15 -2
  323. package/src/sdk/client/discovery.ts +19 -3
  324. package/src/sdk/client/liveness.ts +49 -0
  325. package/src/sdk/host/control/dispatch.ts +8 -2
  326. package/src/sdk/host/control/operations.ts +3 -2
  327. package/src/sdk/host/host.ts +27 -9
  328. package/src/sdk/host/query/handlers.ts +125 -5
  329. package/src/sdk/index.ts +12 -0
  330. package/src/sdk/lifecycle-session.ts +19 -3
  331. package/src/sdk/mcp/server.ts +46 -18
  332. package/src/sdk/prompt-status.ts +97 -0
  333. package/src/sdk/protocol/operation-inventory.generated.json +145 -0
  334. package/src/sdk/protocol/operation-registry.ts +24 -4
  335. package/src/sdk/session-directory.ts +5 -1
  336. package/src/sdk/session.ts +69 -8
  337. package/src/sdk/startup-capability.ts +15 -1
  338. package/src/sdk/transport/auth-preface.ts +82 -0
  339. package/src/sdk/transport/index.ts +18 -0
  340. package/src/sdk/transport/relay.ts +252 -0
  341. package/src/sdk/transport/serve-cli.ts +105 -0
  342. package/src/sdk/transport/socket.ts +197 -0
  343. package/src/sdk/transport/stdio.ts +17 -0
  344. package/src/session/agent-session.ts +1112 -314
  345. package/src/session/blob-store.ts +25 -7
  346. package/src/session/btw-contract.ts +59 -0
  347. package/src/session/fallback-chain-controller.ts +47 -8
  348. package/src/session/internal/managed-session-scope.ts +1426 -259
  349. package/src/session/internal/managed-session-storage.ts +414 -238
  350. package/src/session/internal/native-publish-outcome.ts +271 -0
  351. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  352. package/src/session/session-manager.ts +2079 -469
  353. package/src/session/session-storage.ts +151 -11
  354. package/src/session/streaming-output.ts +262 -43
  355. package/src/setup/credential-import.ts +28 -3
  356. package/src/setup/provider-onboarding.ts +49 -5
  357. package/src/setup/provider-presets.json +2 -1
  358. package/src/skc-runtime/deep-interview-runtime.ts +303 -36
  359. package/src/skc-runtime/deep-interview-stage.ts +1016 -0
  360. package/src/skc-runtime/deep-interview-state.ts +8 -5
  361. package/src/skc-runtime/gc-render.ts +1 -0
  362. package/src/skc-runtime/gc-runtime.ts +11 -1
  363. package/src/skc-runtime/linux-proc.ts +70 -36
  364. package/src/skc-runtime/managed-owner-supervisor.ts +83 -4
  365. package/src/skc-runtime/memory-guard-owner-claims.ts +389 -0
  366. package/src/skc-runtime/ralplan-runtime.ts +1057 -75
  367. package/src/skc-runtime/repository-binding.ts +267 -0
  368. package/src/skc-runtime/session-state-sidecar.ts +25 -9
  369. package/src/skc-runtime/state-runtime.ts +50 -12
  370. package/src/skc-runtime/state-writer.ts +68 -56
  371. package/src/skc-runtime/team-launch.ts +34 -5
  372. package/src/skc-runtime/team-runtime.ts +41 -1
  373. package/src/skc-runtime/team-store.ts +32 -0
  374. package/src/skc-runtime/team-worker-memory-guard.ts +363 -0
  375. package/src/skc-runtime/team-workers.ts +1 -0
  376. package/src/skc-runtime/tmux-common.ts +13 -2
  377. package/src/skc-runtime/tmux-owner-isolation.ts +20 -1
  378. package/src/skc-runtime/tmux-sessions.ts +45 -6
  379. package/src/skc-runtime/ultragoal-guard.ts +93 -15
  380. package/src/skc-runtime/ultragoal-receipt-freshness.ts +123 -0
  381. package/src/skc-runtime/ultragoal-runtime.ts +469 -38
  382. package/src/skc-runtime/workflow-manifest.generated.json +195 -5
  383. package/src/skc-runtime/workflow-manifest.ts +67 -3
  384. package/src/skill-state/workflow-hud.ts +25 -2
  385. package/src/skill-state/workflow-mutation-guard.ts +104 -12
  386. package/src/slash-commands/builtin-registry.ts +1 -1
  387. package/src/ssh/utils.ts +14 -0
  388. package/src/task/discovery.ts +8 -2
  389. package/src/task/executor.ts +165 -47
  390. package/src/task/index.ts +123 -15
  391. package/src/task/provider-retry-status.ts +88 -0
  392. package/src/task/receipt.ts +3 -0
  393. package/src/task/render.ts +45 -26
  394. package/src/task/skc-command.ts +2 -2
  395. package/src/task/types.ts +55 -10
  396. package/src/task/ultragoal-redteam-activation.ts +75 -0
  397. package/src/tools/ask.ts +1 -1
  398. package/src/tools/ast-edit.ts +2 -2
  399. package/src/tools/bash-allowed-prefixes.ts +45 -1
  400. package/src/tools/bash-pty-selection.ts +2 -2
  401. package/src/tools/browser/launch.ts +41 -6
  402. package/src/tools/browser/screenshot-format.ts +6 -1
  403. package/src/tools/cron.ts +1 -1
  404. package/src/tools/fetch.ts +127 -154
  405. package/src/tools/image-gen.ts +476 -23
  406. package/src/tools/index.ts +7 -1
  407. package/src/tools/output-meta.ts +176 -9
  408. package/src/tools/path-utils.ts +5 -1
  409. package/src/tools/read-internals.ts +44 -0
  410. package/src/tools/read.ts +1544 -310
  411. package/src/tools/resource-gc.ts +665 -125
  412. package/src/tools/skill-discovery.ts +39 -3
  413. package/src/tools/sqlite-reader.ts +63 -5
  414. package/src/tools/subagent-render.ts +74 -16
  415. package/src/tools/subagent.ts +2 -0
  416. package/src/tools/tool-result.ts +14 -1
  417. package/src/utils/edit-mode.ts +2 -2
  418. package/src/utils/pasted-image-path.ts +15 -6
  419. package/src/utils/prompt-suggestion.ts +265 -0
  420. package/src/utils/shell-snapshot.ts +173 -63
  421. package/src/utils/sixel.ts +3 -3
  422. package/src/web/insane/bridge.ts +5 -1
  423. package/src/web/insane/url-guard.ts +140 -27
  424. package/src/web/scrapers/docs-rs.ts +30 -5
  425. package/src/web/scrapers/types.ts +19 -49
  426. package/src/web/scrapers/utils.ts +12 -26
  427. package/src/web/search/providers/codex.ts +1 -1
  428. package/src/workflow/workflow-intent-diff.ts +4 -1
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import { getConfigRootDir } from "@sayknow-cli/utils";
4
5
  import { syncSkillActiveState } from "../skill-state/active-state";
5
6
  import { buildRalplanHudSummary } from "../skill-state/workflow-hud";
6
7
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
@@ -11,8 +12,17 @@ import {
11
12
  type RalplanIndexRow,
12
13
  summarizeRalplanIndex,
13
14
  } from "./ledger-event-renderer";
15
+ import {
16
+ assertCwdMatchesRepositoryBinding,
17
+ assertPathUnderRepositoryBinding,
18
+ captureRepositoryBinding,
19
+ parseRepositoryBinding,
20
+ publicRepositoryBinding,
21
+ type RepositoryBinding,
22
+ RepositoryBindingError,
23
+ } from "./repository-binding";
14
24
  import { isRestrictedRoleAgentBash, SKC_RALPLAN_ARTIFACT_ENV } from "./restricted-role-agent-bash";
15
- import { modeStatePath, sessionPlansDir } from "./session-layout";
25
+ import { modeStatePath, sessionPlansDir, skcRoot } from "./session-layout";
16
26
  import { resolveSkcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
17
27
  import { migrateWorkflowState } from "./state-migrations";
18
28
  import { runNativeStateCommand } from "./state-runtime";
@@ -40,7 +50,7 @@ import { getSkillManifest } from "./workflow-manifest";
40
50
  *
41
51
  * 2. **Artifact write**: `skc ralplan --write --stage <type> --stage_n <N>
42
52
  * (--artifact <path-or-string> | --artifact-env SKC_RALPLAN_ARTIFACT)
43
- * [--run-id <id>] [--session-id <id>] [--json]` persists Planner / Architect
53
+ * [--run-id <id>] [--session-id <id>] [--lane-verdict <token>] [--json]` persists Planner / Architect
44
54
  * / Critic / revision / post-interview / ADR / final markdown under `.skc/plans/ralplan/<run-id>/`, maintains
45
55
  * an `index.jsonl` audit log, copies `final` stages to `pending-approval.md`, and advances
46
56
  * the HUD chip to reflect the latest persisted stage.
@@ -54,6 +64,498 @@ export interface RalplanCommandResult {
54
64
 
55
65
  const KNOWN_STAGES = ["planner", "architect", "critic", "revision", "post-interview", "adr", "final"] as const;
56
66
  type RalplanStage = (typeof KNOWN_STAGES)[number];
67
+ /** Default consensus iterations (planner + revision openers) per run. Matches SKILL.md re-review cap. */
68
+ export const RALPLAN_DEFAULT_MAX_ITERATIONS = 5;
69
+ /** Inclusive upper bound for `skc.ralplan.maxIterations` settings overrides. */
70
+ export const RALPLAN_MAX_ITERATIONS_LIMIT = 20;
71
+ /** Operator-visible stuck signal for headless/CI orchestration (#3165). */
72
+ export const PLANNING_STUCK_MARKER = "PLANNING-STUCK";
73
+ /** Default architect/critic review passes per consensus iteration. */
74
+ export const RALPLAN_DEFAULT_MAX_REVIEW_PASSES_PER_LANE = 1;
75
+ /** Inclusive upper bound for `skc.ralplan.maxReviewPassesPerLane` settings overrides. */
76
+ export const RALPLAN_MAX_REVIEW_PASSES_PER_LANE_LIMIT = 10;
77
+
78
+ const RALPLAN_ITERATION_OPENER_STAGES = new Set<RalplanStage>(["planner", "revision"]);
79
+
80
+ /** Collapse duplicate ledger rows for the same deterministic stage artifact before review-lane budget accounting. */
81
+ function deduplicateRalplanIndexRowsByStageIdentity(rows: readonly RalplanIndexRow[]): RalplanIndexRow[] {
82
+ const seen = new Set<string>();
83
+ return rows.filter(row => {
84
+ if (typeof row.stageN !== "number") return true;
85
+ const identity = `${row.stage}\u0000${row.stageN}`;
86
+ if (seen.has(identity)) return false;
87
+ seen.add(identity);
88
+ return true;
89
+ });
90
+ }
91
+
92
+ export type RalplanIterationCapDecision =
93
+ | {
94
+ allowed: true;
95
+ currentIterations: number;
96
+ projectedIterations: number;
97
+ maxIterations: number;
98
+ }
99
+ | {
100
+ allowed: false;
101
+ currentIterations: number;
102
+ projectedIterations: number;
103
+ maxIterations: number;
104
+ reason: string;
105
+ };
106
+
107
+ /**
108
+ * Pure consensus-iteration budget gate (#3165).
109
+ *
110
+ * A `planner` or `revision` write opens a new iteration (same definition as
111
+ * `summarizeRalplanIndex`). Other stages never open iterations and are always
112
+ * allowed by this gate — including `final` after the cap is already reached.
113
+ *
114
+ * `iterationFloor` raises the observed opener count when on-disk evidence or a
115
+ * recovered ledger is higher than the parsed index (fail-closed vs wipe/truncate).
116
+ */
117
+ export function evaluateRalplanIterationCap(input: {
118
+ rows: readonly RalplanIndexRow[];
119
+ stage: string;
120
+ maxIterations?: number;
121
+ /** Minimum opener count (e.g. on-disk stage-*-{planner,revision}.md). */
122
+ iterationFloor?: number;
123
+ }): RalplanIterationCapDecision {
124
+ const maxIterations =
125
+ typeof input.maxIterations === "number" &&
126
+ Number.isInteger(input.maxIterations) &&
127
+ input.maxIterations >= 1 &&
128
+ input.maxIterations <= RALPLAN_MAX_ITERATIONS_LIMIT
129
+ ? input.maxIterations
130
+ : RALPLAN_DEFAULT_MAX_ITERATIONS;
131
+ const fromIndex = summarizeRalplanIndex(input.rows).iteration;
132
+ const floor =
133
+ typeof input.iterationFloor === "number" && Number.isInteger(input.iterationFloor) && input.iterationFloor > 0
134
+ ? input.iterationFloor
135
+ : 0;
136
+ const currentIterations = Math.max(fromIndex, floor);
137
+ if (!RALPLAN_ITERATION_OPENER_STAGES.has(input.stage as RalplanStage)) {
138
+ return {
139
+ allowed: true,
140
+ currentIterations,
141
+ projectedIterations: currentIterations,
142
+ maxIterations,
143
+ };
144
+ }
145
+ const projectedIterations = currentIterations + 1;
146
+ if (projectedIterations > maxIterations) {
147
+ const ledgerNote = floor > fromIndex ? ` (ledger under-count: index=${fromIndex}, on-disk openers=${floor})` : "";
148
+ return {
149
+ allowed: false,
150
+ currentIterations,
151
+ projectedIterations,
152
+ maxIterations,
153
+ reason:
154
+ `ralplan consensus iteration cap exceeded: opening ${input.stage} would start ` +
155
+ `iteration ${projectedIterations} (max ${maxIterations})${ledgerNote}`,
156
+ };
157
+ }
158
+ return {
159
+ allowed: true,
160
+ currentIterations,
161
+ projectedIterations,
162
+ maxIterations,
163
+ };
164
+ }
165
+
166
+ export type RalplanReviewLane = "architect" | "critic";
167
+
168
+ export type RalplanReviewLaneBudgetDecision =
169
+ | {
170
+ allowed: true;
171
+ lane?: RalplanReviewLane;
172
+ currentPasses: number;
173
+ projectedPasses: number;
174
+ maxReviewPassesPerLane: number;
175
+ finalSlot: boolean;
176
+ ledgerNote?: string;
177
+ }
178
+ | {
179
+ allowed: false;
180
+ lane: RalplanReviewLane;
181
+ currentPasses: number;
182
+ projectedPasses: number;
183
+ maxReviewPassesPerLane: number;
184
+ finalSlot: false;
185
+ ledgerNote?: string;
186
+ reason: string;
187
+ };
188
+
189
+ /**
190
+ * Pure per-lane review-pass budget gate. Architect and critic passes are limited
191
+ * within the current consensus iteration; all other stages remain unconditionally
192
+ * available as escalation paths.
193
+ */
194
+ export function evaluateRalplanReviewLaneBudget(input: {
195
+ rows: readonly RalplanIndexRow[];
196
+ stage: string;
197
+ maxReviewPassesPerLane?: unknown;
198
+ onDiskLaneCounts?: { architect: number; critic: number };
199
+ }): RalplanReviewLaneBudgetDecision {
200
+ const maxReviewPassesPerLane =
201
+ typeof input.maxReviewPassesPerLane === "number" &&
202
+ Number.isInteger(input.maxReviewPassesPerLane) &&
203
+ input.maxReviewPassesPerLane >= 1 &&
204
+ input.maxReviewPassesPerLane <= RALPLAN_MAX_REVIEW_PASSES_PER_LANE_LIMIT
205
+ ? input.maxReviewPassesPerLane
206
+ : RALPLAN_DEFAULT_MAX_REVIEW_PASSES_PER_LANE;
207
+ if (input.stage !== "architect" && input.stage !== "critic") {
208
+ return {
209
+ allowed: true,
210
+ currentPasses: 0,
211
+ projectedPasses: 0,
212
+ maxReviewPassesPerLane,
213
+ finalSlot: false,
214
+ };
215
+ }
216
+
217
+ const lane = input.stage as RalplanReviewLane;
218
+ const rows = deduplicateRalplanIndexRowsByStageIdentity(input.rows);
219
+ const summary = summarizeRalplanIndex(rows);
220
+ const indexCurrent = summary.currentStages.filter(stage => stage === lane).length;
221
+ const parsedTotal = rows.filter(row => row.stage === lane).length;
222
+ const onDiskRaw = input.onDiskLaneCounts?.[lane];
223
+ const onDiskTotal = typeof onDiskRaw === "number" && Number.isInteger(onDiskRaw) && onDiskRaw > 0 ? onDiskRaw : 0;
224
+ const diskExcess = Math.max(0, onDiskTotal - parsedTotal);
225
+ const currentPasses = indexCurrent + diskExcess;
226
+ const projectedPasses = currentPasses + 1;
227
+ const ledgerNote =
228
+ diskExcess > 0
229
+ ? ` (ledger under-count: parsed ${lane} rows=${parsedTotal}, on-disk ${lane} artifacts=${onDiskTotal})`
230
+ : undefined;
231
+ const finalSlot = projectedPasses === maxReviewPassesPerLane;
232
+ if (projectedPasses > maxReviewPassesPerLane) {
233
+ return {
234
+ allowed: false,
235
+ lane,
236
+ currentPasses,
237
+ projectedPasses,
238
+ maxReviewPassesPerLane,
239
+ finalSlot: false,
240
+ ledgerNote,
241
+ reason:
242
+ `ralplan review lane budget exceeded: ${lane} pass ${projectedPasses} of max ${maxReviewPassesPerLane} ` +
243
+ `in consensus iteration ${Math.max(1, summary.iteration)}${ledgerNote ?? ""}`,
244
+ };
245
+ }
246
+ return {
247
+ allowed: true,
248
+ lane,
249
+ currentPasses,
250
+ projectedPasses,
251
+ maxReviewPassesPerLane,
252
+ finalSlot,
253
+ ledgerNote,
254
+ };
255
+ }
256
+
257
+ function getErrorCode(error: unknown): string | undefined {
258
+ if (!error || typeof error !== "object" || !("code" in error)) return undefined;
259
+ const code = (error as { code?: unknown }).code;
260
+ return typeof code === "string" ? code : undefined;
261
+ }
262
+
263
+ /** Filename pattern for persisted planner/revision stage artifacts. */
264
+ const OPENER_ARTIFACT_RE = /^stage-\d{2,}-(planner|revision)\.md$/;
265
+ const LANE_ARTIFACT_RE = /^stage-\d{2,}-(architect|critic)\.md$/;
266
+
267
+ /**
268
+ * Count on-disk planner/revision stage artifacts for a run. Used as a floor when
269
+ * `index.jsonl` is missing, empty, truncated, or otherwise under-counts openers.
270
+ */
271
+ export async function countRalplanOnDiskOpeners(cwd: string, sessionId: string, runId: string): Promise<number> {
272
+ const runDir = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId);
273
+ try {
274
+ const entries = await fs.readdir(runDir);
275
+ let count = 0;
276
+ for (const name of entries) {
277
+ if (OPENER_ARTIFACT_RE.test(name)) count += 1;
278
+ }
279
+ return count;
280
+ } catch {
281
+ return 0;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Count on-disk Architect/Critic stage artifacts for a run. This is the
287
+ * fail-closed floor for a missing, truncated, or malformed `index.jsonl`.
288
+ */
289
+ export async function countRalplanOnDiskLaneArtifacts(
290
+ cwd: string,
291
+ sessionId: string,
292
+ runId: string,
293
+ ): Promise<{ architect: number; critic: number }> {
294
+ const runDir = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId);
295
+ try {
296
+ const entries = await fs.readdir(runDir);
297
+ const counts = { architect: 0, critic: 0 };
298
+ for (const name of entries) {
299
+ const match = LANE_ARTIFACT_RE.exec(name);
300
+ if (match) counts[match[1] as RalplanReviewLane] += 1;
301
+ }
302
+ return counts;
303
+ } catch (error) {
304
+ if (getErrorCode(error) === "ENOENT") return { architect: 0, critic: 0 };
305
+ throw error;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Load index rows for cap enforcement. Unlike HUD reads, returns structural
311
+ * signals so callers can fail closed when the ledger is empty/malformed while
312
+ * opener artifacts already exist on disk.
313
+ */
314
+ export async function loadRalplanIndexForCap(
315
+ cwd: string,
316
+ sessionId: string,
317
+ runId: string,
318
+ ): Promise<{
319
+ rows: RalplanIndexRow[];
320
+ indexPresent: boolean;
321
+ parseableLines: number;
322
+ rawLineCount: number;
323
+ rawText?: string;
324
+ }> {
325
+ const indexPath = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId, "index.jsonl");
326
+ try {
327
+ const text = await fs.readFile(indexPath, "utf8");
328
+ const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
329
+ const rows: RalplanIndexRow[] = [];
330
+ for (const line of lines) {
331
+ const row = parseRalplanIndexLine(line);
332
+ if (row) rows.push(row);
333
+ }
334
+ return {
335
+ rows,
336
+ indexPresent: true,
337
+ parseableLines: rows.length,
338
+ rawLineCount: lines.length,
339
+ rawText: text,
340
+ };
341
+ } catch (error) {
342
+ const code =
343
+ error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined;
344
+ if (code === "ENOENT") {
345
+ return { rows: [], indexPresent: false, parseableLines: 0, rawLineCount: 0 };
346
+ }
347
+ // Unreadable index: treat as present-but-untrusted empty parse.
348
+ return { rows: [], indexPresent: true, parseableLines: 0, rawLineCount: 0 };
349
+ }
350
+ }
351
+
352
+ function parseBoundedPositiveInteger(value: unknown, limit: number): number | null {
353
+ return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 1 && value <= limit
354
+ ? value
355
+ : null;
356
+ }
357
+
358
+ function parseMaxIterationsValue(value: unknown): number | null {
359
+ return parseBoundedPositiveInteger(value, RALPLAN_MAX_ITERATIONS_LIMIT);
360
+ }
361
+
362
+ async function readSettingsMaxIterations(settingsPath: string): Promise<number | null> {
363
+ try {
364
+ const raw = await Bun.file(settingsPath).text();
365
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
366
+ const flat = parseMaxIterationsValue(parsed["skc.ralplan.maxIterations"]);
367
+ if (flat !== null) return flat;
368
+ const skc = parsed.skc;
369
+ if (skc && typeof skc === "object") {
370
+ const ralplan = (skc as Record<string, unknown>).ralplan;
371
+ if (ralplan && typeof ralplan === "object") {
372
+ return parseMaxIterationsValue((ralplan as Record<string, unknown>).maxIterations);
373
+ }
374
+ }
375
+ return null;
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Resolve ralplan consensus iteration cap. Project `./.skc/settings.json` overrides
383
+ * user settings, else default 5.
384
+ */
385
+ export async function resolveRalplanMaxIterations(cwd: string): Promise<{ maxIterations: number; source: string }> {
386
+ const projectPath = path.join(skcRoot(cwd), "settings.json");
387
+ const project = await readSettingsMaxIterations(projectPath);
388
+ if (project !== null) return { maxIterations: project, source: projectPath };
389
+ const userPath = path.join(getConfigRootDir(), "settings.json");
390
+ const user = await readSettingsMaxIterations(userPath);
391
+ if (user !== null) return { maxIterations: user, source: userPath };
392
+ return { maxIterations: RALPLAN_DEFAULT_MAX_ITERATIONS, source: "default" };
393
+ }
394
+
395
+ function parseMaxReviewPassesPerLaneValue(value: unknown): number | null {
396
+ return parseBoundedPositiveInteger(value, RALPLAN_MAX_REVIEW_PASSES_PER_LANE_LIMIT);
397
+ }
398
+
399
+ type RalplanReviewPassesPerLaneSetting =
400
+ | { kind: "absent" }
401
+ | { kind: "valid"; value: number }
402
+ | { kind: "invalid"; reason: string };
403
+
404
+ function parsePresentMaxReviewPassesPerLane(value: unknown): RalplanReviewPassesPerLaneSetting {
405
+ const parsed = parseMaxReviewPassesPerLaneValue(value);
406
+ return parsed === null
407
+ ? {
408
+ kind: "invalid",
409
+ reason:
410
+ "expected skc.ralplan.maxReviewPassesPerLane to be an integer between 1 and " +
411
+ RALPLAN_MAX_REVIEW_PASSES_PER_LANE_LIMIT,
412
+ }
413
+ : { kind: "valid", value: parsed };
414
+ }
415
+
416
+ function parseMaxReviewPassesPerLaneSettings(parsed: unknown): RalplanReviewPassesPerLaneSetting {
417
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { kind: "absent" };
418
+ const settings = parsed as Record<string, unknown>;
419
+ if (Object.hasOwn(settings, "skc.ralplan.maxReviewPassesPerLane")) {
420
+ return parsePresentMaxReviewPassesPerLane(settings["skc.ralplan.maxReviewPassesPerLane"]);
421
+ }
422
+ const skc = settings.skc;
423
+ if (!skc || typeof skc !== "object" || Array.isArray(skc)) return { kind: "absent" };
424
+ const ralplan = (skc as Record<string, unknown>).ralplan;
425
+ if (!ralplan || typeof ralplan !== "object" || Array.isArray(ralplan)) return { kind: "absent" };
426
+ const ralplanSettings = ralplan as Record<string, unknown>;
427
+ if (!Object.hasOwn(ralplanSettings, "maxReviewPassesPerLane")) return { kind: "absent" };
428
+ return parsePresentMaxReviewPassesPerLane(ralplanSettings.maxReviewPassesPerLane);
429
+ }
430
+
431
+ async function readSettingsMaxReviewPassesPerLane(settingsPath: string): Promise<RalplanReviewPassesPerLaneSetting> {
432
+ let raw: string;
433
+ try {
434
+ raw = await Bun.file(settingsPath).text();
435
+ } catch (error) {
436
+ if (getErrorCode(error) === "ENOENT") return { kind: "absent" };
437
+ return {
438
+ kind: "invalid",
439
+ reason: `unable to read settings: ${error instanceof Error ? error.message : String(error)}`,
440
+ };
441
+ }
442
+ let parsed: unknown;
443
+ try {
444
+ parsed = JSON.parse(raw);
445
+ } catch (error) {
446
+ return {
447
+ kind: "invalid",
448
+ reason: `malformed JSON: ${error instanceof Error ? error.message : String(error)}`,
449
+ };
450
+ }
451
+ return parseMaxReviewPassesPerLaneSettings(parsed);
452
+ }
453
+
454
+ /** Resolve the per-lane review-pass budget with project-over-user precedence. */
455
+ export async function resolveRalplanMaxReviewPassesPerLane(
456
+ cwd: string,
457
+ ): Promise<{ maxReviewPassesPerLane: number; source: string }> {
458
+ const projectPath = path.join(skcRoot(cwd), "settings.json");
459
+ const project = await readSettingsMaxReviewPassesPerLane(projectPath);
460
+ if (project.kind === "invalid") {
461
+ throw new RalplanCommandError(2, `invalid ralplan settings at ${projectPath}: ${project.reason}`);
462
+ }
463
+ if (project.kind === "valid") return { maxReviewPassesPerLane: project.value, source: projectPath };
464
+ const userPath = path.join(getConfigRootDir(), "settings.json");
465
+ const user = await readSettingsMaxReviewPassesPerLane(userPath);
466
+ if (user.kind === "invalid") {
467
+ throw new RalplanCommandError(2, `invalid ralplan settings at ${userPath}: ${user.reason}`);
468
+ }
469
+ if (user.kind === "valid") return { maxReviewPassesPerLane: user.value, source: userPath };
470
+ return { maxReviewPassesPerLane: RALPLAN_DEFAULT_MAX_REVIEW_PASSES_PER_LANE, source: "default" };
471
+ }
472
+
473
+ function buildPlanningStuckResult(input: {
474
+ json: boolean;
475
+ stage: RalplanStage;
476
+ stageN: number;
477
+ runId: string;
478
+ decision: Extract<RalplanIterationCapDecision, { allowed: false }>;
479
+ source: string;
480
+ }): RalplanCommandResult {
481
+ const detail =
482
+ `${PLANNING_STUCK_MARKER}: ${input.decision.reason} ` +
483
+ `(run_id=${input.runId}, stage=${input.stage}, stage_n=${input.stageN}, source=${input.source}). ` +
484
+ `Stop opening planner/revision passes; escalate the best existing plan via final/pending-approval without auto-implementation.`;
485
+ if (input.json) {
486
+ return {
487
+ status: 3,
488
+ stdout: `${JSON.stringify(
489
+ {
490
+ ok: false,
491
+ planning_stuck: true,
492
+ marker: PLANNING_STUCK_MARKER,
493
+ run_id: input.runId,
494
+ stage: input.stage,
495
+ stage_n: input.stageN,
496
+ iteration: input.decision.currentIterations,
497
+ projected_iteration: input.decision.projectedIterations,
498
+ max_iterations: input.decision.maxIterations,
499
+ max_iterations_source: input.source,
500
+ reason: input.decision.reason,
501
+ },
502
+ null,
503
+ 2,
504
+ )}\n`,
505
+ stderr: `${detail}\n`,
506
+ };
507
+ }
508
+ return {
509
+ status: 3,
510
+ stdout: `${PLANNING_STUCK_MARKER}\n`,
511
+ stderr: `${detail}\n`,
512
+ };
513
+ }
514
+
515
+ function buildLaneBudgetStuckResult(input: {
516
+ json: boolean;
517
+ stage: RalplanStage;
518
+ stageN: number;
519
+ runId: string;
520
+ decision: Extract<RalplanReviewLaneBudgetDecision, { allowed: false }>;
521
+ source: string;
522
+ }): RalplanCommandResult {
523
+ const detail =
524
+ `${PLANNING_STUCK_MARKER}: ${input.decision.reason} ` +
525
+ `(run_id=${input.runId}, stage=${input.stage}, stage_n=${input.stageN}, source=${input.source}). ` +
526
+ `Stop re-invoking the ${input.decision.lane} review lane in this consensus iteration; ` +
527
+ "route a rule-2-justified blocker through a Planner revision opener (fresh lane budget) while opener budget remains, " +
528
+ "or escalate the best existing plan via post-interview/adr/final without auto-implementation.";
529
+ if (input.json) {
530
+ return {
531
+ status: 3,
532
+ stdout: `${JSON.stringify(
533
+ {
534
+ ok: false,
535
+ planning_stuck: true,
536
+ marker: PLANNING_STUCK_MARKER,
537
+ run_id: input.runId,
538
+ stage: input.stage,
539
+ stage_n: input.stageN,
540
+ lane: input.decision.lane,
541
+ passes: input.decision.currentPasses,
542
+ projected_passes: input.decision.projectedPasses,
543
+ max_review_passes_per_lane: input.decision.maxReviewPassesPerLane,
544
+ max_review_passes_source: input.source,
545
+ reason: input.decision.reason,
546
+ },
547
+ null,
548
+ 2,
549
+ )}\n`,
550
+ stderr: `${detail}\n`,
551
+ };
552
+ }
553
+ return {
554
+ status: 3,
555
+ stdout: `${PLANNING_STUCK_MARKER}\n`,
556
+ stderr: `${detail}\n`,
557
+ };
558
+ }
57
559
 
58
560
  const KNOWN_ARCHITECT_KINDS = new Set(["openai-code"]);
59
561
  const KNOWN_CRITIC_KINDS = new Set(["openai-code"]);
@@ -87,10 +589,15 @@ const VALUE_FLAGS = new Set([
87
589
  "--critic",
88
590
  "--planner-id",
89
591
  "--planner-resumable",
592
+ "--architect-id",
593
+ "--architect-resumable",
594
+ "--critic-id",
595
+ "--critic-resumable",
90
596
  "--fallback-reason",
91
597
  "--fallback-attempted-id",
92
598
  "--fallback-stage-n",
93
599
  "--fallback-receipt-path",
600
+ "--lane-verdict",
94
601
  ]);
95
602
 
96
603
  export function isRalplanArtifactWriteInvocation(args: readonly string[]): boolean {
@@ -179,6 +686,43 @@ async function readActiveRunId(cwd: string, sessionId: string): Promise<string |
179
686
  return candidate;
180
687
  }
181
688
 
689
+ /**
690
+ * Read the authoritative repository binding from ralplan run state and fail closed
691
+ * when the active cwd no longer matches (handoff / QA / stage write) (#2901).
692
+ */
693
+ async function enforceRalplanRepositoryBinding(cwd: string, sessionId: string): Promise<RepositoryBinding> {
694
+ const statePath = ralplanStatePath(cwd, sessionId);
695
+ const existingRead = await readExistingStateForMutation(statePath);
696
+ if (existingRead.kind === "corrupt") {
697
+ throw new RalplanCommandError(
698
+ 2,
699
+ `existing ralplan state is corrupt or tampered (${existingRead.error}); refusing to proceed at ${statePath}`,
700
+ );
701
+ }
702
+ if (existingRead.kind === "absent") {
703
+ // No prior seed — capture live authority so subsequent handoffs still have a stamp.
704
+ return publicRepositoryBinding(await captureRepositoryBinding(cwd, { displayPath: cwd }));
705
+ }
706
+ const raw = existingRead.value.repository_binding ?? existingRead.value.repositoryBinding;
707
+ try {
708
+ if (raw === undefined) {
709
+ // Legacy seeds without a field: stamp current cwd and require future writes match it.
710
+ return publicRepositoryBinding(await captureRepositoryBinding(cwd, { displayPath: cwd }));
711
+ }
712
+ const binding = parseRepositoryBinding(raw);
713
+ await assertCwdMatchesRepositoryBinding(cwd, binding);
714
+ return publicRepositoryBinding(binding);
715
+ } catch (error) {
716
+ if (error instanceof RepositoryBindingError) {
717
+ throw new RalplanCommandError(2, `ralplan repository binding rejected: ${error.message}`);
718
+ }
719
+ throw new RalplanCommandError(
720
+ 2,
721
+ `ralplan repository binding rejected: ${error instanceof Error ? error.message : String(error)}`,
722
+ );
723
+ }
724
+ }
725
+
182
726
  /**
183
727
  * Run-state phases that an artifact write must never reopen. Once ralplan has
184
728
  * reached a terminal/handed-off phase, a stray `--write` must not regress
@@ -213,6 +757,13 @@ async function persistActiveRunId(cwd: string, sessionId: string, runId: string,
213
757
  // "complete"/"handoff" and disarm the Stop hook). PHASE_LOCK only guards same-run writes.
214
758
  const isNewRun = existing.run_id !== runId;
215
759
  const nextPhase = isNewRun ? stage : advanceCurrentPhase(existing.current_phase, stage);
760
+ if (isNewRun) {
761
+ // State writes shallow-merge, so clear both HUD verdict sources at the run boundary.
762
+ delete existing.verdict;
763
+ for (const key of Object.keys(existing)) {
764
+ if (key.startsWith("last_review_verdict")) delete existing[key];
765
+ }
766
+ }
216
767
  if (
217
768
  existing.run_id === runId &&
218
769
  existing.version === WORKFLOW_STATE_VERSION &&
@@ -231,6 +782,7 @@ async function persistActiveRunId(cwd: string, sessionId: string, runId: string,
231
782
  existing.updated_at = new Date().toISOString();
232
783
  await writeWorkflowEnvelopeAtomic(statePath, existing, {
233
784
  cwd,
785
+ lockHeld: true,
234
786
  receipt: { cwd, skill: "ralplan", owner: "skc-runtime", command: "skc ralplan persist-run-id", sessionId },
235
787
  audit: { category: "state", verb: "write", owner: "skc-runtime", skill: "ralplan", sessionId },
236
788
  });
@@ -239,9 +791,12 @@ async function persistActiveRunId(cwd: string, sessionId: string, runId: string,
239
791
  );
240
792
  }
241
793
 
242
- /* --------------------------- planner run-state --------------------------- */
794
+ /* ---------------------- persisted role-agent run-state --------------------- */
795
+
796
+ type PersistedRole = "planner" | RalplanReviewLane;
243
797
 
244
- interface PlannerStateUpdate {
798
+ interface PersistedRoleStateUpdate {
799
+ role: PersistedRole;
245
800
  subagentId?: string;
246
801
  resumable?: boolean;
247
802
  fallbackReason?: string;
@@ -250,6 +805,25 @@ interface PlannerStateUpdate {
250
805
  fallbackReceiptPath?: string;
251
806
  }
252
807
 
808
+ interface LaneVerdictUpdate {
809
+ lane: RalplanReviewLane;
810
+ verdict: string;
811
+ stageN: number;
812
+ }
813
+
814
+ const PERSISTED_ROLES = ["planner", "architect", "critic"] as const;
815
+
816
+ const PERSISTED_ROLE_FLAGS: Record<PersistedRole, { id: string; resumable: string }> = {
817
+ planner: { id: "--planner-id", resumable: "--planner-resumable" },
818
+ architect: { id: "--architect-id", resumable: "--architect-resumable" },
819
+ critic: { id: "--critic-id", resumable: "--critic-resumable" },
820
+ };
821
+
822
+ const LANE_VERDICTS: Record<RalplanReviewLane, ReadonlySet<string>> = {
823
+ architect: new Set(["CLEAR", "WATCH", "BLOCK"]),
824
+ critic: new Set(["OKAY", "ITERATE", "REJECT"]),
825
+ };
826
+
253
827
  function parseBooleanFlag(raw: string, flag: string): boolean {
254
828
  if (raw === "true") return true;
255
829
  if (raw === "false") return false;
@@ -262,7 +836,7 @@ function assertSubagentId(value: string, label: string): void {
262
836
  }
263
837
  }
264
838
 
265
- function plannerFlagValue(args: readonly string[], flag: string): string | undefined {
839
+ function roleStateFlagValue(args: readonly string[], flag: string): string | undefined {
266
840
  const value = flagValue(args, flag);
267
841
  if (value === undefined && hasFlag(args, flag)) {
268
842
  throw new RalplanCommandError(2, `missing value for ${flag}.`);
@@ -270,19 +844,64 @@ function plannerFlagValue(args: readonly string[], flag: string): string | undef
270
844
  return value;
271
845
  }
272
846
 
847
+ function persistedRoleForStage(stage: RalplanStage): PersistedRole | undefined {
848
+ if (stage === "planner" || stage === "revision") return "planner";
849
+ if (stage === "architect" || stage === "critic") return stage;
850
+ return undefined;
851
+ }
852
+
853
+ function suppliedPersistedRoleFlag(args: readonly string[], role: PersistedRole): string | undefined {
854
+ const flags = PERSISTED_ROLE_FLAGS[role];
855
+ if (hasFlag(args, flags.id)) return flags.id;
856
+ if (hasFlag(args, flags.resumable)) return flags.resumable;
857
+ return undefined;
858
+ }
859
+
273
860
  /**
274
- * Parse the optional persisted-Planner metadata flags that may ride alongside a
275
- * `--write`. Returns `undefined` when none are present so existing writes are
276
- * unaffected. Throws `RalplanCommandError` on any malformed value. This records
277
- * a same-session audit/routing hint, not a durable subagent registry.
861
+ * Parse the optional same-session persisted role-agent metadata that may ride
862
+ * alongside a `--write`. Planner metadata rides planner/revision stages;
863
+ * Architect and Critic metadata ride only their matching review lane. This is
864
+ * an audit/routing hint, not a durable subagent registry.
278
865
  */
279
- function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | undefined {
280
- const subagentId = plannerFlagValue(args, "--planner-id");
281
- const resumableRaw = plannerFlagValue(args, "--planner-resumable");
282
- const fallbackReason = plannerFlagValue(args, "--fallback-reason");
283
- const fallbackAttemptedId = plannerFlagValue(args, "--fallback-attempted-id");
284
- const fallbackStageNRaw = plannerFlagValue(args, "--fallback-stage-n");
285
- const fallbackReceiptPath = plannerFlagValue(args, "--fallback-receipt-path");
866
+ function parsePersistedRoleStateArgs(
867
+ args: readonly string[],
868
+ stage: RalplanStage,
869
+ ): PersistedRoleStateUpdate | undefined {
870
+ const role = persistedRoleForStage(stage);
871
+ for (const candidate of PERSISTED_ROLES) {
872
+ const suppliedFlag = suppliedPersistedRoleFlag(args, candidate);
873
+ if (suppliedFlag && candidate !== role) {
874
+ const expectedStages = candidate === "planner" ? "planner or revision" : candidate;
875
+ throw new RalplanCommandError(
876
+ 2,
877
+ `${suppliedFlag} is only valid with --stage ${expectedStages} (received ${stage}).`,
878
+ );
879
+ }
880
+ }
881
+
882
+ const fallbackFlagsPresent = [
883
+ "--fallback-reason",
884
+ "--fallback-attempted-id",
885
+ "--fallback-stage-n",
886
+ "--fallback-receipt-path",
887
+ ].some(flag => hasFlag(args, flag));
888
+ if (!role) {
889
+ if (fallbackFlagsPresent) {
890
+ throw new RalplanCommandError(
891
+ 2,
892
+ `--fallback-reason is only valid with --stage planner, revision, architect, or critic (received ${stage}).`,
893
+ );
894
+ }
895
+ return undefined;
896
+ }
897
+
898
+ const flags = PERSISTED_ROLE_FLAGS[role];
899
+ const subagentId = roleStateFlagValue(args, flags.id);
900
+ const resumableRaw = roleStateFlagValue(args, flags.resumable);
901
+ const fallbackReason = roleStateFlagValue(args, "--fallback-reason");
902
+ const fallbackAttemptedId = roleStateFlagValue(args, "--fallback-attempted-id");
903
+ const fallbackStageNRaw = roleStateFlagValue(args, "--fallback-stage-n");
904
+ const fallbackReceiptPath = roleStateFlagValue(args, "--fallback-receipt-path");
286
905
 
287
906
  const anyPresent = [
288
907
  subagentId,
@@ -294,14 +913,13 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
294
913
  ].some(value => value !== undefined);
295
914
  if (!anyPresent) return undefined;
296
915
 
297
- const update: PlannerStateUpdate = {};
298
-
916
+ const update: PersistedRoleStateUpdate = { role };
299
917
  if (subagentId !== undefined) {
300
- assertSubagentId(subagentId, "--planner-id");
918
+ assertSubagentId(subagentId, flags.id);
301
919
  update.subagentId = subagentId;
302
920
  }
303
921
  if (resumableRaw !== undefined) {
304
- update.resumable = parseBooleanFlag(resumableRaw, "--planner-resumable");
922
+ update.resumable = parseBooleanFlag(resumableRaw, flags.resumable);
305
923
  }
306
924
 
307
925
  const anyFallback = [fallbackReason, fallbackAttemptedId, fallbackStageNRaw, fallbackReceiptPath].some(
@@ -309,7 +927,7 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
309
927
  );
310
928
  if (anyFallback) {
311
929
  if (!fallbackReason) {
312
- throw new RalplanCommandError(2, "--fallback-reason is required when recording planner fallback metadata.");
930
+ throw new RalplanCommandError(2, `--fallback-reason is required when recording ${role} fallback metadata.`);
313
931
  }
314
932
  if (!KNOWN_FALLBACK_REASONS.has(fallbackReason)) {
315
933
  throw new RalplanCommandError(
@@ -321,13 +939,13 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
321
939
  if (fallbackAttemptedId === undefined) {
322
940
  throw new RalplanCommandError(
323
941
  2,
324
- "--fallback-attempted-id is required when recording planner fallback metadata.",
942
+ `--fallback-attempted-id is required when recording ${role} fallback metadata.`,
325
943
  );
326
944
  }
327
945
  assertSubagentId(fallbackAttemptedId, "--fallback-attempted-id");
328
946
  update.fallbackAttemptedId = fallbackAttemptedId;
329
947
  if (fallbackStageNRaw === undefined) {
330
- throw new RalplanCommandError(2, "--fallback-stage-n is required when recording planner fallback metadata.");
948
+ throw new RalplanCommandError(2, `--fallback-stage-n is required when recording ${role} fallback metadata.`);
331
949
  }
332
950
  update.fallbackStageN = parseStageN(fallbackStageNRaw);
333
951
  if (fallbackReceiptPath !== undefined) {
@@ -341,24 +959,63 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
341
959
  return update;
342
960
  }
343
961
 
344
- /** 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. */
345
- function plannerStatePayload(update: PlannerStateUpdate): Record<string, unknown> {
962
+ /** Parse the self-reported review verdict carried by a lane artifact write. */
963
+ function parseLaneVerdictArgs(
964
+ args: readonly string[],
965
+ stage: RalplanStage,
966
+ stageN: number,
967
+ ): LaneVerdictUpdate | undefined {
968
+ const rawVerdict = roleStateFlagValue(args, "--lane-verdict");
969
+ if (rawVerdict === undefined) return undefined;
970
+ if (stage !== "architect" && stage !== "critic") {
971
+ throw new RalplanCommandError(
972
+ 2,
973
+ `--lane-verdict is only valid with --stage architect or critic (received ${stage}).`,
974
+ );
975
+ }
976
+ const verdict = rawVerdict.trim().toUpperCase();
977
+ if (!LANE_VERDICTS[stage].has(verdict)) {
978
+ throw new RalplanCommandError(
979
+ 2,
980
+ `invalid --lane-verdict for ${stage}: ${rawVerdict}. Expected one of: ${[...LANE_VERDICTS[stage]].join(", ")}.`,
981
+ );
982
+ }
983
+ return { lane: stage, verdict, stageN };
984
+ }
985
+
986
+ /**
987
+ * Snake-case projection of persisted role metadata for state JSON + receipts.
988
+ * Omitted fields stay absent — an unknown resumability value is never encoded
989
+ * as literal null.
990
+ */
991
+ function persistedRoleStatePayload(update: PersistedRoleStateUpdate): Record<string, unknown> {
346
992
  const payload: Record<string, unknown> = {};
347
- if (update.subagentId !== undefined) payload.planner_subagent_id = update.subagentId;
348
- if (update.resumable !== undefined) payload.planner_resumable = update.resumable;
349
- if (update.fallbackReason !== undefined) payload.planner_fallback_reason = update.fallbackReason;
350
- if (update.fallbackAttemptedId !== undefined) payload.planner_fallback_attempted_id = update.fallbackAttemptedId;
351
- if (update.fallbackStageN !== undefined) payload.planner_fallback_stage_n = update.fallbackStageN;
352
- if (update.fallbackReceiptPath !== undefined) payload.planner_fallback_receipt_path = update.fallbackReceiptPath;
993
+ const prefix = update.role;
994
+ if (update.subagentId !== undefined) {
995
+ payload[prefix === "planner" ? "planner_subagent_id" : `${prefix}_id`] = update.subagentId;
996
+ }
997
+ if (update.resumable !== undefined) payload[`${prefix}_resumable`] = update.resumable;
998
+ if (update.fallbackReason !== undefined) payload[`${prefix}_fallback_reason`] = update.fallbackReason;
999
+ if (update.fallbackAttemptedId !== undefined)
1000
+ payload[`${prefix}_fallback_attempted_id`] = update.fallbackAttemptedId;
1001
+ if (update.fallbackStageN !== undefined) payload[`${prefix}_fallback_stage_n`] = update.fallbackStageN;
1002
+ if (update.fallbackReceiptPath !== undefined) {
1003
+ payload[`${prefix}_fallback_receipt_path`] = update.fallbackReceiptPath;
1004
+ }
353
1005
  return payload;
354
1006
  }
355
1007
 
356
1008
  /**
357
- * Merge persisted-Planner metadata into the ralplan run-state JSON. Same-session
358
- * audit/routing hint only — it records what the caller has already proven and is
359
- * NOT a durable cross-process subagent registry.
1009
+ * Merge persisted role-agent metadata into the ralplan run-state JSON, optionally only for its active run.
1010
+ * This is a same-session audit/routing hint only; it does not create a durable
1011
+ * cross-process subagent registry, ledger entry, or additional state file.
360
1012
  */
361
- async function applyPlannerStateUpdate(cwd: string, sessionId: string, update: PlannerStateUpdate): Promise<void> {
1013
+ async function applyPersistedRoleStateUpdate(
1014
+ cwd: string,
1015
+ sessionId: string,
1016
+ update: PersistedRoleStateUpdate,
1017
+ expectedActiveRunId?: string,
1018
+ ): Promise<boolean> {
362
1019
  const statePath = ralplanStatePath(cwd, sessionId);
363
1020
  return await withWorkflowStateLock(
364
1021
  statePath,
@@ -371,7 +1028,8 @@ async function applyPlannerStateUpdate(cwd: string, sessionId: string, update: P
371
1028
  );
372
1029
  }
373
1030
  let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
374
- Object.assign(existing, plannerStatePayload(update));
1031
+ if (expectedActiveRunId !== undefined && existing.run_id !== expectedActiveRunId) return false;
1032
+ Object.assign(existing, persistedRoleStatePayload(update));
375
1033
  if (typeof existing.skill !== "string") existing.skill = "ralplan";
376
1034
  if (typeof existing.active !== "boolean") existing.active = true;
377
1035
  if (typeof existing.current_phase !== "string") existing.current_phase = "planner";
@@ -379,9 +1037,59 @@ async function applyPlannerStateUpdate(cwd: string, sessionId: string, update: P
379
1037
  existing.updated_at = new Date().toISOString();
380
1038
  await writeWorkflowEnvelopeAtomic(statePath, existing, {
381
1039
  cwd,
382
- receipt: { cwd, skill: "ralplan", owner: "skc-runtime", command: "skc ralplan planner-state", sessionId },
1040
+ lockHeld: true,
1041
+ receipt: {
1042
+ cwd,
1043
+ skill: "ralplan",
1044
+ owner: "skc-runtime",
1045
+ command: `skc ralplan ${update.role}-state`,
1046
+ sessionId,
1047
+ },
383
1048
  audit: { category: "state", verb: "write", owner: "skc-runtime", skill: "ralplan", sessionId },
384
1049
  });
1050
+ return true;
1051
+ },
1052
+ { cwd },
1053
+ );
1054
+ }
1055
+
1056
+ /** Merge a lane's self-reported verdict into the same-session ralplan run state, optionally only for its active run. */
1057
+ async function applyLaneVerdictUpdate(
1058
+ cwd: string,
1059
+ sessionId: string,
1060
+ update: LaneVerdictUpdate,
1061
+ expectedActiveRunId?: string,
1062
+ ): Promise<boolean> {
1063
+ const statePath = ralplanStatePath(cwd, sessionId);
1064
+ return await withWorkflowStateLock(
1065
+ statePath,
1066
+ async () => {
1067
+ const existingRead = await readExistingStateForMutation(statePath);
1068
+ if (existingRead.kind === "corrupt") {
1069
+ throw new RalplanCommandError(
1070
+ 2,
1071
+ `existing ralplan state is corrupt or tampered (${existingRead.error}); refusing to overwrite ${statePath}`,
1072
+ );
1073
+ }
1074
+ let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
1075
+ if (expectedActiveRunId !== undefined && existing.run_id !== expectedActiveRunId) return false;
1076
+ Object.assign(existing, {
1077
+ last_review_verdict: update.verdict,
1078
+ last_review_verdict_lane: update.lane,
1079
+ last_review_verdict_stage_n: update.stageN,
1080
+ });
1081
+ if (typeof existing.skill !== "string") existing.skill = "ralplan";
1082
+ if (typeof existing.active !== "boolean") existing.active = true;
1083
+ if (typeof existing.current_phase !== "string") existing.current_phase = "planner";
1084
+ existing = migrateWorkflowState(existing, "ralplan").state;
1085
+ existing.updated_at = new Date().toISOString();
1086
+ await writeWorkflowEnvelopeAtomic(statePath, existing, {
1087
+ cwd,
1088
+ lockHeld: true,
1089
+ receipt: { cwd, skill: "ralplan", owner: "skc-runtime", command: "skc ralplan lane-verdict", sessionId },
1090
+ audit: { category: "state", verb: "write", owner: "skc-runtime", skill: "ralplan", sessionId },
1091
+ });
1092
+ return true;
385
1093
  },
386
1094
  { cwd },
387
1095
  );
@@ -532,28 +1240,19 @@ interface ExistingStageArtifact {
532
1240
  }
533
1241
 
534
1242
  /**
535
- * Find the most recent `index.jsonl` row for a `(stage, stage_n)` pair so a
536
- * repeated `--write` can dedupe instead of silently clobbering the artifact and
537
- * appending a duplicate ledger row. Best-effort: a missing or unreadable index
538
- * yields `undefined`, treated as "no prior artifact". The ledger is the source of
539
- * truth for dedup because it is exactly what a duplicate write would corrupt.
1243
+ * Find the most recent complete `index.jsonl` row for a `(stage, stage_n)` pair
1244
+ * in the single ledger snapshot used by the dedupe guard and both budget gates.
1245
+ * A parseable row missing `path` or `sha256` is intentionally treated as missing
1246
+ * so the deterministic on-disk probe can repair the crash gap.
540
1247
  */
541
- async function findExistingStageArtifact(
542
- cwd: string,
543
- sessionId: string,
544
- runId: string,
1248
+ function findExistingStageArtifact(
1249
+ indexText: string | undefined,
545
1250
  stage: RalplanStage,
546
1251
  stageN: number,
547
- ): Promise<ExistingStageArtifact | undefined> {
548
- const indexPath = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId, "index.jsonl");
549
- let text: string;
550
- try {
551
- text = await fs.readFile(indexPath, "utf8");
552
- } catch {
553
- return undefined;
554
- }
1252
+ ): ExistingStageArtifact | undefined {
1253
+ if (indexText === undefined) return undefined;
555
1254
  let match: ExistingStageArtifact | undefined;
556
- for (const line of text.split(/\r?\n/)) {
1255
+ for (const line of indexText.split(/\r?\n/)) {
557
1256
  const trimmed = line.trim();
558
1257
  if (!trimmed) continue;
559
1258
  let row: unknown;
@@ -575,6 +1274,124 @@ async function findExistingStageArtifact(
575
1274
  return match;
576
1275
  }
577
1276
 
1277
+ interface OnDiskStageArtifact {
1278
+ path: string;
1279
+ sha256: string;
1280
+ }
1281
+
1282
+ /** Probe the deterministic artifact path left by a crash before ledger append. */
1283
+ async function findOnDiskStageArtifact(
1284
+ cwd: string,
1285
+ resolved: Pick<ResolvedArtifactArgs, "sessionId" | "runId" | "stage" | "stageN">,
1286
+ ): Promise<OnDiskStageArtifact | undefined> {
1287
+ const filePath = path.join(
1288
+ sessionPlansDir(cwd, resolved.sessionId),
1289
+ "ralplan",
1290
+ resolved.runId,
1291
+ `stage-${pad2(resolved.stageN)}-${resolved.stage}.md`,
1292
+ );
1293
+ try {
1294
+ const content = await fs.readFile(filePath, "utf8");
1295
+ return { path: filePath, sha256: createHash("sha256").update(content).digest("hex") };
1296
+ } catch (error) {
1297
+ const code = getErrorCode(error);
1298
+ if (code === "ENOENT" || code === "ENOTDIR") return undefined;
1299
+ throw error;
1300
+ }
1301
+ }
1302
+
1303
+ /** Ensure a deduplicated final stage still has its byte-identical pending-approval copy. */
1304
+ async function ensureFinalPendingApproval(
1305
+ cwd: string,
1306
+ resolved: Pick<ResolvedArtifactArgs, "sessionId" | "runId" | "stageN">,
1307
+ stageArtifact: Pick<OnDiskStageArtifact, "path" | "sha256">,
1308
+ ): Promise<string> {
1309
+ const pendingApprovalPath = path.join(
1310
+ sessionPlansDir(cwd, resolved.sessionId),
1311
+ "ralplan",
1312
+ resolved.runId,
1313
+ "pending-approval.md",
1314
+ );
1315
+ const stageContent = await fs.readFile(stageArtifact.path);
1316
+ const stageSha256 = createHash("sha256").update(stageContent).digest("hex");
1317
+ if (stageSha256 !== stageArtifact.sha256) {
1318
+ throw new RalplanCommandError(
1319
+ 2,
1320
+ `refusing to deduplicate ralplan final stage ${resolved.stageN}: stage artifact sha256 mismatch at ${stageArtifact.path} (ledger sha256=${stageArtifact.sha256}, artifact sha256=${stageSha256}).`,
1321
+ );
1322
+ }
1323
+
1324
+ let pendingContent: Buffer;
1325
+ try {
1326
+ pendingContent = await fs.readFile(pendingApprovalPath);
1327
+ } catch (error) {
1328
+ if (getErrorCode(error) !== "ENOENT") throw error;
1329
+ await writeArtifact(pendingApprovalPath, stageContent.toString("utf8"), {
1330
+ cwd,
1331
+ audit: {
1332
+ category: "artifact",
1333
+ verb: "write",
1334
+ owner: "skc-runtime",
1335
+ skill: "ralplan",
1336
+ sessionId: resolved.sessionId,
1337
+ },
1338
+ });
1339
+ return pendingApprovalPath;
1340
+ }
1341
+
1342
+ const pendingSha256 = createHash("sha256").update(pendingContent).digest("hex");
1343
+ if (!pendingContent.equals(stageContent) || pendingSha256 !== stageSha256) {
1344
+ throw new RalplanCommandError(
1345
+ 2,
1346
+ `refusing to deduplicate ralplan final stage ${resolved.stageN}: pending approval content mismatch at ${pendingApprovalPath} (stage sha256=${stageSha256}, pending sha256=${pendingSha256}).`,
1347
+ );
1348
+ }
1349
+ return pendingApprovalPath;
1350
+ }
1351
+
1352
+ /** Append the missing row for a deterministic artifact that survived a crash gap. */
1353
+ async function repairMissingStageArtifactLedger(
1354
+ cwd: string,
1355
+ resolved: Pick<ResolvedArtifactArgs, "sessionId" | "runId" | "stage" | "stageN">,
1356
+ onDisk: OnDiskStageArtifact,
1357
+ ): Promise<ExistingStageArtifact> {
1358
+ const createdAt = new Date().toISOString();
1359
+ const indexEntry = {
1360
+ stage: resolved.stage,
1361
+ stage_n: resolved.stageN,
1362
+ path: onDisk.path,
1363
+ created_at: createdAt,
1364
+ sha256: onDisk.sha256,
1365
+ };
1366
+ const result = await appendJsonlIdempotent(
1367
+ path.join(sessionPlansDir(cwd, resolved.sessionId), "ralplan", resolved.runId, "index.jsonl"),
1368
+ indexEntry,
1369
+ {
1370
+ cwd,
1371
+ audit: {
1372
+ category: "ledger",
1373
+ verb: "append",
1374
+ owner: "skc-runtime",
1375
+ skill: "ralplan",
1376
+ sessionId: resolved.sessionId,
1377
+ },
1378
+ key: ralplanIndexKey,
1379
+ },
1380
+ );
1381
+ const duplicate = result.duplicate;
1382
+ if (duplicate && typeof duplicate === "object" && !Array.isArray(duplicate)) {
1383
+ const record = duplicate as Record<string, unknown>;
1384
+ if (typeof record.path === "string" && typeof record.sha256 === "string") {
1385
+ return {
1386
+ path: record.path,
1387
+ sha256: record.sha256,
1388
+ createdAt: typeof record.created_at === "string" ? record.created_at : createdAt,
1389
+ };
1390
+ }
1391
+ }
1392
+ return { path: onDisk.path, sha256: onDisk.sha256, createdAt };
1393
+ }
1394
+
578
1395
  /**
579
1396
  * Read and parse the run's `index.jsonl` rows. Best-effort: returns [] when the
580
1397
  * file is absent or unreadable so HUD sync never fails on a missing index.
@@ -594,6 +1411,18 @@ async function readRalplanIndexRows(cwd: string, sessionId: string, runId: strin
594
1411
  }
595
1412
  }
596
1413
 
1414
+ /** Read the lane verdict from ralplan run state without making HUD sync fail on state read errors. */
1415
+ async function readRalplanLastReviewVerdict(cwd: string, sessionId: string): Promise<string | undefined> {
1416
+ try {
1417
+ const existingRead = await readExistingStateForMutation(ralplanStatePath(cwd, sessionId));
1418
+ return existingRead.kind === "valid" && typeof existingRead.value.last_review_verdict === "string"
1419
+ ? existingRead.value.last_review_verdict
1420
+ : undefined;
1421
+ } catch {
1422
+ return undefined;
1423
+ }
1424
+ }
1425
+
597
1426
  async function syncRalplanHud(options: {
598
1427
  cwd: string;
599
1428
  sessionId: string;
@@ -601,6 +1430,7 @@ async function syncRalplanHud(options: {
601
1430
  pendingApproval: boolean;
602
1431
  iteration?: number;
603
1432
  runId?: string;
1433
+ reviewPassBudget?: number;
604
1434
  latestSummary?: string;
605
1435
  }): Promise<void> {
606
1436
  try {
@@ -626,15 +1456,25 @@ async function buildRalplanHud(options: {
626
1456
  latestSummary?: string;
627
1457
  runId?: string;
628
1458
  sessionId?: string;
1459
+ reviewPassBudget?: number;
629
1460
  }) {
630
1461
  let iterationFromIndex: number | undefined;
631
1462
  let stages: string | undefined;
1463
+ let architectPasses: number | undefined;
1464
+ let criticPasses: number | undefined;
1465
+ let verdict: string | undefined;
632
1466
  if (options.runId && options.sessionId) {
633
- const rows = await readRalplanIndexRows(options.cwd, options.sessionId, options.runId);
1467
+ const [rows, lastReviewVerdict] = await Promise.all([
1468
+ readRalplanIndexRows(options.cwd, options.sessionId, options.runId),
1469
+ readRalplanLastReviewVerdict(options.cwd, options.sessionId),
1470
+ ]);
1471
+ verdict = lastReviewVerdict;
634
1472
  if (rows.length > 0) {
635
1473
  const summary = summarizeRalplanIndex(rows);
636
1474
  iterationFromIndex = summary.iteration;
637
1475
  stages = formatRalplanStagePresence(summary.currentStages);
1476
+ architectPasses = summary.currentStages.filter(stage => stage === "architect").length;
1477
+ criticPasses = summary.currentStages.filter(stage => stage === "critic").length;
638
1478
  }
639
1479
  }
640
1480
  return buildRalplanHudSummary({
@@ -642,6 +1482,10 @@ async function buildRalplanHud(options: {
642
1482
  iteration: options.iteration,
643
1483
  iterationFromIndex,
644
1484
  stages,
1485
+ architectPasses,
1486
+ criticPasses,
1487
+ reviewPassBudget: options.reviewPassBudget,
1488
+ verdict,
645
1489
  pendingApproval: options.pendingApproval,
646
1490
  latestSummary: options.latestSummary,
647
1491
  updatedAt: new Date().toISOString(),
@@ -649,21 +1493,39 @@ async function buildRalplanHud(options: {
649
1493
  }
650
1494
 
651
1495
  async function handleArtifactWrite(args: readonly string[], cwd: string): Promise<RalplanCommandResult> {
652
- const plannerState = parsePlannerStateArgs(args);
653
1496
  const resolved = await resolveArtifactArgs(args, cwd);
1497
+ const persistedRoleState = parsePersistedRoleStateArgs(args, resolved.stage);
1498
+ const laneVerdict = parseLaneVerdictArgs(args, resolved.stage, resolved.stageN);
1499
+ // Fail closed before stage persistence / path writes when cwd drifted to a sibling repo.
1500
+ const repositoryBinding = await enforceRalplanRepositoryBinding(cwd, resolved.sessionId);
1501
+ // Artifact file paths (when --artifact points at a file) must stay under the bound root.
1502
+ const rawArtifact = flagValue(args, "--artifact");
1503
+ if (rawArtifact && !isRestrictedRoleAgentBash()) {
1504
+ const candidate = path.isAbsolute(rawArtifact) ? rawArtifact : path.resolve(cwd, rawArtifact);
1505
+ try {
1506
+ const stat = await fs.stat(candidate);
1507
+ if (stat.isFile()) {
1508
+ assertPathUnderRepositoryBinding(repositoryBinding, candidate);
1509
+ }
1510
+ } catch (error) {
1511
+ if (error instanceof RepositoryBindingError) {
1512
+ throw new RalplanCommandError(2, `ralplan repository binding rejected: ${error.message}`);
1513
+ }
1514
+ // Non-file / missing path is inline content; resolveArtifactContent already handled it.
1515
+ }
1516
+ }
654
1517
  const content = resolved.artifact.endsWith("\n") ? resolved.artifact : `${resolved.artifact}\n`;
655
1518
  const sha256 = createHash("sha256").update(content).digest("hex");
656
1519
 
1520
+ // Read the ledger once before persistence. The dedupe guard and both gates
1521
+ // consume this same snapshot so no additional ledger read can slip between gate
1522
+ // evaluation and persistence.
1523
+ const indexLoad = await loadRalplanIndexForCap(cwd, resolved.sessionId, resolved.runId);
1524
+
657
1525
  // Duplicate-write guard: a second `--write` for the same (stage, stage_n) must not
658
1526
  // silently clobber the artifact or append a duplicate ledger row. Classify before any
659
1527
  // state mutation so a conflict never regresses run-state phase.
660
- const existingArtifact = await findExistingStageArtifact(
661
- cwd,
662
- resolved.sessionId,
663
- resolved.runId,
664
- resolved.stage,
665
- resolved.stageN,
666
- );
1528
+ const existingArtifact = findExistingStageArtifact(indexLoad.rawText, resolved.stage, resolved.stageN);
667
1529
  if (existingArtifact) {
668
1530
  if (existingArtifact.sha256 !== sha256) {
669
1531
  throw new RalplanCommandError(
@@ -671,14 +1533,102 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
671
1533
  `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.`,
672
1534
  );
673
1535
  }
674
- return buildDeduplicatedResult(resolved, existingArtifact, sha256, cwd);
1536
+ if (resolved.stage === "final") await ensureFinalPendingApproval(cwd, resolved, existingArtifact);
1537
+ return buildDeduplicatedResult(resolved, existingArtifact, sha256, cwd, repositoryBinding);
1538
+ }
1539
+
1540
+ // `persistArtifact` writes the artifact before its ledger row. If a process crashed
1541
+ // in that gap, an identical retry repairs the row and remains a normal deduplicated
1542
+ // receipt; a differing retry retains the ordinary no-overwrite refusal.
1543
+ const onDiskArtifact = await findOnDiskStageArtifact(cwd, resolved);
1544
+ if (onDiskArtifact) {
1545
+ if (onDiskArtifact.sha256 !== sha256) {
1546
+ throw new RalplanCommandError(
1547
+ 2,
1548
+ `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.`,
1549
+ );
1550
+ }
1551
+ if (resolved.stage === "final") await ensureFinalPendingApproval(cwd, resolved, onDiskArtifact);
1552
+ const repairedArtifact = await repairMissingStageArtifactLedger(cwd, resolved, onDiskArtifact);
1553
+ let appliedPersistedRoleState: PersistedRoleStateUpdate | undefined;
1554
+ if (
1555
+ persistedRoleState &&
1556
+ (await applyPersistedRoleStateUpdate(cwd, resolved.sessionId, persistedRoleState, resolved.runId))
1557
+ ) {
1558
+ appliedPersistedRoleState = persistedRoleState;
1559
+ }
1560
+ let appliedLaneVerdict: LaneVerdictUpdate | undefined;
1561
+ if (laneVerdict && (await applyLaneVerdictUpdate(cwd, resolved.sessionId, laneVerdict, resolved.runId))) {
1562
+ appliedLaneVerdict = laneVerdict;
1563
+ }
1564
+ return buildDeduplicatedResult(
1565
+ resolved,
1566
+ repairedArtifact,
1567
+ sha256,
1568
+ cwd,
1569
+ repositoryBinding,
1570
+ appliedLaneVerdict,
1571
+ appliedPersistedRoleState,
1572
+ );
1573
+ }
1574
+
1575
+ // Consensus iteration budget (#3165): refuse new planner/revision openers past the cap.
1576
+ // Dedupe returns above so identical re-writes never stuck-signal. Non-openers (architect,
1577
+ // critic, final, …) remain allowed so operators can escalate without auto-implementation.
1578
+ // On-disk opener artifacts floor the count so a wiped/truncated/malformed index.jsonl cannot
1579
+ // under-count and fail open after prior planner/revision writes.
1580
+ //
1581
+ // Gate evaluation, artifact write, and ledger append are sequential within one
1582
+ // `skc ralplan --write` invocation. This is NOT exclusive across processes: no
1583
+ // run-scoped lock or CAS admission exists, intentionally matching #3165's
1584
+ // check-then-persist exposure.
1585
+ const [onDiskOpeners, onDiskLaneArtifacts, iterationLimit, laneLimit] = await Promise.all([
1586
+ countRalplanOnDiskOpeners(cwd, resolved.sessionId, resolved.runId),
1587
+ countRalplanOnDiskLaneArtifacts(cwd, resolved.sessionId, resolved.runId),
1588
+ resolveRalplanMaxIterations(cwd),
1589
+ resolveRalplanMaxReviewPassesPerLane(cwd),
1590
+ ]);
1591
+ const capDecision = evaluateRalplanIterationCap({
1592
+ rows: indexLoad.rows,
1593
+ stage: resolved.stage,
1594
+ maxIterations: iterationLimit.maxIterations,
1595
+ iterationFloor: onDiskOpeners,
1596
+ });
1597
+ if (!capDecision.allowed) {
1598
+ return buildPlanningStuckResult({
1599
+ json: resolved.json,
1600
+ stage: resolved.stage,
1601
+ stageN: resolved.stageN,
1602
+ runId: resolved.runId,
1603
+ decision: capDecision,
1604
+ source: iterationLimit.source,
1605
+ });
1606
+ }
1607
+ const laneBudgetDecision = evaluateRalplanReviewLaneBudget({
1608
+ rows: indexLoad.rows,
1609
+ stage: resolved.stage,
1610
+ maxReviewPassesPerLane: laneLimit.maxReviewPassesPerLane,
1611
+ onDiskLaneCounts: onDiskLaneArtifacts,
1612
+ });
1613
+ if (!laneBudgetDecision.allowed) {
1614
+ return buildLaneBudgetStuckResult({
1615
+ json: resolved.json,
1616
+ stage: resolved.stage,
1617
+ stageN: resolved.stageN,
1618
+ runId: resolved.runId,
1619
+ decision: laneBudgetDecision,
1620
+ source: laneLimit.source,
1621
+ });
675
1622
  }
676
1623
 
677
1624
  // Keep run-state `current_phase` coherent with the stage being persisted.
678
1625
  await persistActiveRunId(cwd, resolved.sessionId, resolved.runId, resolved.stage);
679
1626
  const persisted = await persistArtifact(resolved, cwd, content, sha256);
680
- if (plannerState) {
681
- await applyPlannerStateUpdate(cwd, resolved.sessionId, plannerState);
1627
+ if (persistedRoleState) {
1628
+ await applyPersistedRoleStateUpdate(cwd, resolved.sessionId, persistedRoleState);
1629
+ }
1630
+ if (laneVerdict) {
1631
+ await applyLaneVerdictUpdate(cwd, resolved.sessionId, laneVerdict);
682
1632
  }
683
1633
  await writeSessionActivityMarker(cwd, resolved.sessionId, { writer: "ralplan-runtime", path: persisted.path });
684
1634
  await syncRalplanHud({
@@ -688,34 +1638,51 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
688
1638
  runId: persisted.runId,
689
1639
  pendingApproval: persisted.stage === "final",
690
1640
  iteration: persisted.stageN,
1641
+ reviewPassBudget: laneLimit.maxReviewPassesPerLane,
691
1642
  latestSummary: `persisted ${persisted.stage} stage ${persisted.stageN}`,
692
1643
  });
1644
+ const reviewBudgetWarning =
1645
+ laneBudgetDecision.lane && laneBudgetDecision.finalSlot && laneBudgetDecision.maxReviewPassesPerLane > 1
1646
+ ? {
1647
+ lane: laneBudgetDecision.lane,
1648
+ passes: laneBudgetDecision.projectedPasses,
1649
+ max: laneBudgetDecision.maxReviewPassesPerLane,
1650
+ }
1651
+ : undefined;
1652
+
693
1653
  const payload: Record<string, unknown> = {
694
1654
  run_id: persisted.runId,
695
1655
  path: persisted.path,
696
1656
  stage: persisted.stage,
697
1657
  stage_n: persisted.stageN,
698
1658
  sha256: persisted.sha256,
1659
+ repository_binding: repositoryBinding,
699
1660
  created_at: persisted.createdAt,
700
1661
  };
701
1662
  if (persisted.pendingApprovalPath) payload.pending_approval_path = persisted.pendingApprovalPath;
702
- if (plannerState) payload.planner_state = plannerStatePayload(plannerState);
1663
+ if (persistedRoleState) payload[`${persistedRoleState.role}_state`] = persistedRoleStatePayload(persistedRoleState);
1664
+ if (reviewBudgetWarning) payload.review_budget_warning = reviewBudgetWarning;
1665
+ if (laneVerdict) payload.lane_verdict = { lane: laneVerdict.lane, verdict: laneVerdict.verdict };
1666
+
703
1667
  const stdout = resolved.json
704
1668
  ? `${JSON.stringify(payload, null, 2)}\n`
705
- : `Persisted ralplan ${persisted.stage} stage ${persisted.stageN} at ${persisted.path}.\n`;
1669
+ : `${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`;
706
1670
  return { status: 0, stdout };
707
1671
  }
708
1672
 
709
1673
  /**
710
- * Deterministic no-op receipt for an identical repeated `--write`: report the
711
- * already-persisted artifact without rewriting the file, appending a ledger row, or
712
- * churning run-state. `deduplicated: true` lets callers distinguish it from a fresh write.
1674
+ * Deterministic receipt for an identical repeated `--write`. Ledger-backed duplicates
1675
+ * do not rewrite artifacts, append rows, or churn run state; a crash-gap repair may
1676
+ * complete riding persisted role/lane metadata before returning this receipt.
713
1677
  */
714
1678
  function buildDeduplicatedResult(
715
1679
  resolved: ResolvedArtifactArgs,
716
1680
  existing: ExistingStageArtifact,
717
1681
  sha256: string,
718
1682
  cwd: string,
1683
+ repositoryBinding: RepositoryBinding,
1684
+ laneVerdict?: LaneVerdictUpdate,
1685
+ persistedRoleState?: PersistedRoleStateUpdate,
719
1686
  ): RalplanCommandResult {
720
1687
  const payload: Record<string, unknown> = {
721
1688
  run_id: resolved.runId,
@@ -723,9 +1690,14 @@ function buildDeduplicatedResult(
723
1690
  stage: resolved.stage,
724
1691
  stage_n: resolved.stageN,
725
1692
  sha256,
1693
+ repository_binding: repositoryBinding,
726
1694
  created_at: existing.createdAt,
727
1695
  deduplicated: true,
728
1696
  };
1697
+ if (laneVerdict) payload.lane_verdict = { lane: laneVerdict.lane, verdict: laneVerdict.verdict };
1698
+ if (persistedRoleState) {
1699
+ payload[`${persistedRoleState.role}_state`] = persistedRoleStatePayload(persistedRoleState);
1700
+ }
729
1701
  if (resolved.stage === "final") {
730
1702
  payload.pending_approval_path = path.join(
731
1703
  sessionPlansDir(cwd, resolved.sessionId),
@@ -774,6 +1746,9 @@ function extractPositionalTask(args: readonly string[]): string {
774
1746
  }
775
1747
 
776
1748
  function resolveConsensusArgs(args: readonly string[], cwd: string): ConsensusHandoffArgs {
1749
+ if (hasFlag(args, "--lane-verdict")) {
1750
+ throw new RalplanCommandError(2, "--lane-verdict is only supported with skc ralplan --write.");
1751
+ }
777
1752
  const architectKind = flagValue(args, "--architect")?.trim() || undefined;
778
1753
  if (architectKind && !KNOWN_ARCHITECT_KINDS.has(architectKind)) {
779
1754
  throw new RalplanCommandError(
@@ -809,7 +1784,7 @@ function resolveConsensusArgs(args: readonly string[], cwd: string): ConsensusHa
809
1784
  async function seedRalplanState(
810
1785
  cwd: string,
811
1786
  resolved: ConsensusHandoffArgs,
812
- ): Promise<{ statePath: string; runId: string }> {
1787
+ ): Promise<{ statePath: string; runId: string; repositoryBinding: RepositoryBinding }> {
813
1788
  const statePath = ralplanStatePath(cwd, resolved.sessionId);
814
1789
  // Reuse an existing run id when present so a re-invocation of `skc ralplan "task"` doesn't
815
1790
  // orphan in-progress artifacts under a fresh run id.
@@ -817,6 +1792,11 @@ async function seedRalplanState(
817
1792
  const runId = existingRunId ?? resolved.sessionId ?? defaultRunId();
818
1793
  assertSafePathComponent(runId, "run-id");
819
1794
  const now = new Date().toISOString();
1795
+ // When an active seed already carries authority, re-entry must match it (fail closed).
1796
+ // Otherwise stamp the current cwd as the durable binding for this run.
1797
+ const repositoryBinding = existingRunId
1798
+ ? await enforceRalplanRepositoryBinding(cwd, resolved.sessionId)
1799
+ : publicRepositoryBinding(await captureRepositoryBinding(cwd, { displayPath: cwd }));
820
1800
  const payload: Record<string, unknown> = {
821
1801
  active: true,
822
1802
  current_phase: "planner",
@@ -827,6 +1807,7 @@ async function seedRalplanState(
827
1807
  task: resolved.task,
828
1808
  run_id: runId,
829
1809
  updated_at: now,
1810
+ repository_binding: repositoryBinding,
830
1811
  };
831
1812
  if (resolved.architectKind) payload.architect_kind = resolved.architectKind;
832
1813
  if (resolved.criticKind) payload.critic_kind = resolved.criticKind;
@@ -849,7 +1830,7 @@ async function seedRalplanState(
849
1830
  },
850
1831
  });
851
1832
  await writeSessionActivityMarker(cwd, resolved.sessionId, { writer: "ralplan-runtime", path: statePath });
852
- return { statePath, runId };
1833
+ return { statePath, runId, repositoryBinding };
853
1834
  }
854
1835
 
855
1836
  async function handleConsensusHandoff(args: readonly string[], cwd: string): Promise<RalplanCommandResult> {
@@ -857,7 +1838,7 @@ async function handleConsensusHandoff(args: readonly string[], cwd: string): Pro
857
1838
  if (!resolved.task) {
858
1839
  throw new RalplanCommandError(2, 'skc ralplan requires a task description, e.g. `skc ralplan "<task>"`.');
859
1840
  }
860
- const { statePath, runId } = await seedRalplanState(cwd, resolved);
1841
+ const { statePath, runId, repositoryBinding } = await seedRalplanState(cwd, resolved);
861
1842
  const mode = resolved.deliberate ? "deliberate" : "short";
862
1843
  await syncRalplanHud({
863
1844
  cwd,
@@ -875,6 +1856,7 @@ async function handleConsensusHandoff(args: readonly string[], cwd: string): Pro
875
1856
  state_path: statePath,
876
1857
  run_id: runId,
877
1858
  handoff: "/skill:ralplan",
1859
+ repository_binding: repositoryBinding,
878
1860
  };
879
1861
  const stdout = resolved.json
880
1862
  ? renderCliWriteReceipt({ ok: true, ...summary })