@gajae-code/coding-agent 0.11.10 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/dist/types/cli/read-cli.d.ts +1 -0
  3. package/dist/types/cli.d.ts +8 -1
  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-profile-activation.d.ts +1 -0
  8. package/dist/types/config/model-profile-contract.d.ts +2 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/provider-auth-health.d.ts +14 -0
  11. package/dist/types/config/provider-ranking.d.ts +59 -0
  12. package/dist/types/config/settings-schema.d.ts +86 -3
  13. package/dist/types/config/settings.d.ts +3 -0
  14. package/dist/types/extensibility/extensions/types.d.ts +43 -2
  15. package/dist/types/extensibility/shared-events.d.ts +6 -0
  16. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  17. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  18. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  19. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  20. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  21. package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
  22. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  23. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +112 -1
  24. package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
  25. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  26. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  28. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  29. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  30. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +7 -0
  31. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  32. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  33. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  34. package/dist/types/main.d.ts +1 -1
  35. package/dist/types/memories/index.d.ts +2 -0
  36. package/dist/types/modes/acp/acp-agent.d.ts +22 -9
  37. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  38. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  39. package/dist/types/modes/components/queue-pane.d.ts +8 -0
  40. package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
  41. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  42. package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
  43. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  44. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  45. package/dist/types/modes/components/welcome.d.ts +1 -0
  46. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  47. package/dist/types/modes/interactive-mode.d.ts +7 -1
  48. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  49. package/dist/types/modes/types.d.ts +2 -0
  50. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  51. package/dist/types/runtime/memory-domain.d.ts +9 -0
  52. package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
  53. package/dist/types/runtime/memory-guard.d.ts +39 -0
  54. package/dist/types/runtime/memory-limit.d.ts +11 -0
  55. package/dist/types/runtime-mcp/manager.d.ts +8 -0
  56. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  57. package/dist/types/sdk/acp/final-text.d.ts +19 -0
  58. package/dist/types/sdk/acp/mcp.d.ts +23 -0
  59. package/dist/types/sdk/broker/lifecycle.d.ts +7 -0
  60. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  61. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  62. package/dist/types/sdk/bus/index.d.ts +18 -2
  63. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
  64. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  65. package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
  66. package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
  67. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -4
  68. package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -3
  69. package/dist/types/sdk/host/control/operations.d.ts +2 -1
  70. package/dist/types/sdk/host/host.d.ts +2 -0
  71. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  72. package/dist/types/sdk/lifecycle-session.d.ts +10 -1
  73. package/dist/types/sdk/startup-capability.d.ts +6 -0
  74. package/dist/types/session/agent-session.d.ts +51 -19
  75. package/dist/types/session/fallback-chain-controller.d.ts +18 -2
  76. package/dist/types/session/internal/managed-session-scope.d.ts +27 -0
  77. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  78. package/dist/types/session/session-manager.d.ts +59 -13
  79. package/dist/types/session/streaming-output.d.ts +60 -5
  80. package/dist/types/skill-state/active-state.d.ts +0 -4
  81. package/dist/types/skill-state/workflow-hud.d.ts +3 -0
  82. package/dist/types/task/executor.d.ts +6 -0
  83. package/dist/types/task/types.d.ts +36 -0
  84. package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
  85. package/dist/types/tools/browser/launch.d.ts +7 -0
  86. package/dist/types/tools/fetch.d.ts +17 -1
  87. package/dist/types/tools/output-meta.d.ts +21 -1
  88. package/dist/types/tools/read-internals.d.ts +12 -0
  89. package/dist/types/tools/read.d.ts +45 -1
  90. package/dist/types/tools/resource-gc.d.ts +23 -0
  91. package/dist/types/tools/tool-result.d.ts +7 -1
  92. package/package.json +9 -13
  93. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  94. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  95. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  96. package/scripts/run-test-manifest.ts +6 -4
  97. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  98. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  99. package/src/capability/index.ts +8 -0
  100. package/src/cli/args.ts +14 -8
  101. package/src/cli/fast-help.ts +1 -1
  102. package/src/cli/read-cli.ts +2 -1
  103. package/src/cli.ts +49 -2
  104. package/src/commands/deep-interview.ts +29 -9
  105. package/src/commands/harness.ts +35 -12
  106. package/src/commands/read.ts +11 -2
  107. package/src/commands/sdk.ts +91 -7
  108. package/src/commands/team.ts +1 -1
  109. package/src/config/model-profile-activation.ts +1 -0
  110. package/src/config/model-profile-contract.ts +13 -0
  111. package/src/config/model-profiles.ts +24 -0
  112. package/src/config/model-registry.ts +21 -2
  113. package/src/config/provider-auth-health.ts +42 -0
  114. package/src/config/provider-ranking.ts +119 -0
  115. package/src/config/settings-schema.ts +84 -3
  116. package/src/config/settings.ts +254 -108
  117. package/src/defaults/gjc/skills/deep-interview/SKILL.md +229 -82
  118. package/src/defaults/gjc/skills/ralplan/SKILL.md +78 -26
  119. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  120. package/src/defaults/gjc/skills/ultragoal/SKILL.md +26 -16
  121. package/src/eval/py/executor.ts +7 -2
  122. package/src/extensibility/extensions/runner.ts +4 -0
  123. package/src/extensibility/extensions/types.ts +42 -4
  124. package/src/extensibility/shared-events.ts +5 -0
  125. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -68
  126. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  127. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  128. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  129. package/src/gjc-runtime/deep-interview-state.ts +12 -921
  130. package/src/gjc-runtime/gc-render.ts +1 -0
  131. package/src/gjc-runtime/gc-runtime.ts +11 -1
  132. package/src/gjc-runtime/linux-proc.ts +70 -36
  133. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  134. package/src/gjc-runtime/ralplan-runtime.ts +979 -71
  135. package/src/gjc-runtime/state-runtime.ts +50 -12
  136. package/src/gjc-runtime/state-writer.ts +70 -222
  137. package/src/gjc-runtime/team-launch.ts +34 -5
  138. package/src/gjc-runtime/team-runtime.ts +970 -33
  139. package/src/gjc-runtime/team-store.ts +32 -0
  140. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  141. package/src/gjc-runtime/team-workers.ts +1 -0
  142. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -0
  143. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  144. package/src/gjc-runtime/ultragoal-runtime.ts +13 -7
  145. package/src/gjc-runtime/workflow-manifest.generated.json +97 -350
  146. package/src/gjc-runtime/workflow-manifest.ts +34 -179
  147. package/src/hooks/native-skill-hook.ts +17 -4
  148. package/src/hooks/skill-state.ts +1 -23
  149. package/src/internal-urls/docs-index.generated.ts +10 -11
  150. package/src/internal-urls/local-protocol.ts +23 -4
  151. package/src/internal-urls/local-root-gc.ts +127 -0
  152. package/src/main.ts +9 -5
  153. package/src/memories/index.ts +10 -0
  154. package/src/modes/DESIGN.md +47 -0
  155. package/src/modes/acp/acp-agent.ts +597 -67
  156. package/src/modes/acp/acp-event-mapper.ts +106 -12
  157. package/src/modes/acp/acp-mode.ts +39 -3
  158. package/src/modes/components/assistant-message.ts +123 -22
  159. package/src/modes/components/irc-sidebar.ts +176 -62
  160. package/src/modes/components/model-selector.ts +76 -5
  161. package/src/modes/components/notifications-settings-editor.ts +1 -1
  162. package/src/modes/components/oauth-selector.ts +42 -12
  163. package/src/modes/components/queue-pane.ts +55 -9
  164. package/src/modes/components/queued-message-selector.ts +53 -10
  165. package/src/modes/components/read-tool-group.ts +82 -14
  166. package/src/modes/components/session-observer-overlay.ts +187 -32
  167. package/src/modes/components/settings-selector.ts +60 -18
  168. package/src/modes/components/tool-execution.ts +138 -27
  169. package/src/modes/components/tool-status-header.ts +1 -4
  170. package/src/modes/components/welcome.ts +18 -3
  171. package/src/modes/controllers/command-controller.ts +4 -0
  172. package/src/modes/controllers/event-controller.ts +109 -27
  173. package/src/modes/controllers/extension-ui-controller.ts +23 -12
  174. package/src/modes/controllers/input-controller.ts +62 -6
  175. package/src/modes/controllers/selector-controller.ts +83 -37
  176. package/src/modes/interactive-mode.ts +127 -29
  177. package/src/modes/irc-observation-ledger.ts +71 -15
  178. package/src/modes/runtime-init.ts +20 -2
  179. package/src/modes/types.ts +2 -0
  180. package/src/modes/utils/ui-helpers.ts +94 -1
  181. package/src/prompts/agents/architect.md +9 -1
  182. package/src/prompts/agents/critic.md +9 -0
  183. package/src/prompts/agents/executor.md +1 -1
  184. package/src/prompts/system/system-prompt.md +0 -1
  185. package/src/prompts/tools/read.md +4 -2
  186. package/src/runtime/memory-domain.ts +66 -0
  187. package/src/runtime/memory-guard-contract.ts +73 -0
  188. package/src/runtime/memory-guard.ts +292 -0
  189. package/src/runtime/memory-limit.ts +50 -0
  190. package/src/runtime-mcp/manager.ts +26 -6
  191. package/src/runtime-mcp/smithery-auth.ts +17 -2
  192. package/src/runtime-mcp/smithery-connect.ts +11 -1
  193. package/src/sdk/acp/adapter.ts +8 -1
  194. package/src/sdk/acp/final-text.ts +40 -0
  195. package/src/sdk/acp/mcp.ts +26 -0
  196. package/src/sdk/broker/lifecycle.ts +107 -7
  197. package/src/sdk/bus/chat-daemon-cli.ts +2 -2
  198. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  199. package/src/sdk/bus/config.ts +1 -1
  200. package/src/sdk/bus/control-drain-lease.ts +104 -0
  201. package/src/sdk/bus/index.ts +503 -60
  202. package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
  203. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  204. package/src/sdk/bus/rate-limit-pool.ts +8 -0
  205. package/src/sdk/bus/reconciliation-store.ts +224 -0
  206. package/src/sdk/bus/telegram-daemon-contract.ts +6 -4
  207. package/src/sdk/bus/telegram-daemon.ts +531 -53
  208. package/src/sdk/host/control/dispatch.ts +7 -1
  209. package/src/sdk/host/control/operations.ts +2 -1
  210. package/src/sdk/host/host.ts +25 -3
  211. package/src/sdk/host/query/handlers.ts +55 -0
  212. package/src/sdk/lifecycle-session.ts +19 -3
  213. package/src/sdk/mcp/server.ts +46 -18
  214. package/src/sdk/protocol/operation-inventory.generated.json +76 -0
  215. package/src/sdk/protocol/operation-registry.ts +8 -2
  216. package/src/sdk/session.ts +25 -2
  217. package/src/sdk/startup-capability.ts +6 -0
  218. package/src/session/agent-session.ts +720 -190
  219. package/src/session/fallback-chain-controller.ts +47 -8
  220. package/src/session/internal/managed-session-scope.ts +40 -10
  221. package/src/session/internal/native-publish-outcome.ts +8 -3
  222. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  223. package/src/session/session-manager.ts +1372 -42
  224. package/src/session/streaming-output.ts +262 -43
  225. package/src/setup/provider-onboarding.ts +19 -4
  226. package/src/skill-state/active-state.ts +0 -10
  227. package/src/skill-state/workflow-hud.ts +25 -2
  228. package/src/skill-state/workflow-mutation-guard.ts +65 -7
  229. package/src/task/executor.ts +11 -3
  230. package/src/task/index.ts +2 -0
  231. package/src/task/types.ts +22 -11
  232. package/src/task/ultragoal-redteam-activation.ts +75 -0
  233. package/src/tools/ask.ts +89 -69
  234. package/src/tools/browser/launch.ts +41 -6
  235. package/src/tools/fetch.ts +114 -23
  236. package/src/tools/output-meta.ts +176 -9
  237. package/src/tools/path-utils.ts +5 -1
  238. package/src/tools/read-internals.ts +44 -0
  239. package/src/tools/read.ts +1542 -310
  240. package/src/tools/resource-gc.ts +665 -125
  241. package/src/tools/tool-result.ts +14 -1
  242. package/src/web/search/providers/codex.ts +1 -1
  243. package/src/workflow/workflow-intent-diff.ts +4 -1
  244. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  245. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  246. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  247. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  248. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  249. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
