@gajae-code/coding-agent 0.11.11 → 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 (182) hide show
  1. package/CHANGELOG.md +49 -3
  2. package/dist/types/cli/read-cli.d.ts +1 -0
  3. package/dist/types/commands/deep-interview.d.ts +9 -0
  4. package/dist/types/commands/harness.d.ts +22 -0
  5. package/dist/types/commands/read.d.ts +6 -0
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/provider-auth-health.d.ts +14 -0
  8. package/dist/types/config/provider-ranking.d.ts +59 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/config/settings.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +22 -2
  12. package/dist/types/extensibility/shared-events.d.ts +6 -0
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  14. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  15. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  16. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  17. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  18. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  19. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +52 -1
  20. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  21. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  22. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  23. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  24. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  25. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +7 -0
  26. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  27. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  28. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  29. package/dist/types/main.d.ts +1 -1
  30. package/dist/types/memories/index.d.ts +2 -0
  31. package/dist/types/modes/acp/acp-agent.d.ts +8 -0
  32. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  33. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  34. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  35. package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
  36. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  37. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  38. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  39. package/dist/types/modes/interactive-mode.d.ts +2 -0
  40. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  41. package/dist/types/modes/types.d.ts +2 -0
  42. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  43. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  44. package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
  45. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  46. package/dist/types/sdk/bus/index.d.ts +15 -1
  47. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  49. package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
  50. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  51. package/dist/types/sdk/host/host.d.ts +2 -0
  52. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  53. package/dist/types/session/agent-session.d.ts +44 -10
  54. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  55. package/dist/types/session/session-manager.d.ts +25 -13
  56. package/dist/types/session/streaming-output.d.ts +60 -5
  57. package/dist/types/skill-state/active-state.d.ts +0 -4
  58. package/dist/types/skill-state/workflow-hud.d.ts +3 -0
  59. package/dist/types/tools/browser/launch.d.ts +7 -0
  60. package/dist/types/tools/fetch.d.ts +17 -1
  61. package/dist/types/tools/output-meta.d.ts +21 -1
  62. package/dist/types/tools/read-internals.d.ts +12 -0
  63. package/dist/types/tools/read.d.ts +45 -1
  64. package/dist/types/tools/resource-gc.d.ts +4 -0
  65. package/dist/types/tools/tool-result.d.ts +7 -1
  66. package/package.json +9 -13
  67. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  68. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  69. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  70. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  71. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  72. package/src/capability/index.ts +8 -0
  73. package/src/cli/read-cli.ts +2 -1
  74. package/src/cli.ts +1 -0
  75. package/src/commands/deep-interview.ts +29 -9
  76. package/src/commands/harness.ts +35 -12
  77. package/src/commands/read.ts +11 -2
  78. package/src/commands/team.ts +1 -1
  79. package/src/config/model-registry.ts +21 -2
  80. package/src/config/provider-auth-health.ts +42 -0
  81. package/src/config/provider-ranking.ts +119 -0
  82. package/src/config/settings-schema.ts +23 -1
  83. package/src/config/settings.ts +246 -108
  84. package/src/defaults/gjc/skills/deep-interview/SKILL.md +229 -82
  85. package/src/defaults/gjc/skills/ralplan/SKILL.md +58 -25
  86. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  87. package/src/defaults/gjc/skills/ultragoal/SKILL.md +25 -15
  88. package/src/eval/py/executor.ts +7 -2
  89. package/src/extensibility/extensions/types.ts +28 -4
  90. package/src/extensibility/shared-events.ts +5 -0
  91. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
  92. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  93. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  94. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  95. package/src/gjc-runtime/deep-interview-state.ts +12 -943
  96. package/src/gjc-runtime/gc-render.ts +1 -0
  97. package/src/gjc-runtime/gc-runtime.ts +11 -1
  98. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  99. package/src/gjc-runtime/ralplan-runtime.ts +742 -85
  100. package/src/gjc-runtime/state-runtime.ts +2 -1
  101. package/src/gjc-runtime/state-writer.ts +70 -222
  102. package/src/gjc-runtime/team-launch.ts +34 -5
  103. package/src/gjc-runtime/team-runtime.ts +970 -33
  104. package/src/gjc-runtime/team-store.ts +32 -0
  105. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  106. package/src/gjc-runtime/team-workers.ts +1 -0
  107. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -0
  108. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  109. package/src/gjc-runtime/ultragoal-runtime.ts +13 -7
  110. package/src/gjc-runtime/workflow-manifest.generated.json +97 -350
  111. package/src/gjc-runtime/workflow-manifest.ts +34 -179
  112. package/src/hooks/native-skill-hook.ts +17 -4
  113. package/src/hooks/skill-state.ts +1 -23
  114. package/src/internal-urls/docs-index.generated.ts +8 -9
  115. package/src/internal-urls/local-root-gc.ts +127 -0
  116. package/src/main.ts +9 -5
  117. package/src/memories/index.ts +10 -0
  118. package/src/modes/DESIGN.md +47 -0
  119. package/src/modes/acp/acp-agent.ts +43 -7
  120. package/src/modes/acp/acp-event-mapper.ts +14 -7
  121. package/src/modes/acp/acp-mode.ts +39 -3
  122. package/src/modes/components/assistant-message.ts +123 -22
  123. package/src/modes/components/irc-sidebar.ts +176 -62
  124. package/src/modes/components/model-selector.ts +61 -4
  125. package/src/modes/components/oauth-selector.ts +42 -12
  126. package/src/modes/components/read-tool-group.ts +82 -14
  127. package/src/modes/components/session-observer-overlay.ts +187 -32
  128. package/src/modes/components/settings-selector.ts +60 -18
  129. package/src/modes/components/tool-execution.ts +138 -27
  130. package/src/modes/controllers/event-controller.ts +109 -27
  131. package/src/modes/controllers/extension-ui-controller.ts +19 -2
  132. package/src/modes/controllers/input-controller.ts +18 -3
  133. package/src/modes/controllers/selector-controller.ts +83 -37
  134. package/src/modes/interactive-mode.ts +62 -3
  135. package/src/modes/irc-observation-ledger.ts +71 -15
  136. package/src/modes/runtime-init.ts +19 -2
  137. package/src/modes/types.ts +2 -0
  138. package/src/modes/utils/ui-helpers.ts +94 -1
  139. package/src/prompts/agents/architect.md +9 -1
  140. package/src/prompts/agents/critic.md +9 -0
  141. package/src/prompts/system/system-prompt.md +0 -1
  142. package/src/prompts/tools/read.md +4 -2
  143. package/src/runtime-mcp/smithery-auth.ts +17 -2
  144. package/src/runtime-mcp/smithery-connect.ts +11 -1
  145. package/src/sdk/broker/lifecycle.ts +25 -6
  146. package/src/sdk/bus/control-drain-lease.ts +104 -0
  147. package/src/sdk/bus/index.ts +341 -38
  148. package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
  149. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  150. package/src/sdk/bus/reconciliation-store.ts +224 -0
  151. package/src/sdk/host/control/dispatch.ts +5 -1
  152. package/src/sdk/host/control/operations.ts +1 -1
  153. package/src/sdk/host/host.ts +23 -1
  154. package/src/sdk/host/query/handlers.ts +55 -0
  155. package/src/sdk/mcp/server.ts +46 -18
  156. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  157. package/src/sdk/protocol/operation-registry.ts +7 -2
  158. package/src/sdk/session.ts +13 -1
  159. package/src/session/agent-session.ts +466 -50
  160. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  161. package/src/session/session-manager.ts +764 -17
  162. package/src/session/streaming-output.ts +262 -43
  163. package/src/setup/provider-onboarding.ts +19 -4
  164. package/src/skill-state/active-state.ts +0 -10
  165. package/src/skill-state/workflow-hud.ts +25 -2
  166. package/src/skill-state/workflow-mutation-guard.ts +65 -7
  167. package/src/tools/ask.ts +89 -69
  168. package/src/tools/browser/launch.ts +41 -6
  169. package/src/tools/fetch.ts +114 -23
  170. package/src/tools/output-meta.ts +176 -9
  171. package/src/tools/path-utils.ts +5 -1
  172. package/src/tools/read-internals.ts +44 -0
  173. package/src/tools/read.ts +1542 -310
  174. package/src/tools/resource-gc.ts +182 -18
  175. package/src/tools/tool-result.ts +14 -1
  176. package/src/web/search/providers/codex.ts +1 -1
  177. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  178. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  179. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  180. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  181. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  182. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