@@ -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 "@gajae-code/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";
@@ -21,7 +22,7 @@ import {
21
22
  RepositoryBindingError,
22
23
  } from "./repository-binding";
23
24
  import { GJC_RALPLAN_ARTIFACT_ENV, isRestrictedRoleAgentBash } from "./restricted-role-agent-bash";
24
- import { modeStatePath, sessionPlansDir } from "./session-layout";
25
+ import { gjcRoot, modeStatePath, sessionPlansDir } from "./session-layout";
25
26
  import { resolveGjcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
26
27
  import { migrateWorkflowState } from "./state-migrations";
27
28
  import { runNativeStateCommand } from "./state-runtime";
@@ -49,7 +50,7 @@ import { getSkillManifest } from "./workflow-manifest";
49
50
  *
50
51
  * 2. **Artifact write**: `gjc ralplan --write --stage <type> --stage_n <N>
51
52
  * (--artifact <path-or-string> | --artifact-env GJC_RALPLAN_ARTIFACT)
52
- * [--run-id <id>] [--session-id <id>] [--json]` persists Planner / Architect
53
+ * [--run-id <id>] [--session-id <id>] [--lane-verdict <token>] [--json]` persists Planner / Architect
53
54
  * / Critic / revision / post-interview / ADR / final markdown under `.gjc/plans/ralplan/<run-id>/`, maintains
54
55
  * an `index.jsonl` audit log, copies `final` stages to `pending-approval.md`, and advances
55
56
  * the HUD chip to reflect the latest persisted stage.
@@ -63,6 +64,498 @@ export interface RalplanCommandResult {
63
64
 
64
65
  const KNOWN_STAGES = ["planner", "architect", "critic", "revision", "post-interview", "adr", "final"] as const;
65
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 `gjc.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 `gjc.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["gjc.ralplan.maxIterations"]);
367
+ if (flat !== null) return flat;
368
+ const gjc = parsed.gjc;
369
+ if (gjc && typeof gjc === "object") {
370
+ const ralplan = (gjc 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 `./.gjc/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(gjcRoot(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 gjc.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, "gjc.ralplan.maxReviewPassesPerLane")) {
420
+ return parsePresentMaxReviewPassesPerLane(settings["gjc.ralplan.maxReviewPassesPerLane"]);
421
+ }
422
+ const gjc = settings.gjc;
423
+ if (!gjc || typeof gjc !== "object" || Array.isArray(gjc)) return { kind: "absent" };
424
+ const ralplan = (gjc 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(gjcRoot(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
+ }
66
559
 
67
560
  const KNOWN_ARCHITECT_KINDS = new Set(["openai-code"]);
68
561
  const KNOWN_CRITIC_KINDS = new Set(["openai-code"]);
@@ -96,10 +589,15 @@ const VALUE_FLAGS = new Set([
96
589
  "--critic",
97
590
  "--planner-id",
98
591
  "--planner-resumable",
592
+ "--architect-id",
593
+ "--architect-resumable",
594
+ "--critic-id",
595
+ "--critic-resumable",
99
596
  "--fallback-reason",
100
597
  "--fallback-attempted-id",
101
598
  "--fallback-stage-n",
102
599
  "--fallback-receipt-path",
600
+ "--lane-verdict",
103
601
  ]);
104
602
 
105
603
  export function isRalplanArtifactWriteInvocation(args: readonly string[]): boolean {
@@ -259,6 +757,13 @@ async function persistActiveRunId(cwd: string, sessionId: string, runId: string,
259
757
  // "complete"/"handoff" and disarm the Stop hook). PHASE_LOCK only guards same-run writes.
260
758
  const isNewRun = existing.run_id !== runId;
261
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
+ }
262
767
  if (
263
768
  existing.run_id === runId &&
264
769
  existing.version === WORKFLOW_STATE_VERSION &&
@@ -277,6 +782,7 @@ async function persistActiveRunId(cwd: string, sessionId: string, runId: string,
277
782
  existing.updated_at = new Date().toISOString();
278
783
  await writeWorkflowEnvelopeAtomic(statePath, existing, {
279
784
  cwd,
785
+ lockHeld: true,
280
786
  receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan persist-run-id", sessionId },
281
787
  audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan", sessionId },
282
788
  });
@@ -285,9 +791,12 @@ async function persistActiveRunId(cwd: string, sessionId: string, runId: string,
285
791
  );
286
792
  }
287
793
 
288
- /* --------------------------- planner run-state --------------------------- */
794
+ /* ---------------------- persisted role-agent run-state --------------------- */
795
+
796
+ type PersistedRole = "planner" | RalplanReviewLane;
289
797
 
290
- interface PlannerStateUpdate {
798
+ interface PersistedRoleStateUpdate {
799
+ role: PersistedRole;
291
800
  subagentId?: string;
292
801
  resumable?: boolean;
293
802
  fallbackReason?: string;
@@ -296,6 +805,25 @@ interface PlannerStateUpdate {
296
805
  fallbackReceiptPath?: string;
297
806
  }
298
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
+
299
827
  function parseBooleanFlag(raw: string, flag: string): boolean {
300
828
  if (raw === "true") return true;
301
829
  if (raw === "false") return false;
@@ -308,7 +836,7 @@ function assertSubagentId(value: string, label: string): void {
308
836
  }
309
837
  }
310
838
 
311
- function plannerFlagValue(args: readonly string[], flag: string): string | undefined {
839
+ function roleStateFlagValue(args: readonly string[], flag: string): string | undefined {
312
840
  const value = flagValue(args, flag);
313
841
  if (value === undefined && hasFlag(args, flag)) {
314
842
  throw new RalplanCommandError(2, `missing value for ${flag}.`);
@@ -316,19 +844,64 @@ function plannerFlagValue(args: readonly string[], flag: string): string | undef
316
844
  return value;
317
845
  }
318
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
+
319
860
  /**
320
- * Parse the optional persisted-Planner metadata flags that may ride alongside a
321
- * `--write`. Returns `undefined` when none are present so existing writes are
322
- * unaffected. Throws `RalplanCommandError` on any malformed value. This records
323
- * 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.
324
865
  */
325
- function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | undefined {
326
- const subagentId = plannerFlagValue(args, "--planner-id");
327
- const resumableRaw = plannerFlagValue(args, "--planner-resumable");
328
- const fallbackReason = plannerFlagValue(args, "--fallback-reason");
329
- const fallbackAttemptedId = plannerFlagValue(args, "--fallback-attempted-id");
330
- const fallbackStageNRaw = plannerFlagValue(args, "--fallback-stage-n");
331
- 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");
332
905
 
333
906
  const anyPresent = [
334
907
  subagentId,
@@ -340,14 +913,13 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
340
913
  ].some(value => value !== undefined);
341
914
  if (!anyPresent) return undefined;
342
915
 
343
- const update: PlannerStateUpdate = {};
344
-
916
+ const update: PersistedRoleStateUpdate = { role };
345
917
  if (subagentId !== undefined) {
346
- assertSubagentId(subagentId, "--planner-id");
918
+ assertSubagentId(subagentId, flags.id);
347
919
  update.subagentId = subagentId;
348
920
  }
349
921
  if (resumableRaw !== undefined) {
350
- update.resumable = parseBooleanFlag(resumableRaw, "--planner-resumable");
922
+ update.resumable = parseBooleanFlag(resumableRaw, flags.resumable);
351
923
  }
352
924
 
353
925
  const anyFallback = [fallbackReason, fallbackAttemptedId, fallbackStageNRaw, fallbackReceiptPath].some(
@@ -355,7 +927,7 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
355
927
  );
356
928
  if (anyFallback) {
357
929
  if (!fallbackReason) {
358
- 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.`);
359
931
  }
360
932
  if (!KNOWN_FALLBACK_REASONS.has(fallbackReason)) {
361
933
  throw new RalplanCommandError(
@@ -367,13 +939,13 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
367
939
  if (fallbackAttemptedId === undefined) {
368
940
  throw new RalplanCommandError(
369
941
  2,
370
- "--fallback-attempted-id is required when recording planner fallback metadata.",
942
+ `--fallback-attempted-id is required when recording ${role} fallback metadata.`,
371
943
  );
372
944
  }
373
945
  assertSubagentId(fallbackAttemptedId, "--fallback-attempted-id");
374
946
  update.fallbackAttemptedId = fallbackAttemptedId;
375
947
  if (fallbackStageNRaw === undefined) {
376
- 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.`);
377
949
  }
378
950
  update.fallbackStageN = parseStageN(fallbackStageNRaw);
379
951
  if (fallbackReceiptPath !== undefined) {
@@ -387,24 +959,107 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
387
959
  return update;
388
960
  }
389
961
 
390
- /** 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. */
391
- 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> {
392
992
  const payload: Record<string, unknown> = {};
393
- if (update.subagentId !== undefined) payload.planner_subagent_id = update.subagentId;
394
- if (update.resumable !== undefined) payload.planner_resumable = update.resumable;
395
- if (update.fallbackReason !== undefined) payload.planner_fallback_reason = update.fallbackReason;
396
- if (update.fallbackAttemptedId !== undefined) payload.planner_fallback_attempted_id = update.fallbackAttemptedId;
397
- if (update.fallbackStageN !== undefined) payload.planner_fallback_stage_n = update.fallbackStageN;
398
- 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
+ }
399
1005
  return payload;
400
1006
  }
401
1007
 
402
1008
  /**
403
- * Merge persisted-Planner metadata into the ralplan run-state JSON. Same-session
404
- * audit/routing hint only — it records what the caller has already proven and is
405
- * 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.
406
1012
  */
407
- 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> {
1019
+ const statePath = ralplanStatePath(cwd, sessionId);
1020
+ return await withWorkflowStateLock(
1021
+ statePath,
1022
+ async () => {
1023
+ const existingRead = await readExistingStateForMutation(statePath);
1024
+ if (existingRead.kind === "corrupt") {
1025
+ throw new RalplanCommandError(
1026
+ 2,
1027
+ `existing ralplan state is corrupt or tampered (${existingRead.error}); refusing to overwrite ${statePath}`,
1028
+ );
1029
+ }
1030
+ let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
1031
+ if (expectedActiveRunId !== undefined && existing.run_id !== expectedActiveRunId) return false;
1032
+ Object.assign(existing, persistedRoleStatePayload(update));
1033
+ if (typeof existing.skill !== "string") existing.skill = "ralplan";
1034
+ if (typeof existing.active !== "boolean") existing.active = true;
1035
+ if (typeof existing.current_phase !== "string") existing.current_phase = "planner";
1036
+ existing = migrateWorkflowState(existing, "ralplan").state;
1037
+ existing.updated_at = new Date().toISOString();
1038
+ await writeWorkflowEnvelopeAtomic(statePath, existing, {
1039
+ cwd,
1040
+ lockHeld: true,
1041
+ receipt: {
1042
+ cwd,
1043
+ skill: "ralplan",
1044
+ owner: "gjc-runtime",
1045
+ command: `gjc ralplan ${update.role}-state`,
1046
+ sessionId,
1047
+ },
1048
+ audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan", sessionId },
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> {
408
1063
  const statePath = ralplanStatePath(cwd, sessionId);
409
1064
  return await withWorkflowStateLock(
410
1065
  statePath,
@@ -417,7 +1072,12 @@ async function applyPlannerStateUpdate(cwd: string, sessionId: string, update: P
417
1072
  );
418
1073
  }
419
1074
  let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
420
- Object.assign(existing, plannerStatePayload(update));
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
+ });
421
1081
  if (typeof existing.skill !== "string") existing.skill = "ralplan";
422
1082
  if (typeof existing.active !== "boolean") existing.active = true;
423
1083
  if (typeof existing.current_phase !== "string") existing.current_phase = "planner";
@@ -425,9 +1085,11 @@ async function applyPlannerStateUpdate(cwd: string, sessionId: string, update: P
425
1085
  existing.updated_at = new Date().toISOString();
426
1086
  await writeWorkflowEnvelopeAtomic(statePath, existing, {
427
1087
  cwd,
428
- receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan planner-state", sessionId },
1088
+ lockHeld: true,
1089
+ receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan lane-verdict", sessionId },
429
1090
  audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan", sessionId },
430
1091
  });
1092
+ return true;
431
1093
  },
432
1094
  { cwd },
433
1095
  );
@@ -578,28 +1240,19 @@ interface ExistingStageArtifact {
578
1240
  }
579
1241
 
580
1242
  /**
581
- * Find the most recent `index.jsonl` row for a `(stage, stage_n)` pair so a
582
- * repeated `--write` can dedupe instead of silently clobbering the artifact and
583
- * appending a duplicate ledger row. Best-effort: a missing or unreadable index
584
- * yields `undefined`, treated as "no prior artifact". The ledger is the source of
585
- * 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.
586
1247
  */
587
- async function findExistingStageArtifact(
588
- cwd: string,
589
- sessionId: string,
590
- runId: string,
1248
+ function findExistingStageArtifact(
1249
+ indexText: string | undefined,
591
1250
  stage: RalplanStage,
592
1251
  stageN: number,
593
- ): Promise<ExistingStageArtifact | undefined> {
594
- const indexPath = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId, "index.jsonl");
595
- let text: string;
596
- try {
597
- text = await fs.readFile(indexPath, "utf8");
598
- } catch {
599
- return undefined;
600
- }
1252
+ ): ExistingStageArtifact | undefined {
1253
+ if (indexText === undefined) return undefined;
601
1254
  let match: ExistingStageArtifact | undefined;
602
- for (const line of text.split(/\r?\n/)) {
1255
+ for (const line of indexText.split(/\r?\n/)) {
603
1256
  const trimmed = line.trim();
604
1257
  if (!trimmed) continue;
605
1258
  let row: unknown;
@@ -621,6 +1274,124 @@ async function findExistingStageArtifact(
621
1274
  return match;
622
1275
  }
623
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: "gjc-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: "gjc-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
+
624
1395
  /**
625
1396
  * Read and parse the run's `index.jsonl` rows. Best-effort: returns [] when the
626
1397
  * file is absent or unreadable so HUD sync never fails on a missing index.
@@ -640,6 +1411,18 @@ async function readRalplanIndexRows(cwd: string, sessionId: string, runId: strin
640
1411
  }
641
1412
  }
642
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
+
643
1426
  async function syncRalplanHud(options: {
644
1427
  cwd: string;
645
1428
  sessionId: string;
@@ -647,6 +1430,7 @@ async function syncRalplanHud(options: {
647
1430
  pendingApproval: boolean;
648
1431
  iteration?: number;
649
1432
  runId?: string;
1433
+ reviewPassBudget?: number;
650
1434
  latestSummary?: string;
651
1435
  }): Promise<void> {
652
1436
  try {
@@ -672,15 +1456,25 @@ async function buildRalplanHud(options: {
672
1456
  latestSummary?: string;
673
1457
  runId?: string;
674
1458
  sessionId?: string;
1459
+ reviewPassBudget?: number;
675
1460
  }) {
676
1461
  let iterationFromIndex: number | undefined;
677
1462
  let stages: string | undefined;
1463
+ let architectPasses: number | undefined;
1464
+ let criticPasses: number | undefined;
1465
+ let verdict: string | undefined;
678
1466
  if (options.runId && options.sessionId) {
679
- 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;
680
1472
  if (rows.length > 0) {
681
1473
  const summary = summarizeRalplanIndex(rows);
682
1474
  iterationFromIndex = summary.iteration;
683
1475
  stages = formatRalplanStagePresence(summary.currentStages);
1476
+ architectPasses = summary.currentStages.filter(stage => stage === "architect").length;
1477
+ criticPasses = summary.currentStages.filter(stage => stage === "critic").length;
684
1478
  }
685
1479
  }
686
1480
  return buildRalplanHudSummary({
@@ -688,6 +1482,10 @@ async function buildRalplanHud(options: {
688
1482
  iteration: options.iteration,
689
1483
  iterationFromIndex,
690
1484
  stages,
1485
+ architectPasses,
1486
+ criticPasses,
1487
+ reviewPassBudget: options.reviewPassBudget,
1488
+ verdict,
691
1489
  pendingApproval: options.pendingApproval,
692
1490
  latestSummary: options.latestSummary,
693
1491
  updatedAt: new Date().toISOString(),
@@ -695,8 +1493,9 @@ async function buildRalplanHud(options: {
695
1493
  }
696
1494
 
697
1495
  async function handleArtifactWrite(args: readonly string[], cwd: string): Promise<RalplanCommandResult> {
698
- const plannerState = parsePlannerStateArgs(args);
699
1496
  const resolved = await resolveArtifactArgs(args, cwd);
1497
+ const persistedRoleState = parsePersistedRoleStateArgs(args, resolved.stage);
1498
+ const laneVerdict = parseLaneVerdictArgs(args, resolved.stage, resolved.stageN);
700
1499
  // Fail closed before stage persistence / path writes when cwd drifted to a sibling repo.
701
1500
  const repositoryBinding = await enforceRalplanRepositoryBinding(cwd, resolved.sessionId);
702
1501
  // Artifact file paths (when --artifact points at a file) must stay under the bound root.
@@ -718,16 +1517,15 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
718
1517
  const content = resolved.artifact.endsWith("\n") ? resolved.artifact : `${resolved.artifact}\n`;
719
1518
  const sha256 = createHash("sha256").update(content).digest("hex");
720
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
+
721
1525
  // Duplicate-write guard: a second `--write` for the same (stage, stage_n) must not
722
1526
  // silently clobber the artifact or append a duplicate ledger row. Classify before any
723
1527
  // state mutation so a conflict never regresses run-state phase.
724
- const existingArtifact = await findExistingStageArtifact(
725
- cwd,
726
- resolved.sessionId,
727
- resolved.runId,
728
- resolved.stage,
729
- resolved.stageN,
730
- );
1528
+ const existingArtifact = findExistingStageArtifact(indexLoad.rawText, resolved.stage, resolved.stageN);
731
1529
  if (existingArtifact) {
732
1530
  if (existingArtifact.sha256 !== sha256) {
733
1531
  throw new RalplanCommandError(
@@ -735,14 +1533,102 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
735
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.`,
736
1534
  );
737
1535
  }
1536
+ if (resolved.stage === "final") await ensureFinalPendingApproval(cwd, resolved, existingArtifact);
738
1537
  return buildDeduplicatedResult(resolved, existingArtifact, sha256, cwd, repositoryBinding);
739
1538
  }
740
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
+ // `gjc 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
+ });
1622
+ }
1623
+
741
1624
  // Keep run-state `current_phase` coherent with the stage being persisted.
742
1625
  await persistActiveRunId(cwd, resolved.sessionId, resolved.runId, resolved.stage);
743
1626
  const persisted = await persistArtifact(resolved, cwd, content, sha256);
744
- if (plannerState) {
745
- 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);
746
1632
  }
747
1633
  await writeSessionActivityMarker(cwd, resolved.sessionId, { writer: "ralplan-runtime", path: persisted.path });
748
1634
  await syncRalplanHud({
@@ -752,8 +1638,18 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
752
1638
  runId: persisted.runId,
753
1639
  pendingApproval: persisted.stage === "final",
754
1640
  iteration: persisted.stageN,
1641
+ reviewPassBudget: laneLimit.maxReviewPassesPerLane,
755
1642
  latestSummary: `persisted ${persisted.stage} stage ${persisted.stageN}`,
756
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
+
757
1653
  const payload: Record<string, unknown> = {
758
1654
  run_id: persisted.runId,
759
1655
  path: persisted.path,
@@ -764,17 +1660,20 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
764
1660
  created_at: persisted.createdAt,
765
1661
  };
766
1662
  if (persisted.pendingApprovalPath) payload.pending_approval_path = persisted.pendingApprovalPath;
767
- 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
+
768
1667
  const stdout = resolved.json
769
1668
  ? `${JSON.stringify(payload, null, 2)}\n`
770
- : `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`;
771
1670
  return { status: 0, stdout };
772
1671
  }
773
1672
 
774
1673
  /**
775
- * Deterministic no-op receipt for an identical repeated `--write`: report the
776
- * already-persisted artifact without rewriting the file, appending a ledger row, or
777
- * 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.
778
1677
  */
779
1678
  function buildDeduplicatedResult(
780
1679
  resolved: ResolvedArtifactArgs,
@@ -782,6 +1681,8 @@ function buildDeduplicatedResult(
782
1681
  sha256: string,
783
1682
  cwd: string,
784
1683
  repositoryBinding: RepositoryBinding,
1684
+ laneVerdict?: LaneVerdictUpdate,
1685
+ persistedRoleState?: PersistedRoleStateUpdate,
785
1686
  ): RalplanCommandResult {
786
1687
  const payload: Record<string, unknown> = {
787
1688
  run_id: resolved.runId,
@@ -793,6 +1694,10 @@ function buildDeduplicatedResult(
793
1694
  created_at: existing.createdAt,
794
1695
  deduplicated: true,
795
1696
  };
1697
+ if (laneVerdict) payload.lane_verdict = { lane: laneVerdict.lane, verdict: laneVerdict.verdict };
1698
+ if (persistedRoleState) {
1699
+ payload[`${persistedRoleState.role}_state`] = persistedRoleStatePayload(persistedRoleState);
1700
+ }
796
1701
  if (resolved.stage === "final") {
797
1702
  payload.pending_approval_path = path.join(
798
1703
  sessionPlansDir(cwd, resolved.sessionId),
@@ -841,6 +1746,9 @@ function extractPositionalTask(args: readonly string[]): string {
841
1746
  }
842
1747
 
843
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 gjc ralplan --write.");
1751
+ }
844
1752
  const architectKind = flagValue(args, "--architect")?.trim() || undefined;
845
1753
  if (architectKind && !KNOWN_ARCHITECT_KINDS.has(architectKind)) {
846
1754
  throw new RalplanCommandError(