- import * as os from "node:os";
4
3
  import * as path from "node:path";
4
+ import { getConfigRootDir } from "@gajae-code/utils";
5
5
  import { syncSkillActiveState } from "../skill-state/active-state";
6
6
  import { buildRalplanHudSummary } from "../skill-state/workflow-hud";
7
7
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
@@ -50,7 +50,7 @@ import { getSkillManifest } from "./workflow-manifest";
50
50
  *
51
51
  * 2. **Artifact write**: `gjc ralplan --write --stage <type> --stage_n <N>
52
52
  * (--artifact <path-or-string> | --artifact-env GJC_RALPLAN_ARTIFACT)
53
- * [--run-id <id>] [--session-id <id>] [--json]` persists Planner / Architect
53
+ * [--run-id <id>] [--session-id <id>] [--lane-verdict <token>] [--json]` persists Planner / Architect
54
54
  * / Critic / revision / post-interview / ADR / final markdown under `.gjc/plans/ralplan/<run-id>/`, maintains
55
55
  * an `index.jsonl` audit log, copies `final` stages to `pending-approval.md`, and advances
56
56
  * the HUD chip to reflect the latest persisted stage.
@@ -70,9 +70,25 @@ export const RALPLAN_DEFAULT_MAX_ITERATIONS = 5;
70
70
  export const RALPLAN_MAX_ITERATIONS_LIMIT = 20;
71
71
  /** Operator-visible stuck signal for headless/CI orchestration (#3165). */
72
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;
73
77
 
74
78
  const RALPLAN_ITERATION_OPENER_STAGES = new Set<RalplanStage>(["planner", "revision"]);
75
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
+
76
92
  export type RalplanIterationCapDecision =
77
93
  | {
78
94
  allowed: true;
@@ -147,8 +163,106 @@ export function evaluateRalplanIterationCap(input: {
147
163
  };
148
164
  }
149
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
+
150
263
  /** Filename pattern for persisted planner/revision stage artifacts. */
151
264
  const OPENER_ARTIFACT_RE = /^stage-\d{2,}-(planner|revision)\.md$/;
265
+ const LANE_ARTIFACT_RE = /^stage-\d{2,}-(architect|critic)\.md$/;
152
266
 
153
267
  /**
154
268
  * Count on-disk planner/revision stage artifacts for a run. Used as a floor when
@@ -168,6 +282,30 @@ export async function countRalplanOnDiskOpeners(cwd: string, sessionId: string,
168
282
  }
169
283
  }
170
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
+
171
309
  /**
172
310
  * Load index rows for cap enforcement. Unlike HUD reads, returns structural
173
311
  * signals so callers can fail closed when the ledger is empty/malformed while
@@ -177,7 +315,13 @@ export async function loadRalplanIndexForCap(
177
315
  cwd: string,
178
316
  sessionId: string,
179
317
  runId: string,
180
- ): Promise<{ rows: RalplanIndexRow[]; indexPresent: boolean; parseableLines: number; rawLineCount: number }> {
318
+ ): Promise<{
319
+ rows: RalplanIndexRow[];
320
+ indexPresent: boolean;
321
+ parseableLines: number;
322
+ rawLineCount: number;
323
+ rawText?: string;
324
+ }> {
181
325
  const indexPath = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId, "index.jsonl");
182
326
  try {
183
327
  const text = await fs.readFile(indexPath, "utf8");
@@ -192,6 +336,7 @@ export async function loadRalplanIndexForCap(
192
336
  indexPresent: true,
193
337
  parseableLines: rows.length,
194
338
  rawLineCount: lines.length,
339
+ rawText: text,
195
340
  };
196
341
  } catch (error) {
197
342
  const code =
@@ -204,16 +349,16 @@ export async function loadRalplanIndexForCap(
204
349
  }
205
350
  }
206
351
 
207
- function parseMaxIterationsValue(value: unknown): number | null {
208
- return typeof value === "number" &&
209
- Number.isFinite(value) &&
210
- Number.isInteger(value) &&
211
- value >= 1 &&
212
- value <= RALPLAN_MAX_ITERATIONS_LIMIT
352
+ function parseBoundedPositiveInteger(value: unknown, limit: number): number | null {
353
+ return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 1 && value <= limit
213
354
  ? value
214
355
  : null;
215
356
  }
216
357
 
358
+ function parseMaxIterationsValue(value: unknown): number | null {
359
+ return parseBoundedPositiveInteger(value, RALPLAN_MAX_ITERATIONS_LIMIT);
360
+ }
361
+
217
362
  async function readSettingsMaxIterations(settingsPath: string): Promise<number | null> {
218
363
  try {
219
364
  const raw = await Bun.file(settingsPath).text();
@@ -241,13 +386,90 @@ export async function resolveRalplanMaxIterations(cwd: string): Promise<{ maxIte
241
386
  const projectPath = path.join(gjcRoot(cwd), "settings.json");
242
387
  const project = await readSettingsMaxIterations(projectPath);
243
388
  if (project !== null) return { maxIterations: project, source: projectPath };
244
- const userDir = process.env.GJC_CONFIG_DIR?.trim() || path.join(os.homedir(), ".gjc");
245
- const userPath = path.join(userDir, "settings.json");
389
+ const userPath = path.join(getConfigRootDir(), "settings.json");
246
390
  const user = await readSettingsMaxIterations(userPath);
247
391
  if (user !== null) return { maxIterations: user, source: userPath };
248
392
  return { maxIterations: RALPLAN_DEFAULT_MAX_ITERATIONS, source: "default" };
249
393
  }
250
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
+
251
473
  function buildPlanningStuckResult(input: {
252
474
  json: boolean;
253
475
  stage: RalplanStage;
@@ -290,6 +512,51 @@ function buildPlanningStuckResult(input: {
290
512
  };
291
513
  }
292
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
+ }
559
+
293
560
  const KNOWN_ARCHITECT_KINDS = new Set(["openai-code"]);
294
561
  const KNOWN_CRITIC_KINDS = new Set(["openai-code"]);
295
562
 
@@ -322,10 +589,15 @@ const VALUE_FLAGS = new Set([
322
589
  "--critic",
323
590
  "--planner-id",
324
591
  "--planner-resumable",
592
+ "--architect-id",
593
+ "--architect-resumable",
594
+ "--critic-id",
595
+ "--critic-resumable",
325
596
  "--fallback-reason",
326
597
  "--fallback-attempted-id",
327
598
  "--fallback-stage-n",
328
599
  "--fallback-receipt-path",
600
+ "--lane-verdict",
329
601
  ]);
330
602
 
331
603
  export function isRalplanArtifactWriteInvocation(args: readonly string[]): boolean {
@@ -485,6 +757,13 @@ async function persistActiveRunId(cwd: string, sessionId: string, runId: string,
485
757
  // "complete"/"handoff" and disarm the Stop hook). PHASE_LOCK only guards same-run writes.
486
758
  const isNewRun = existing.run_id !== runId;
487
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
+ }
488
767
  if (
489
768
  existing.run_id === runId &&
490
769
  existing.version === WORKFLOW_STATE_VERSION &&
@@ -503,6 +782,7 @@ async function persistActiveRunId(cwd: string, sessionId: string, runId: string,
503
782
  existing.updated_at = new Date().toISOString();
504
783
  await writeWorkflowEnvelopeAtomic(statePath, existing, {
505
784
  cwd,
785
+ lockHeld: true,
506
786
  receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan persist-run-id", sessionId },
507
787
  audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan", sessionId },
508
788
  });
@@ -511,9 +791,12 @@ async function persistActiveRunId(cwd: string, sessionId: string, runId: string,
511
791
  );
512
792
  }
513
793
 
514
- /* --------------------------- planner run-state --------------------------- */
794
+ /* ---------------------- persisted role-agent run-state --------------------- */
795
+
796
+ type PersistedRole = "planner" | RalplanReviewLane;
515
797
 
516
- interface PlannerStateUpdate {
798
+ interface PersistedRoleStateUpdate {
799
+ role: PersistedRole;
517
800
  subagentId?: string;
518
801
  resumable?: boolean;
519
802
  fallbackReason?: string;
@@ -522,6 +805,25 @@ interface PlannerStateUpdate {
522
805
  fallbackReceiptPath?: string;
523
806
  }
524
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
+
525
827
  function parseBooleanFlag(raw: string, flag: string): boolean {
526
828
  if (raw === "true") return true;
527
829
  if (raw === "false") return false;
@@ -534,7 +836,7 @@ function assertSubagentId(value: string, label: string): void {
534
836
  }
535
837
  }
536
838
 
537
- function plannerFlagValue(args: readonly string[], flag: string): string | undefined {
839
+ function roleStateFlagValue(args: readonly string[], flag: string): string | undefined {
538
840
  const value = flagValue(args, flag);
539
841
  if (value === undefined && hasFlag(args, flag)) {
540
842
  throw new RalplanCommandError(2, `missing value for ${flag}.`);
@@ -542,19 +844,64 @@ function plannerFlagValue(args: readonly string[], flag: string): string | undef
542
844
  return value;
543
845
  }
544
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
+
545
860
  /**
546
- * Parse the optional persisted-Planner metadata flags that may ride alongside a
547
- * `--write`. Returns `undefined` when none are present so existing writes are
548
- * unaffected. Throws `RalplanCommandError` on any malformed value. This records
549
- * a same-session audit/routing hint, not a durable subagent registry.
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.
550
865
  */
551
- function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | undefined {
552
- const subagentId = plannerFlagValue(args, "--planner-id");
553
- const resumableRaw = plannerFlagValue(args, "--planner-resumable");
554
- const fallbackReason = plannerFlagValue(args, "--fallback-reason");
555
- const fallbackAttemptedId = plannerFlagValue(args, "--fallback-attempted-id");
556
- const fallbackStageNRaw = plannerFlagValue(args, "--fallback-stage-n");
557
- const fallbackReceiptPath = plannerFlagValue(args, "--fallback-receipt-path");
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");
558
905
 
559
906
  const anyPresent = [
560
907
  subagentId,
@@ -566,14 +913,13 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
566
913
  ].some(value => value !== undefined);
567
914
  if (!anyPresent) return undefined;
568
915
 
569
- const update: PlannerStateUpdate = {};
570
-
916
+ const update: PersistedRoleStateUpdate = { role };
571
917
  if (subagentId !== undefined) {
572
- assertSubagentId(subagentId, "--planner-id");
918
+ assertSubagentId(subagentId, flags.id);
573
919
  update.subagentId = subagentId;
574
920
  }
575
921
  if (resumableRaw !== undefined) {
576
- update.resumable = parseBooleanFlag(resumableRaw, "--planner-resumable");
922
+ update.resumable = parseBooleanFlag(resumableRaw, flags.resumable);
577
923
  }
578
924
 
579
925
  const anyFallback = [fallbackReason, fallbackAttemptedId, fallbackStageNRaw, fallbackReceiptPath].some(
@@ -581,7 +927,7 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
581
927
  );
582
928
  if (anyFallback) {
583
929
  if (!fallbackReason) {
584
- 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.`);
585
931
  }
586
932
  if (!KNOWN_FALLBACK_REASONS.has(fallbackReason)) {
587
933
  throw new RalplanCommandError(
@@ -593,13 +939,13 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
593
939
  if (fallbackAttemptedId === undefined) {
594
940
  throw new RalplanCommandError(
595
941
  2,
596
- "--fallback-attempted-id is required when recording planner fallback metadata.",
942
+ `--fallback-attempted-id is required when recording ${role} fallback metadata.`,
597
943
  );
598
944
  }
599
945
  assertSubagentId(fallbackAttemptedId, "--fallback-attempted-id");
600
946
  update.fallbackAttemptedId = fallbackAttemptedId;
601
947
  if (fallbackStageNRaw === undefined) {
602
- 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.`);
603
949
  }
604
950
  update.fallbackStageN = parseStageN(fallbackStageNRaw);
605
951
  if (fallbackReceiptPath !== undefined) {
@@ -613,24 +959,63 @@ function parsePlannerStateArgs(args: readonly string[]): PlannerStateUpdate | un
613
959
  return update;
614
960
  }
615
961
 
616
- /** Snake-case projection of a PlannerStateUpdate for state JSON + receipts. Omitted fields stay absent — an unknown `planner_resumable` is encoded by omission, never literal null. */
617
- function plannerStatePayload(update: PlannerStateUpdate): Record<string, unknown> {
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> {
618
992
  const payload: Record<string, unknown> = {};
619
- if (update.subagentId !== undefined) payload.planner_subagent_id = update.subagentId;
620
- if (update.resumable !== undefined) payload.planner_resumable = update.resumable;
621
- if (update.fallbackReason !== undefined) payload.planner_fallback_reason = update.fallbackReason;
622
- if (update.fallbackAttemptedId !== undefined) payload.planner_fallback_attempted_id = update.fallbackAttemptedId;
623
- if (update.fallbackStageN !== undefined) payload.planner_fallback_stage_n = update.fallbackStageN;
624
- if (update.fallbackReceiptPath !== undefined) payload.planner_fallback_receipt_path = update.fallbackReceiptPath;
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
+ }
625
1005
  return payload;
626
1006
  }
627
1007
 
628
1008
  /**
629
- * Merge persisted-Planner metadata into the ralplan run-state JSON. Same-session
630
- * audit/routing hint only — it records what the caller has already proven and is
631
- * NOT a durable cross-process subagent registry.
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.
632
1012
  */
633
- 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> {
634
1019
  const statePath = ralplanStatePath(cwd, sessionId);
635
1020
  return await withWorkflowStateLock(
636
1021
  statePath,
@@ -643,7 +1028,8 @@ async function applyPlannerStateUpdate(cwd: string, sessionId: string, update: P
643
1028
  );
644
1029
  }
645
1030
  let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
646
- Object.assign(existing, plannerStatePayload(update));
1031
+ if (expectedActiveRunId !== undefined && existing.run_id !== expectedActiveRunId) return false;
1032
+ Object.assign(existing, persistedRoleStatePayload(update));
647
1033
  if (typeof existing.skill !== "string") existing.skill = "ralplan";
648
1034
  if (typeof existing.active !== "boolean") existing.active = true;
649
1035
  if (typeof existing.current_phase !== "string") existing.current_phase = "planner";
@@ -651,9 +1037,59 @@ async function applyPlannerStateUpdate(cwd: string, sessionId: string, update: P
651
1037
  existing.updated_at = new Date().toISOString();
652
1038
  await writeWorkflowEnvelopeAtomic(statePath, existing, {
653
1039
  cwd,
654
- receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan planner-state", sessionId },
1040
+ lockHeld: true,
1041
+ receipt: {
1042
+ cwd,
1043
+ skill: "ralplan",
1044
+ owner: "gjc-runtime",
1045
+ command: `gjc ralplan ${update.role}-state`,
1046
+ sessionId,
1047
+ },
655
1048
  audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan", sessionId },
656
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: "gjc-runtime", command: "gjc ralplan lane-verdict", sessionId },
1090
+ audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan", sessionId },
1091
+ });
1092
+ return true;
657
1093
  },
658
1094
  { cwd },
659
1095
  );
@@ -804,28 +1240,19 @@ interface ExistingStageArtifact {
804
1240
  }
805
1241
 
806
1242
  /**
807
- * Find the most recent `index.jsonl` row for a `(stage, stage_n)` pair so a
808
- * repeated `--write` can dedupe instead of silently clobbering the artifact and
809
- * appending a duplicate ledger row. Best-effort: a missing or unreadable index
810
- * yields `undefined`, treated as "no prior artifact". The ledger is the source of
811
- * truth for dedup because it is exactly what a duplicate write would corrupt.
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.
812
1247
  */
813
- async function findExistingStageArtifact(
814
- cwd: string,
815
- sessionId: string,
816
- runId: string,
1248
+ function findExistingStageArtifact(
1249
+ indexText: string | undefined,
817
1250
  stage: RalplanStage,
818
1251
  stageN: number,
819
- ): Promise<ExistingStageArtifact | undefined> {
820
- const indexPath = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId, "index.jsonl");
821
- let text: string;
822
- try {
823
- text = await fs.readFile(indexPath, "utf8");
824
- } catch {
825
- return undefined;
826
- }
1252
+ ): ExistingStageArtifact | undefined {
1253
+ if (indexText === undefined) return undefined;
827
1254
  let match: ExistingStageArtifact | undefined;
828
- for (const line of text.split(/\r?\n/)) {
1255
+ for (const line of indexText.split(/\r?\n/)) {
829
1256
  const trimmed = line.trim();
830
1257
  if (!trimmed) continue;
831
1258
  let row: unknown;
@@ -847,6 +1274,124 @@ async function findExistingStageArtifact(
847
1274
  return match;
848
1275
  }
849
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
+
850
1395
  /**
851
1396
  * Read and parse the run's `index.jsonl` rows. Best-effort: returns [] when the
852
1397
  * file is absent or unreadable so HUD sync never fails on a missing index.
@@ -866,6 +1411,18 @@ async function readRalplanIndexRows(cwd: string, sessionId: string, runId: strin
866
1411
  }
867
1412
  }
868
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
+
869
1426
  async function syncRalplanHud(options: {
870
1427
  cwd: string;
871
1428
  sessionId: string;
@@ -873,6 +1430,7 @@ async function syncRalplanHud(options: {
873
1430
  pendingApproval: boolean;
874
1431
  iteration?: number;
875
1432
  runId?: string;
1433
+ reviewPassBudget?: number;
876
1434
  latestSummary?: string;
877
1435
  }): Promise<void> {
878
1436
  try {
@@ -898,15 +1456,25 @@ async function buildRalplanHud(options: {
898
1456
  latestSummary?: string;
899
1457
  runId?: string;
900
1458
  sessionId?: string;
1459
+ reviewPassBudget?: number;
901
1460
  }) {
902
1461
  let iterationFromIndex: number | undefined;
903
1462
  let stages: string | undefined;
1463
+ let architectPasses: number | undefined;
1464
+ let criticPasses: number | undefined;
1465
+ let verdict: string | undefined;
904
1466
  if (options.runId && options.sessionId) {
905
- 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;
906
1472
  if (rows.length > 0) {
907
1473
  const summary = summarizeRalplanIndex(rows);
908
1474
  iterationFromIndex = summary.iteration;
909
1475
  stages = formatRalplanStagePresence(summary.currentStages);
1476
+ architectPasses = summary.currentStages.filter(stage => stage === "architect").length;
1477
+ criticPasses = summary.currentStages.filter(stage => stage === "critic").length;
910
1478
  }
911
1479
  }
912
1480
  return buildRalplanHudSummary({
@@ -914,6 +1482,10 @@ async function buildRalplanHud(options: {
914
1482
  iteration: options.iteration,
915
1483
  iterationFromIndex,
916
1484
  stages,
1485
+ architectPasses,
1486
+ criticPasses,
1487
+ reviewPassBudget: options.reviewPassBudget,
1488
+ verdict,
917
1489
  pendingApproval: options.pendingApproval,
918
1490
  latestSummary: options.latestSummary,
919
1491
  updatedAt: new Date().toISOString(),
@@ -921,8 +1493,9 @@ async function buildRalplanHud(options: {
921
1493
  }
922
1494
 
923
1495
  async function handleArtifactWrite(args: readonly string[], cwd: string): Promise<RalplanCommandResult> {
924
- const plannerState = parsePlannerStateArgs(args);
925
1496
  const resolved = await resolveArtifactArgs(args, cwd);
1497
+ const persistedRoleState = parsePersistedRoleStateArgs(args, resolved.stage);
1498
+ const laneVerdict = parseLaneVerdictArgs(args, resolved.stage, resolved.stageN);
926
1499
  // Fail closed before stage persistence / path writes when cwd drifted to a sibling repo.
927
1500
  const repositoryBinding = await enforceRalplanRepositoryBinding(cwd, resolved.sessionId);
928
1501
  // Artifact file paths (when --artifact points at a file) must stay under the bound root.
@@ -944,16 +1517,15 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
944
1517
  const content = resolved.artifact.endsWith("\n") ? resolved.artifact : `${resolved.artifact}\n`;
945
1518
  const sha256 = createHash("sha256").update(content).digest("hex");
946
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
+
947
1525
  // Duplicate-write guard: a second `--write` for the same (stage, stage_n) must not
948
1526
  // silently clobber the artifact or append a duplicate ledger row. Classify before any
949
1527
  // state mutation so a conflict never regresses run-state phase.
950
- const existingArtifact = await findExistingStageArtifact(
951
- cwd,
952
- resolved.sessionId,
953
- resolved.runId,
954
- resolved.stage,
955
- resolved.stageN,
956
- );
1528
+ const existingArtifact = findExistingStageArtifact(indexLoad.rawText, resolved.stage, resolved.stageN);
957
1529
  if (existingArtifact) {
958
1530
  if (existingArtifact.sha256 !== sha256) {
959
1531
  throw new RalplanCommandError(
@@ -961,21 +1533,65 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
961
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.`,
962
1534
  );
963
1535
  }
1536
+ if (resolved.stage === "final") await ensureFinalPendingApproval(cwd, resolved, existingArtifact);
964
1537
  return buildDeduplicatedResult(resolved, existingArtifact, sha256, cwd, repositoryBinding);
965
1538
  }
966
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
+
967
1575
  // Consensus iteration budget (#3165): refuse new planner/revision openers past the cap.
968
1576
  // Dedupe returns above so identical re-writes never stuck-signal. Non-openers (architect,
969
1577
  // critic, final, …) remain allowed so operators can escalate without auto-implementation.
970
1578
  // On-disk opener artifacts floor the count so a wiped/truncated/malformed index.jsonl cannot
971
1579
  // under-count and fail open after prior planner/revision writes.
972
- const indexLoad = await loadRalplanIndexForCap(cwd, resolved.sessionId, resolved.runId);
973
- const onDiskOpeners = await countRalplanOnDiskOpeners(cwd, resolved.sessionId, resolved.runId);
974
- const { maxIterations, source: maxIterationsSource } = await resolveRalplanMaxIterations(cwd);
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
+ ]);
975
1591
  const capDecision = evaluateRalplanIterationCap({
976
1592
  rows: indexLoad.rows,
977
1593
  stage: resolved.stage,
978
- maxIterations,
1594
+ maxIterations: iterationLimit.maxIterations,
979
1595
  iterationFloor: onDiskOpeners,
980
1596
  });
981
1597
  if (!capDecision.allowed) {
@@ -985,15 +1601,34 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
985
1601
  stageN: resolved.stageN,
986
1602
  runId: resolved.runId,
987
1603
  decision: capDecision,
988
- source: maxIterationsSource,
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,
989
1621
  });
990
1622
  }
991
1623
 
992
1624
  // Keep run-state `current_phase` coherent with the stage being persisted.
993
1625
  await persistActiveRunId(cwd, resolved.sessionId, resolved.runId, resolved.stage);
994
1626
  const persisted = await persistArtifact(resolved, cwd, content, sha256);
995
- if (plannerState) {
996
- 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);
997
1632
  }
998
1633
  await writeSessionActivityMarker(cwd, resolved.sessionId, { writer: "ralplan-runtime", path: persisted.path });
999
1634
  await syncRalplanHud({
@@ -1003,8 +1638,18 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1003
1638
  runId: persisted.runId,
1004
1639
  pendingApproval: persisted.stage === "final",
1005
1640
  iteration: persisted.stageN,
1641
+ reviewPassBudget: laneLimit.maxReviewPassesPerLane,
1006
1642
  latestSummary: `persisted ${persisted.stage} stage ${persisted.stageN}`,
1007
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
+
1008
1653
  const payload: Record<string, unknown> = {
1009
1654
  run_id: persisted.runId,
1010
1655
  path: persisted.path,
@@ -1015,17 +1660,20 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1015
1660
  created_at: persisted.createdAt,
1016
1661
  };
1017
1662
  if (persisted.pendingApprovalPath) payload.pending_approval_path = persisted.pendingApprovalPath;
1018
- 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
+
1019
1667
  const stdout = resolved.json
1020
1668
  ? `${JSON.stringify(payload, null, 2)}\n`
1021
- : `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`;
1022
1670
  return { status: 0, stdout };
1023
1671
  }
1024
1672
 
1025
1673
  /**
1026
- * Deterministic no-op receipt for an identical repeated `--write`: report the
1027
- * already-persisted artifact without rewriting the file, appending a ledger row, or
1028
- * churning run-state. `deduplicated: true` lets callers distinguish it from a fresh write.
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.
1029
1677
  */
1030
1678
  function buildDeduplicatedResult(
1031
1679
  resolved: ResolvedArtifactArgs,
@@ -1033,6 +1681,8 @@ function buildDeduplicatedResult(
1033
1681
  sha256: string,
1034
1682
  cwd: string,
1035
1683
  repositoryBinding: RepositoryBinding,
1684
+ laneVerdict?: LaneVerdictUpdate,
1685
+ persistedRoleState?: PersistedRoleStateUpdate,
1036
1686
  ): RalplanCommandResult {
1037
1687
  const payload: Record<string, unknown> = {
1038
1688
  run_id: resolved.runId,
@@ -1044,6 +1694,10 @@ function buildDeduplicatedResult(
1044
1694
  created_at: existing.createdAt,
1045
1695
  deduplicated: true,
1046
1696
  };
1697
+ if (laneVerdict) payload.lane_verdict = { lane: laneVerdict.lane, verdict: laneVerdict.verdict };
1698
+ if (persistedRoleState) {
1699
+ payload[`${persistedRoleState.role}_state`] = persistedRoleStatePayload(persistedRoleState);
1700
+ }
1047
1701
  if (resolved.stage === "final") {
1048
1702
  payload.pending_approval_path = path.join(
1049
1703
  sessionPlansDir(cwd, resolved.sessionId),
@@ -1092,6 +1746,9 @@ function extractPositionalTask(args: readonly string[]): string {
1092
1746
  }
1093
1747
 
1094
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
+ }
1095
1752
  const architectKind = flagValue(args, "--architect")?.trim() || undefined;
1096
1753
  if (architectKind && !KNOWN_ARCHITECT_KINDS.has(architectKind)) {
1097
1754
  throw new RalplanCommandError(