@gajae-code/coding-agent 0.12.0 → 0.12.2

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 (247) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/config/settings-schema.d.ts +56 -2
  4. package/dist/types/exa/mcp-client.d.ts +10 -1
  5. package/dist/types/exec/bash-executor.d.ts +45 -12
  6. package/dist/types/extensibility/extensions/types.d.ts +16 -2
  7. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  8. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  9. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  10. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  11. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  12. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  13. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  15. package/dist/types/gjc-runtime/ralplan-review-conflicts.d.ts +114 -0
  16. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +23 -3
  17. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  18. package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
  19. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  20. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
  21. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +37 -80
  22. package/dist/types/modes/acp/acp-agent.d.ts +2 -19
  23. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  24. package/dist/types/modes/components/irc-sidebar.d.ts +16 -0
  25. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  26. package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
  27. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  28. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  29. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  30. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  31. package/dist/types/modes/controllers/event-controller.d.ts +1 -1
  32. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  33. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  34. package/dist/types/modes/interactive-mode.d.ts +9 -0
  35. package/dist/types/modes/theme/theme.d.ts +3 -1
  36. package/dist/types/modes/types.d.ts +32 -0
  37. package/dist/types/reminders/star-reminder.d.ts +3 -13
  38. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  39. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  40. package/dist/types/sdk/broker/transport.d.ts +2 -0
  41. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -1
  43. package/dist/types/sdk/bus/config.d.ts +6 -2
  44. package/dist/types/sdk/bus/index.d.ts +29 -0
  45. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
  46. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  47. package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
  48. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  49. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  50. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  51. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  52. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -4
  53. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  54. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  55. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  56. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  57. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  58. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  59. package/dist/types/sdk/prompt-status.d.ts +23 -3
  60. package/dist/types/sdk/session.d.ts +6 -0
  61. package/dist/types/session/agent-session.d.ts +40 -2
  62. package/dist/types/session/artifacts.d.ts +8 -0
  63. package/dist/types/session/blob-store.d.ts +57 -4
  64. package/dist/types/session/internal/managed-session-storage.d.ts +30 -4
  65. package/dist/types/session/session-manager.d.ts +23 -6
  66. package/dist/types/session/session-storage.d.ts +4 -0
  67. package/dist/types/session/streaming-output.d.ts +36 -7
  68. package/dist/types/setup/credential-import.d.ts +12 -0
  69. package/dist/types/skill-state/active-state.d.ts +12 -0
  70. package/dist/types/skill-state/workflow-hud.d.ts +6 -0
  71. package/dist/types/task/executor.d.ts +3 -2
  72. package/dist/types/task/index.d.ts +7 -1
  73. package/dist/types/task/output-manager.d.ts +1 -0
  74. package/dist/types/task/provider-retry-status.d.ts +8 -0
  75. package/dist/types/task/receipt.d.ts +9 -1
  76. package/dist/types/task/types.d.ts +23 -0
  77. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  78. package/dist/types/tools/bash-interactive.d.ts +4 -1
  79. package/dist/types/tools/bash.d.ts +3 -1
  80. package/dist/types/tools/image-gen.d.ts +4 -0
  81. package/dist/types/tools/index.d.ts +6 -0
  82. package/dist/types/tools/output-meta.d.ts +23 -2
  83. package/dist/types/tools/path-utils.d.ts +1 -0
  84. package/dist/types/tools/read.d.ts +0 -6
  85. package/dist/types/tools/review.d.ts +5 -7
  86. package/dist/types/tools/subagent.d.ts +2 -0
  87. package/dist/types/utils/gh.d.ts +13 -0
  88. package/dist/types/utils/image-resize.d.ts +1 -0
  89. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  90. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  91. package/dist/types/web/search/providers/xai.d.ts +2 -0
  92. package/package.json +10 -8
  93. package/scripts/acp-conformance-agent.ts +218 -0
  94. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  95. package/scripts/dogfood-ralplan-review-conflicts.ts +272 -0
  96. package/scripts/generate-sdk-operation-inventory.ts +5 -0
  97. package/scripts/resident-memory-bench.ts +1341 -0
  98. package/scripts/run-acp-conformance.ts +290 -0
  99. package/src/async/job-manager.ts +99 -39
  100. package/src/cli/fast-help.ts +1 -1
  101. package/src/cli/plugin-cli.ts +176 -18
  102. package/src/commands/team.ts +19 -1
  103. package/src/config/file-lock.ts +11 -2
  104. package/src/config/settings-schema.ts +53 -2
  105. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  106. package/src/coordinator-mcp/server.ts +5 -12
  107. package/src/debug/index.ts +42 -17
  108. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
  109. package/src/defaults/gjc/skills/ralplan/SKILL.md +25 -20
  110. package/src/defaults/gjc/skills/team/SKILL.md +2 -0
  111. package/src/defaults/gjc/skills/ultragoal/SKILL.md +39 -26
  112. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  113. package/src/defaults/gjc-defaults.ts +4 -4
  114. package/src/exa/mcp-client.ts +12 -3
  115. package/src/exec/bash-executor.ts +144 -27
  116. package/src/extensibility/extensions/runner.ts +15 -0
  117. package/src/extensibility/extensions/types.ts +18 -1
  118. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  119. package/src/extensibility/gjc-plugins/index.ts +19 -2
  120. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  121. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  122. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  123. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  124. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  125. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  126. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  127. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  128. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  129. package/src/extensibility/gjc-plugins/types.ts +141 -0
  130. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  131. package/src/gjc-runtime/launch-tmux.ts +25 -6
  132. package/src/gjc-runtime/launch-worktree.ts +103 -1
  133. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  134. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  135. package/src/gjc-runtime/ralplan-review-conflicts.ts +472 -0
  136. package/src/gjc-runtime/ralplan-runtime.ts +491 -27
  137. package/src/gjc-runtime/team-launch.ts +8 -0
  138. package/src/gjc-runtime/team-runtime.ts +140 -32
  139. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  140. package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
  141. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  142. package/src/gjc-runtime/ultragoal-evidence.ts +6 -74
  143. package/src/gjc-runtime/ultragoal-runtime.ts +635 -612
  144. package/src/gjc-runtime/workflow-manifest.generated.json +37 -99
  145. package/src/gjc-runtime/workflow-manifest.ts +21 -70
  146. package/src/internal-urls/docs-index.generated.ts +16 -16
  147. package/src/modes/DESIGN.md +104 -0
  148. package/src/modes/acp/acp-agent.ts +298 -186
  149. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  150. package/src/modes/components/irc-sidebar.ts +30 -1
  151. package/src/modes/components/notifications-settings-editor.ts +12 -1
  152. package/src/modes/components/oauth-selector.ts +68 -30
  153. package/src/modes/components/session-observer-overlay.ts +305 -86
  154. package/src/modes/components/settings-selector.ts +56 -5
  155. package/src/modes/components/status-line/gh.ts +16 -0
  156. package/src/modes/components/tool-status-header.ts +3 -15
  157. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  158. package/src/modes/controllers/command-controller.ts +42 -34
  159. package/src/modes/controllers/event-controller.ts +27 -16
  160. package/src/modes/controllers/extension-ui-controller.ts +193 -34
  161. package/src/modes/controllers/selector-controller.ts +160 -22
  162. package/src/modes/interactive-mode.ts +189 -62
  163. package/src/modes/jobs-observer.ts +1 -1
  164. package/src/modes/runtime-init.ts +3 -0
  165. package/src/modes/theme/theme.ts +11 -9
  166. package/src/modes/types.ts +88 -0
  167. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  168. package/src/prompts/system/eager-todo.md +8 -4
  169. package/src/prompts/system/system-prompt.md +11 -4
  170. package/src/prompts/tools/bash.md +5 -5
  171. package/src/prompts/tools/search-tool-bm25.md +5 -0
  172. package/src/prompts/tools/task.md +1 -1
  173. package/src/reminders/star-reminder.ts +4 -42
  174. package/src/runtime-mcp/manager.ts +19 -4
  175. package/src/sdk/acp/adapter.ts +22 -6
  176. package/src/sdk/broker/lifecycle.ts +18 -13
  177. package/src/sdk/broker/transport.ts +35 -3
  178. package/src/sdk/bus/attachment-registry.ts +5 -1
  179. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  180. package/src/sdk/bus/chat-daemon-control.ts +7 -3
  181. package/src/sdk/bus/config.ts +18 -2
  182. package/src/sdk/bus/index.ts +661 -126
  183. package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
  184. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  185. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  186. package/src/sdk/bus/reconciliation-store.ts +95 -16
  187. package/src/sdk/bus/rich-draft.ts +1 -1
  188. package/src/sdk/bus/rich-render.ts +2 -2
  189. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  190. package/src/sdk/bus/telegram-cli.ts +33 -8
  191. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  192. package/src/sdk/bus/telegram-daemon-contract.ts +12 -4
  193. package/src/sdk/bus/telegram-daemon.ts +916 -23
  194. package/src/sdk/bus/telegram-reference.ts +40 -3
  195. package/src/sdk/bus/threaded-render.ts +48 -3
  196. package/src/sdk/bus/topic-registry.ts +20 -0
  197. package/src/sdk/host/query/revision-store.ts +36 -2
  198. package/src/sdk/host/reverse-leases.ts +44 -7
  199. package/src/sdk/prompt-status.ts +16 -3
  200. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  201. package/src/sdk/session.ts +89 -9
  202. package/src/session/agent-session.ts +844 -173
  203. package/src/session/artifacts.ts +22 -0
  204. package/src/session/auth-broker-config.ts +10 -3
  205. package/src/session/blob-store.ts +915 -24
  206. package/src/session/internal/managed-session-scope.ts +200 -111
  207. package/src/session/internal/managed-session-storage.ts +492 -62
  208. package/src/session/session-manager.ts +1614 -435
  209. package/src/session/session-storage.ts +38 -45
  210. package/src/session/streaming-output.ts +238 -68
  211. package/src/setup/credential-import.ts +75 -13
  212. package/src/skill-state/active-state.ts +32 -0
  213. package/src/skill-state/workflow-hud.ts +17 -0
  214. package/src/skill-state/workflow-mutation-guard.ts +496 -2
  215. package/src/task/executor.ts +122 -39
  216. package/src/task/index.ts +280 -45
  217. package/src/task/output-manager.ts +24 -14
  218. package/src/task/provider-retry-status.ts +16 -0
  219. package/src/task/receipt.ts +34 -8
  220. package/src/task/render.ts +6 -2
  221. package/src/task/types.ts +108 -0
  222. package/src/tools/ask-answer-registry.ts +12 -6
  223. package/src/tools/ask.ts +86 -55
  224. package/src/tools/ast-edit.ts +1 -0
  225. package/src/tools/ast-grep.ts +1 -0
  226. package/src/tools/bash-interactive.ts +8 -3
  227. package/src/tools/bash.ts +503 -60
  228. package/src/tools/find.ts +1 -0
  229. package/src/tools/image-gen.ts +29 -5
  230. package/src/tools/index.ts +6 -0
  231. package/src/tools/output-meta.ts +108 -14
  232. package/src/tools/path-utils.ts +3 -2
  233. package/src/tools/read.ts +18 -0
  234. package/src/tools/review.ts +5 -7
  235. package/src/tools/search.ts +1 -0
  236. package/src/tools/subagent-render.ts +6 -2
  237. package/src/tools/subagent.ts +6 -0
  238. package/src/tools/telegram-send.ts +6 -3
  239. package/src/utils/gh.ts +39 -0
  240. package/src/utils/image-resize.ts +3 -0
  241. package/src/web/search/providers/anthropic.ts +4 -4
  242. package/src/web/search/providers/kimi.ts +15 -5
  243. package/src/web/search/providers/searxng.ts +47 -4
  244. package/src/web/search/providers/xai.ts +7 -2
  245. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  246. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  247. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
+ import type { Dirent } from "node:fs";
2
3
  import * as fs from "node:fs/promises";
3
4
  import * as path from "node:path";
4
5
  import { getConfigRootDir } from "@gajae-code/utils";
@@ -12,6 +13,12 @@ import {
12
13
  type RalplanIndexRow,
13
14
  summarizeRalplanIndex,
14
15
  } from "./ledger-event-renderer";
16
+ import {
17
+ type IndexedReviewArtifact,
18
+ parseReviewConflictDocument,
19
+ reviewArtifactIndexKey,
20
+ serializeReviewConflictDocument,
21
+ } from "./ralplan-review-conflicts";
15
22
  import {
16
23
  assertCwdMatchesRepositoryBinding,
17
24
  assertPathUnderRepositoryBinding,
@@ -22,7 +29,7 @@ import {
22
29
  RepositoryBindingError,
23
30
  } from "./repository-binding";
24
31
  import { GJC_RALPLAN_ARTIFACT_ENV, isRestrictedRoleAgentBash } from "./restricted-role-agent-bash";
25
- import { gjcRoot, modeStatePath, sessionPlansDir } from "./session-layout";
32
+ import { gjcRoot, modeStatePath, sessionIdFromDirName, sessionPlansDir } from "./session-layout";
26
33
  import { resolveGjcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
27
34
  import { migrateWorkflowState } from "./state-migrations";
28
35
  import { runNativeStateCommand } from "./state-runtime";
@@ -33,9 +40,9 @@ import {
33
40
  writeArtifact,
34
41
  writeWorkflowEnvelopeAtomic,
35
42
  } from "./state-writer";
43
+ import { probeGjcTeamAvailability } from "./team-runtime";
36
44
  import { assertSafePathComponent, CommandError, flagValue, hasFlag } from "./workflow-cli-common";
37
45
  import { getSkillManifest } from "./workflow-manifest";
38
-
39
46
  /**
40
47
  * Native implementation of `gjc ralplan`.
41
48
  *
@@ -51,9 +58,11 @@ import { getSkillManifest } from "./workflow-manifest";
51
58
  * 2. **Artifact write**: `gjc ralplan --write --stage <type> --stage_n <N>
52
59
  * (--artifact <path-or-string> | --artifact-env GJC_RALPLAN_ARTIFACT)
53
60
  * [--run-id <id>] [--session-id <id>] [--lane-verdict <token>] [--json]` persists Planner / Architect
54
- * / Critic / revision / post-interview / ADR / final markdown under `.gjc/plans/ralplan/<run-id>/`, maintains
55
- * an `index.jsonl` audit log, copies `final` stages to `pending-approval.md`, and advances
56
- * the HUD chip to reflect the latest persisted stage.
61
+ * / Critic / disposition / revision / post-interview / ADR / final artifacts under
62
+ * `.gjc/plans/ralplan/<run-id>/`, maintains an `index.jsonl` audit log, copies `final`
63
+ * stages to `pending-approval.md`, and advances the HUD chip to reflect the latest
64
+ * persisted stage. Disposition stage artifacts are fail-closed JSON documents that
65
+ * record typed review conflicts with authoritative same-pass source receipts (#2902).
57
66
  */
58
67
 
59
68
  export interface RalplanCommandResult {
@@ -62,7 +71,16 @@ export interface RalplanCommandResult {
62
71
  stderr?: string;
63
72
  }
64
73
 
65
- const KNOWN_STAGES = ["planner", "architect", "critic", "revision", "post-interview", "adr", "final"] as const;
74
+ const KNOWN_STAGES = [
75
+ "planner",
76
+ "architect",
77
+ "critic",
78
+ "disposition",
79
+ "revision",
80
+ "post-interview",
81
+ "adr",
82
+ "final",
83
+ ] as const;
66
84
  type RalplanStage = (typeof KNOWN_STAGES)[number];
67
85
  /** Default consensus iterations (planner + revision openers) per run. Matches SKILL.md re-review cap. */
68
86
  export const RALPLAN_DEFAULT_MAX_ITERATIONS = 5;
@@ -74,6 +92,16 @@ export const PLANNING_STUCK_MARKER = "PLANNING-STUCK";
74
92
  export const RALPLAN_DEFAULT_MAX_REVIEW_PASSES_PER_LANE = 1;
75
93
  /** Inclusive upper bound for `gjc.ralplan.maxReviewPassesPerLane` settings overrides. */
76
94
  export const RALPLAN_MAX_REVIEW_PASSES_PER_LANE_LIMIT = 10;
95
+ export type RalplanAutoHandoffTarget = "off" | "ultragoal" | "team";
96
+
97
+ export interface RalplanAutoHandoffResolution {
98
+ configuredTarget: RalplanAutoHandoffTarget;
99
+ effectiveTarget: RalplanAutoHandoffTarget;
100
+ degradationReason: string | null;
101
+ source: string;
102
+ }
103
+
104
+ const RALPLAN_AUTO_HANDOFF_TARGETS = new Set<RalplanAutoHandoffTarget>(["off", "ultragoal", "team"]);
77
105
 
78
106
  const RALPLAN_ITERATION_OPENER_STAGES = new Set<RalplanStage>(["planner", "revision"]);
79
107
 
@@ -391,6 +419,115 @@ export async function resolveRalplanMaxIterations(cwd: string): Promise<{ maxIte
391
419
  if (user !== null) return { maxIterations: user, source: userPath };
392
420
  return { maxIterations: RALPLAN_DEFAULT_MAX_ITERATIONS, source: "default" };
393
421
  }
422
+ function parseRalplanAutoHandoffTarget(value: unknown): RalplanAutoHandoffTarget | undefined {
423
+ return typeof value === "string" && RALPLAN_AUTO_HANDOFF_TARGETS.has(value as RalplanAutoHandoffTarget)
424
+ ? (value as RalplanAutoHandoffTarget)
425
+ : undefined;
426
+ }
427
+
428
+ type RalplanAutoHandoffOptions = {
429
+ planningStuck?: boolean;
430
+ teamAvailabilityProbe?: () => { available: true } | { available: false; reason: string };
431
+ };
432
+
433
+ type RalplanAutoHandoffSetting =
434
+ | { kind: "absent" }
435
+ | { kind: "valid"; value: RalplanAutoHandoffTarget }
436
+ | { kind: "invalid"; reason: string };
437
+
438
+ function parsePresentRalplanAutoHandoff(value: unknown): RalplanAutoHandoffSetting {
439
+ const target = parseRalplanAutoHandoffTarget(value);
440
+ return target === undefined
441
+ ? {
442
+ kind: "invalid",
443
+ reason: "expected gjc.ralplan.autoHandoff to be one of off, ultragoal, team",
444
+ }
445
+ : { kind: "valid", value: target };
446
+ }
447
+
448
+ function parseRalplanAutoHandoffSettings(parsed: unknown): RalplanAutoHandoffSetting {
449
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { kind: "absent" };
450
+ const settings = parsed as Record<string, unknown>;
451
+ if (Object.hasOwn(settings, "gjc.ralplan.autoHandoff")) {
452
+ return parsePresentRalplanAutoHandoff(settings["gjc.ralplan.autoHandoff"]);
453
+ }
454
+ const gjc = settings.gjc;
455
+ if (!gjc || typeof gjc !== "object" || Array.isArray(gjc)) return { kind: "absent" };
456
+ const ralplan = (gjc as Record<string, unknown>).ralplan;
457
+ if (!ralplan || typeof ralplan !== "object" || Array.isArray(ralplan)) return { kind: "absent" };
458
+ const ralplanSettings = ralplan as Record<string, unknown>;
459
+ if (!Object.hasOwn(ralplanSettings, "autoHandoff")) return { kind: "absent" };
460
+ return parsePresentRalplanAutoHandoff(ralplanSettings.autoHandoff);
461
+ }
462
+
463
+ async function readSettingsAutoHandoff(settingsPath: string): Promise<RalplanAutoHandoffSetting> {
464
+ let raw: string;
465
+ try {
466
+ raw = await Bun.file(settingsPath).text();
467
+ } catch (error) {
468
+ if (getErrorCode(error) === "ENOENT") return { kind: "absent" };
469
+ return {
470
+ kind: "invalid",
471
+ reason: `unable to read settings: ${error instanceof Error ? error.message : String(error)}`,
472
+ };
473
+ }
474
+ let parsed: unknown;
475
+ try {
476
+ parsed = JSON.parse(raw);
477
+ } catch (error) {
478
+ return {
479
+ kind: "invalid",
480
+ reason: `malformed JSON: ${error instanceof Error ? error.message : String(error)}`,
481
+ };
482
+ }
483
+ return parseRalplanAutoHandoffSettings(parsed);
484
+ }
485
+
486
+ export async function resolveRalplanAutoHandoff(
487
+ cwd: string,
488
+ options: RalplanAutoHandoffOptions = {},
489
+ ): Promise<RalplanAutoHandoffResolution> {
490
+ const projectPath = path.join(gjcRoot(cwd), "settings.json");
491
+ const project = await readSettingsAutoHandoff(projectPath);
492
+ if (project.kind === "invalid") {
493
+ throw new RalplanCommandError(2, `invalid ralplan settings at ${projectPath}: ${project.reason}`);
494
+ }
495
+ if (project.kind === "valid") {
496
+ return resolveRalplanAutoHandoffTarget(project.value, projectPath, options);
497
+ }
498
+ const userPath = path.join(getConfigRootDir(), "settings.json");
499
+ const user = await readSettingsAutoHandoff(userPath);
500
+ if (user.kind === "invalid") {
501
+ throw new RalplanCommandError(2, `invalid ralplan settings at ${userPath}: ${user.reason}`);
502
+ }
503
+ return resolveRalplanAutoHandoffTarget(
504
+ user.kind === "valid" ? user.value : "off",
505
+ user.kind === "valid" ? userPath : "default",
506
+ options,
507
+ );
508
+ }
509
+
510
+ function resolveRalplanAutoHandoffTarget(
511
+ configuredTarget: RalplanAutoHandoffTarget,
512
+ source: string,
513
+ options: RalplanAutoHandoffOptions,
514
+ ): RalplanAutoHandoffResolution {
515
+ if (options.planningStuck) {
516
+ return { configuredTarget, effectiveTarget: "off", degradationReason: "planning_stuck", source };
517
+ }
518
+ if (configuredTarget !== "team")
519
+ return { configuredTarget, effectiveTarget: configuredTarget, degradationReason: null, source };
520
+
521
+ const availability = (options.teamAvailabilityProbe ?? probeGjcTeamAvailability)();
522
+ return availability.available
523
+ ? { configuredTarget, effectiveTarget: "team", degradationReason: null, source }
524
+ : {
525
+ configuredTarget,
526
+ effectiveTarget: "off",
527
+ degradationReason: `team_unavailable:${availability.reason}`,
528
+ source,
529
+ };
530
+ }
394
531
 
395
532
  function parseMaxReviewPassesPerLaneValue(value: unknown): number | null {
396
533
  return parseBoundedPositiveInteger(value, RALPLAN_MAX_REVIEW_PASSES_PER_LANE_LIMIT);
@@ -763,6 +900,8 @@ async function persistActiveRunId(cwd: string, sessionId: string, runId: string,
763
900
  for (const key of Object.keys(existing)) {
764
901
  if (key.startsWith("last_review_verdict")) delete existing[key];
765
902
  }
903
+ delete existing.planning_stuck;
904
+ delete existing.auto_handoff;
766
905
  }
767
906
  if (
768
907
  existing.run_id === runId &&
@@ -1094,6 +1233,194 @@ async function applyLaneVerdictUpdate(
1094
1233
  { cwd },
1095
1234
  );
1096
1235
  }
1236
+ function ralplanPlanningStuckIndexKey(entry: unknown): string | undefined {
1237
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
1238
+ const record = entry as Record<string, unknown>;
1239
+ return record.planning_stuck === true ? "planning_stuck" : undefined;
1240
+ }
1241
+
1242
+ async function recordRalplanPlanningStuck(
1243
+ cwd: string,
1244
+ sessionId: string,
1245
+ runId: string,
1246
+ reason: string,
1247
+ ): Promise<void> {
1248
+ const runDir = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId);
1249
+ await appendJsonlIdempotent(
1250
+ path.join(runDir, "index.jsonl"),
1251
+ {
1252
+ event: "planning_stuck",
1253
+ planning_stuck: true,
1254
+ marker: PLANNING_STUCK_MARKER,
1255
+ reason,
1256
+ created_at: new Date().toISOString(),
1257
+ },
1258
+ {
1259
+ cwd,
1260
+ audit: {
1261
+ category: "ledger",
1262
+ verb: "append",
1263
+ owner: "gjc-runtime",
1264
+ skill: "ralplan",
1265
+ sessionId,
1266
+ },
1267
+ key: ralplanPlanningStuckIndexKey,
1268
+ },
1269
+ );
1270
+ const statePath = ralplanStatePath(cwd, sessionId);
1271
+ await withWorkflowStateLock(
1272
+ statePath,
1273
+ async () => {
1274
+ const existingRead = await readExistingStateForMutation(statePath);
1275
+ if (existingRead.kind === "corrupt") return;
1276
+ let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
1277
+ if (existing.run_id !== runId) return;
1278
+ existing.planning_stuck = { marker: PLANNING_STUCK_MARKER, reason };
1279
+ existing = migrateWorkflowState(existing, "ralplan").state;
1280
+ existing.updated_at = new Date().toISOString();
1281
+ await writeWorkflowEnvelopeAtomic(statePath, existing, {
1282
+ cwd,
1283
+ lockHeld: true,
1284
+ receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan planning-stuck", sessionId },
1285
+ audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan", sessionId },
1286
+ });
1287
+ },
1288
+ { cwd },
1289
+ );
1290
+ }
1291
+
1292
+ async function readRalplanFinalAdmission(
1293
+ cwd: string,
1294
+ sessionId: string,
1295
+ runId: string,
1296
+ ): Promise<RalplanAutoHandoffResolution | undefined> {
1297
+ const index = await loadRalplanIndexForCap(cwd, sessionId, runId);
1298
+ if (index.rawText === undefined) return undefined;
1299
+ let finalAdmission: RalplanAutoHandoffResolution | undefined;
1300
+ for (const line of index.rawText.split(/\r?\n/)) {
1301
+ try {
1302
+ const row = JSON.parse(line) as Record<string, unknown>;
1303
+ if (row.stage === "final" && typeof row.path === "string" && typeof row.sha256 === "string") {
1304
+ finalAdmission = parseRalplanFinalAdmission(row.auto_handoff) ?? unavailableRalplanFinalAdmission();
1305
+ }
1306
+ } catch {
1307
+ // The artifact dedupe guard handles malformed ledger records separately.
1308
+ }
1309
+ }
1310
+ return finalAdmission;
1311
+ }
1312
+
1313
+ async function readRalplanPlanningStuck(cwd: string, sessionId: string, runId: string): Promise<boolean> {
1314
+ const index = await loadRalplanIndexForCap(cwd, sessionId, runId);
1315
+ if (index.rawText === undefined) return index.indexPresent;
1316
+ if (index.indexPresent && index.rawLineCount > 0 && index.parseableLines === 0) return true;
1317
+ for (const line of index.rawText.split(/\r?\n/)) {
1318
+ if (!line.trim()) continue;
1319
+ try {
1320
+ const row = JSON.parse(line) as Record<string, unknown>;
1321
+ if (row.planning_stuck === true) return true;
1322
+ } catch {
1323
+ return true;
1324
+ }
1325
+ }
1326
+ return false;
1327
+ }
1328
+
1329
+ async function persistRalplanFinalAdmission(
1330
+ cwd: string,
1331
+ sessionId: string,
1332
+ runId: string,
1333
+ admission: RalplanAutoHandoffResolution,
1334
+ ): Promise<void> {
1335
+ const statePath = ralplanStatePath(cwd, sessionId);
1336
+ await withWorkflowStateLock(
1337
+ statePath,
1338
+ async () => {
1339
+ const existingRead = await readExistingStateForMutation(statePath);
1340
+ if (existingRead.kind === "corrupt") {
1341
+ throw new RalplanCommandError(
1342
+ 2,
1343
+ `existing ralplan state is corrupt or tampered (${existingRead.error}); refusing to record final admission`,
1344
+ );
1345
+ }
1346
+ let existing: Record<string, unknown> = existingRead.kind === "valid" ? existingRead.value : {};
1347
+ if (existing.run_id !== runId) return;
1348
+ existing.auto_handoff = admission;
1349
+ existing = migrateWorkflowState(existing, "ralplan").state;
1350
+ existing.updated_at = new Date().toISOString();
1351
+ await writeWorkflowEnvelopeAtomic(statePath, existing, {
1352
+ cwd,
1353
+ lockHeld: true,
1354
+ receipt: { cwd, skill: "ralplan", owner: "gjc-runtime", command: "gjc ralplan final-admission", sessionId },
1355
+ audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "ralplan", sessionId },
1356
+ });
1357
+ },
1358
+ { cwd },
1359
+ );
1360
+ }
1361
+
1362
+ async function findExistingRalplanRunOwners(cwd: string, runId: string): Promise<string[]> {
1363
+ let entries: Dirent<string>[];
1364
+ try {
1365
+ entries = await fs.readdir(gjcRoot(cwd), { withFileTypes: true });
1366
+ } catch (error) {
1367
+ const err = error as NodeJS.ErrnoException;
1368
+ if (err.code === "ENOENT" || err.code === "ENOTDIR") return [];
1369
+ throw error;
1370
+ }
1371
+
1372
+ const owners = await Promise.all(
1373
+ entries.map(async entry => {
1374
+ if (!entry.isDirectory()) return undefined;
1375
+ const sessionId = sessionIdFromDirName(entry.name);
1376
+ if (!sessionId) return undefined;
1377
+
1378
+ const stateRead = await readExistingStateForMutation(ralplanStatePath(cwd, sessionId));
1379
+ const stateOwnsRun =
1380
+ stateRead.kind === "valid" &&
1381
+ typeof stateRead.value.run_id === "string" &&
1382
+ stateRead.value.run_id.trim() === runId;
1383
+ if (stateOwnsRun) return sessionId;
1384
+
1385
+ try {
1386
+ const stat = await fs.stat(path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId));
1387
+ return stat.isDirectory() ? sessionId : undefined;
1388
+ } catch (error) {
1389
+ const err = error as NodeJS.ErrnoException;
1390
+ if (err.code === "ENOENT" || err.code === "ENOTDIR") return undefined;
1391
+ throw error;
1392
+ }
1393
+ }),
1394
+ );
1395
+ return owners.filter((owner): owner is string => owner !== undefined).sort();
1396
+ }
1397
+
1398
+ async function resolveArtifactSessionId(args: readonly string[], cwd: string, explicitRunId: string | undefined) {
1399
+ const flagSessionId = flagValue(args, "--session-id");
1400
+ const currentSession = resolveGjcSessionForWrite(cwd, {
1401
+ flagValue: flagSessionId,
1402
+ envSessionId: process.env.GJC_SESSION_ID,
1403
+ });
1404
+ if (!explicitRunId) return currentSession.gjcSessionId;
1405
+
1406
+ const owners = await findExistingRalplanRunOwners(cwd, explicitRunId);
1407
+ if (owners.length === 0) return currentSession.gjcSessionId;
1408
+ if (owners.length > 1) {
1409
+ throw new RalplanCommandError(
1410
+ 2,
1411
+ `ralplan run ${explicitRunId} has multiple owner sessions (${owners.join(", ")}); repair the fragmented run before writing`,
1412
+ );
1413
+ }
1414
+
1415
+ const ownerSessionId = owners[0]!;
1416
+ if (flagSessionId !== undefined && currentSession.gjcSessionId !== ownerSessionId) {
1417
+ throw new RalplanCommandError(
1418
+ 2,
1419
+ `ralplan run ${explicitRunId} is owned by session ${ownerSessionId}, not ${currentSession.gjcSessionId}`,
1420
+ );
1421
+ }
1422
+ return ownerSessionId;
1423
+ }
1097
1424
 
1098
1425
  async function resolveArtifactArgs(args: readonly string[], cwd: string): Promise<ResolvedArtifactArgs> {
1099
1426
  const stage = flagValue(args, "--stage");
@@ -1115,20 +1442,18 @@ async function resolveArtifactArgs(args: readonly string[], cwd: string): Promis
1115
1442
  throw new RalplanCommandError(2, `--artifact-env must be ${GJC_RALPLAN_ARTIFACT_ENV}`);
1116
1443
  }
1117
1444
 
1118
- const session = resolveGjcSessionForWrite(cwd, {
1119
- flagValue: flagValue(args, "--session-id"),
1120
- envSessionId: process.env.GJC_SESSION_ID,
1121
- });
1122
- const sessionId = session.gjcSessionId;
1445
+ const explicitRunId = flagValue(args, "--run-id")?.trim();
1446
+ if (explicitRunId) assertSafePathComponent(explicitRunId, "run-id");
1447
+
1448
+ const sessionId = await resolveArtifactSessionId(args, cwd, explicitRunId);
1123
1449
  assertSafePathComponent(sessionId, "session-id");
1124
1450
  const sessionIdRaw = sessionId;
1125
1451
 
1126
1452
  // Precedence for run_id:
1127
1453
  // 1. explicit --run-id flag
1128
- // 2. existing run_id field in .gjc/state[/sessions/<id>]/ralplan-state.json
1129
- // 3. explicit --session-id flag (use as run id)
1454
+ // 2. existing run_id field in the resolved owner session's ralplan state
1455
+ // 3. resolved owner session id
1130
1456
  // 4. freshly generated default run id
1131
- const explicitRunId = flagValue(args, "--run-id")?.trim();
1132
1457
  const runId = explicitRunId || (await readActiveRunId(cwd, sessionId)) || sessionIdRaw || defaultRunId();
1133
1458
  assertSafePathComponent(runId, "run-id");
1134
1459
 
@@ -1170,6 +1495,7 @@ async function persistArtifact(
1170
1495
  cwd: string,
1171
1496
  content: string,
1172
1497
  sha256: string,
1498
+ finalAdmission?: RalplanAutoHandoffResolution,
1173
1499
  ): Promise<PersistedArtifact> {
1174
1500
  const runDir = path.join(sessionPlansDir(cwd, resolved.sessionId), "ralplan", resolved.runId);
1175
1501
 
@@ -1193,6 +1519,7 @@ async function persistArtifact(
1193
1519
  path: filePath,
1194
1520
  created_at: createdAt,
1195
1521
  sha256,
1522
+ ...(finalAdmission ? { auto_handoff: finalAdmission } : {}),
1196
1523
  };
1197
1524
  await appendJsonlIdempotent(path.join(runDir, "index.jsonl"), indexEntry, {
1198
1525
  cwd,
@@ -1237,6 +1564,35 @@ interface ExistingStageArtifact {
1237
1564
  path: string;
1238
1565
  sha256: string;
1239
1566
  createdAt: string;
1567
+ autoHandoff?: RalplanAutoHandoffResolution;
1568
+ }
1569
+
1570
+ function parseRalplanFinalAdmission(value: unknown): RalplanAutoHandoffResolution | undefined {
1571
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1572
+ const admission = value as Record<string, unknown>;
1573
+ if (
1574
+ !RALPLAN_AUTO_HANDOFF_TARGETS.has(admission.configuredTarget as RalplanAutoHandoffTarget) ||
1575
+ !RALPLAN_AUTO_HANDOFF_TARGETS.has(admission.effectiveTarget as RalplanAutoHandoffTarget) ||
1576
+ (typeof admission.degradationReason !== "string" && admission.degradationReason !== null) ||
1577
+ typeof admission.source !== "string"
1578
+ ) {
1579
+ return undefined;
1580
+ }
1581
+ return {
1582
+ configuredTarget: admission.configuredTarget as RalplanAutoHandoffTarget,
1583
+ effectiveTarget: admission.effectiveTarget as RalplanAutoHandoffTarget,
1584
+ degradationReason: typeof admission.degradationReason === "string" ? admission.degradationReason : null,
1585
+ source: admission.source,
1586
+ };
1587
+ }
1588
+
1589
+ function unavailableRalplanFinalAdmission(): RalplanAutoHandoffResolution {
1590
+ return {
1591
+ configuredTarget: "off",
1592
+ effectiveTarget: "off",
1593
+ degradationReason: "admission_unavailable",
1594
+ source: "ledger",
1595
+ };
1240
1596
  }
1241
1597
 
1242
1598
  /**
@@ -1269,6 +1625,7 @@ function findExistingStageArtifact(
1269
1625
  path: record.path,
1270
1626
  sha256: record.sha256,
1271
1627
  createdAt: typeof record.created_at === "string" ? record.created_at : "",
1628
+ ...(stage === "final" ? { autoHandoff: parseRalplanFinalAdmission(record.auto_handoff) } : {}),
1272
1629
  };
1273
1630
  }
1274
1631
  return match;
@@ -1354,6 +1711,7 @@ async function repairMissingStageArtifactLedger(
1354
1711
  cwd: string,
1355
1712
  resolved: Pick<ResolvedArtifactArgs, "sessionId" | "runId" | "stage" | "stageN">,
1356
1713
  onDisk: OnDiskStageArtifact,
1714
+ finalAdmission?: RalplanAutoHandoffResolution,
1357
1715
  ): Promise<ExistingStageArtifact> {
1358
1716
  const createdAt = new Date().toISOString();
1359
1717
  const indexEntry = {
@@ -1362,6 +1720,7 @@ async function repairMissingStageArtifactLedger(
1362
1720
  path: onDisk.path,
1363
1721
  created_at: createdAt,
1364
1722
  sha256: onDisk.sha256,
1723
+ ...(finalAdmission ? { auto_handoff: finalAdmission } : {}),
1365
1724
  };
1366
1725
  const result = await appendJsonlIdempotent(
1367
1726
  path.join(sessionPlansDir(cwd, resolved.sessionId), "ralplan", resolved.runId, "index.jsonl"),
@@ -1386,10 +1745,18 @@ async function repairMissingStageArtifactLedger(
1386
1745
  path: record.path,
1387
1746
  sha256: record.sha256,
1388
1747
  createdAt: typeof record.created_at === "string" ? record.created_at : createdAt,
1748
+ ...(resolved.stage === "final"
1749
+ ? { autoHandoff: parseRalplanFinalAdmission(record.auto_handoff) ?? unavailableRalplanFinalAdmission() }
1750
+ : {}),
1389
1751
  };
1390
1752
  }
1391
1753
  }
1392
- return { path: onDisk.path, sha256: onDisk.sha256, createdAt };
1754
+ return {
1755
+ path: onDisk.path,
1756
+ sha256: onDisk.sha256,
1757
+ createdAt,
1758
+ ...(resolved.stage === "final" ? { autoHandoff: finalAdmission ?? unavailableRalplanFinalAdmission() } : {}),
1759
+ };
1393
1760
  }
1394
1761
 
1395
1762
  /**
@@ -1463,12 +1830,18 @@ async function buildRalplanHud(options: {
1463
1830
  let architectPasses: number | undefined;
1464
1831
  let criticPasses: number | undefined;
1465
1832
  let verdict: string | undefined;
1833
+ let autoHandoff: RalplanAutoHandoffResolution | undefined;
1834
+ let planningStuck = false;
1466
1835
  if (options.runId && options.sessionId) {
1467
- const [rows, lastReviewVerdict] = await Promise.all([
1836
+ const [rows, lastReviewVerdict, persistedAutoHandoff, persistedPlanningStuck] = await Promise.all([
1468
1837
  readRalplanIndexRows(options.cwd, options.sessionId, options.runId),
1469
1838
  readRalplanLastReviewVerdict(options.cwd, options.sessionId),
1839
+ readRalplanFinalAdmission(options.cwd, options.sessionId, options.runId),
1840
+ readRalplanPlanningStuck(options.cwd, options.sessionId, options.runId),
1470
1841
  ]);
1471
1842
  verdict = lastReviewVerdict;
1843
+ autoHandoff = persistedAutoHandoff;
1844
+ planningStuck = persistedPlanningStuck;
1472
1845
  if (rows.length > 0) {
1473
1846
  const summary = summarizeRalplanIndex(rows);
1474
1847
  iterationFromIndex = summary.iteration;
@@ -1486,12 +1859,62 @@ async function buildRalplanHud(options: {
1486
1859
  criticPasses,
1487
1860
  reviewPassBudget: options.reviewPassBudget,
1488
1861
  verdict,
1862
+ autoHandoff,
1863
+ planningStuck,
1489
1864
  pendingApproval: options.pendingApproval,
1490
1865
  latestSummary: options.latestSummary,
1491
1866
  updatedAt: new Date().toISOString(),
1492
1867
  });
1493
1868
  }
1494
1869
 
1870
+ /**
1871
+ * Disposition-stage artifacts are machine-checkable JSON. Validate, enforce
1872
+ * authoritative same-pass provenance against the run index, and re-serialize to
1873
+ * canonical form so join gates and re-review share one shape (#2902 / #3013).
1874
+ */
1875
+ function normalizeDispositionArtifact(raw: string, expectedStageN: number, indexText: string | undefined): string {
1876
+ try {
1877
+ const indexedArtifacts = buildIndexedReviewArtifacts(indexText);
1878
+ const doc = parseReviewConflictDocument(raw, {
1879
+ expectedStageN,
1880
+ indexedArtifacts,
1881
+ });
1882
+ return serializeReviewConflictDocument(doc);
1883
+ } catch (error) {
1884
+ throw new RalplanCommandError(
1885
+ 2,
1886
+ `invalid ralplan disposition artifact: ${error instanceof Error ? error.message : String(error)}`,
1887
+ );
1888
+ }
1889
+ }
1890
+
1891
+ /** Parse complete path/sha256 rows from a run `index.jsonl` snapshot for provenance. */
1892
+ function buildIndexedReviewArtifacts(indexText: string | undefined): Map<string, IndexedReviewArtifact> {
1893
+ const map = new Map<string, IndexedReviewArtifact>();
1894
+ if (indexText === undefined) return map;
1895
+ for (const line of indexText.split(/\r?\n/)) {
1896
+ const trimmed = line.trim();
1897
+ if (!trimmed) continue;
1898
+ let row: unknown;
1899
+ try {
1900
+ row = JSON.parse(trimmed);
1901
+ } catch {
1902
+ continue;
1903
+ }
1904
+ if (!row || typeof row !== "object" || Array.isArray(row)) continue;
1905
+ const record = row as Record<string, unknown>;
1906
+ if (typeof record.stage !== "string") continue;
1907
+ if (typeof record.stage_n !== "number" || !Number.isInteger(record.stage_n)) continue;
1908
+ if (typeof record.path !== "string" || typeof record.sha256 !== "string") continue;
1909
+ // Last complete row for an identity wins (matches findExistingStageArtifact).
1910
+ map.set(reviewArtifactIndexKey(record.stage, record.stage_n), {
1911
+ path: record.path,
1912
+ sha256: record.sha256,
1913
+ });
1914
+ }
1915
+ return map;
1916
+ }
1917
+
1495
1918
  async function handleArtifactWrite(args: readonly string[], cwd: string): Promise<RalplanCommandResult> {
1496
1919
  const resolved = await resolveArtifactArgs(args, cwd);
1497
1920
  const persistedRoleState = parsePersistedRoleStateArgs(args, resolved.stage);
@@ -1514,14 +1937,20 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1514
1937
  // Non-file / missing path is inline content; resolveArtifactContent already handled it.
1515
1938
  }
1516
1939
  }
1517
- const content = resolved.artifact.endsWith("\n") ? resolved.artifact : `${resolved.artifact}\n`;
1518
- const sha256 = createHash("sha256").update(content).digest("hex");
1519
1940
 
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.
1941
+ // Read the ledger once before persistence. The dedupe guard, both gates, and
1942
+ // disposition provenance consume this same snapshot so no additional ledger
1943
+ // read can slip between gate evaluation and persistence.
1523
1944
  const indexLoad = await loadRalplanIndexForCap(cwd, resolved.sessionId, resolved.runId);
1524
1945
 
1946
+ // Disposition stage: fail-closed parse/normalize with authoritative receipts (#2902).
1947
+ const normalizedArtifact =
1948
+ resolved.stage === "disposition"
1949
+ ? normalizeDispositionArtifact(resolved.artifact, resolved.stageN, indexLoad.rawText)
1950
+ : resolved.artifact;
1951
+ const content = normalizedArtifact.endsWith("\n") ? normalizedArtifact : `${normalizedArtifact}\n`;
1952
+ const sha256 = createHash("sha256").update(content).digest("hex");
1953
+
1525
1954
  // Duplicate-write guard: a second `--write` for the same (stage, stage_n) must not
1526
1955
  // silently clobber the artifact or append a duplicate ledger row. Classify before any
1527
1956
  // state mutation so a conflict never regresses run-state phase.
@@ -1534,7 +1963,7 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1534
1963
  );
1535
1964
  }
1536
1965
  if (resolved.stage === "final") await ensureFinalPendingApproval(cwd, resolved, existingArtifact);
1537
- return buildDeduplicatedResult(resolved, existingArtifact, sha256, cwd, repositoryBinding);
1966
+ return await buildDeduplicatedResult(resolved, existingArtifact, sha256, cwd, repositoryBinding);
1538
1967
  }
1539
1968
 
1540
1969
  // `persistArtifact` writes the artifact before its ledger row. If a process crashed
@@ -1549,7 +1978,12 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1549
1978
  );
1550
1979
  }
1551
1980
  if (resolved.stage === "final") await ensureFinalPendingApproval(cwd, resolved, onDiskArtifact);
1552
- const repairedArtifact = await repairMissingStageArtifactLedger(cwd, resolved, onDiskArtifact);
1981
+ const repairedArtifact = await repairMissingStageArtifactLedger(
1982
+ cwd,
1983
+ resolved,
1984
+ onDiskArtifact,
1985
+ resolved.stage === "final" ? unavailableRalplanFinalAdmission() : undefined,
1986
+ );
1553
1987
  let appliedPersistedRoleState: PersistedRoleStateUpdate | undefined;
1554
1988
  if (
1555
1989
  persistedRoleState &&
@@ -1561,7 +1995,7 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1561
1995
  if (laneVerdict && (await applyLaneVerdictUpdate(cwd, resolved.sessionId, laneVerdict, resolved.runId))) {
1562
1996
  appliedLaneVerdict = laneVerdict;
1563
1997
  }
1564
- return buildDeduplicatedResult(
1998
+ return await buildDeduplicatedResult(
1565
1999
  resolved,
1566
2000
  repairedArtifact,
1567
2001
  sha256,
@@ -1595,6 +2029,7 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1595
2029
  iterationFloor: onDiskOpeners,
1596
2030
  });
1597
2031
  if (!capDecision.allowed) {
2032
+ await recordRalplanPlanningStuck(cwd, resolved.sessionId, resolved.runId, capDecision.reason);
1598
2033
  return buildPlanningStuckResult({
1599
2034
  json: resolved.json,
1600
2035
  stage: resolved.stage,
@@ -1611,6 +2046,7 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1611
2046
  onDiskLaneCounts: onDiskLaneArtifacts,
1612
2047
  });
1613
2048
  if (!laneBudgetDecision.allowed) {
2049
+ await recordRalplanPlanningStuck(cwd, resolved.sessionId, resolved.runId, laneBudgetDecision.reason);
1614
2050
  return buildLaneBudgetStuckResult({
1615
2051
  json: resolved.json,
1616
2052
  stage: resolved.stage,
@@ -1621,15 +2057,27 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1621
2057
  });
1622
2058
  }
1623
2059
 
2060
+ let autoHandoff: RalplanAutoHandoffResolution | undefined;
2061
+ if (resolved.stage === "final") {
2062
+ // Resolve and validate the configured admission before any final artifact or
2063
+ // state write. The ledger row below is the durable receipt for deduplicated
2064
+ // retries; state is only a current-session projection.
2065
+ autoHandoff = await resolveRalplanAutoHandoff(cwd, {
2066
+ planningStuck: await readRalplanPlanningStuck(cwd, resolved.sessionId, resolved.runId),
2067
+ });
2068
+ }
1624
2069
  // Keep run-state `current_phase` coherent with the stage being persisted.
1625
2070
  await persistActiveRunId(cwd, resolved.sessionId, resolved.runId, resolved.stage);
1626
- const persisted = await persistArtifact(resolved, cwd, content, sha256);
2071
+ const persisted = await persistArtifact(resolved, cwd, content, sha256, autoHandoff);
1627
2072
  if (persistedRoleState) {
1628
2073
  await applyPersistedRoleStateUpdate(cwd, resolved.sessionId, persistedRoleState);
1629
2074
  }
1630
2075
  if (laneVerdict) {
1631
2076
  await applyLaneVerdictUpdate(cwd, resolved.sessionId, laneVerdict);
1632
2077
  }
2078
+ if (autoHandoff) {
2079
+ await persistRalplanFinalAdmission(cwd, resolved.sessionId, resolved.runId, autoHandoff);
2080
+ }
1633
2081
  await writeSessionActivityMarker(cwd, resolved.sessionId, { writer: "ralplan-runtime", path: persisted.path });
1634
2082
  await syncRalplanHud({
1635
2083
  cwd,
@@ -1651,6 +2099,7 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1651
2099
  : undefined;
1652
2100
 
1653
2101
  const payload: Record<string, unknown> = {
2102
+ session_id: resolved.sessionId,
1654
2103
  run_id: persisted.runId,
1655
2104
  path: persisted.path,
1656
2105
  stage: persisted.stage,
@@ -1663,6 +2112,7 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1663
2112
  if (persistedRoleState) payload[`${persistedRoleState.role}_state`] = persistedRoleStatePayload(persistedRoleState);
1664
2113
  if (reviewBudgetWarning) payload.review_budget_warning = reviewBudgetWarning;
1665
2114
  if (laneVerdict) payload.lane_verdict = { lane: laneVerdict.lane, verdict: laneVerdict.verdict };
2115
+ if (autoHandoff) payload.auto_handoff = autoHandoff;
1666
2116
 
1667
2117
  const stdout = resolved.json
1668
2118
  ? `${JSON.stringify(payload, null, 2)}\n`
@@ -1675,7 +2125,14 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
1675
2125
  * do not rewrite artifacts, append rows, or churn run state; a crash-gap repair may
1676
2126
  * complete riding persisted role/lane metadata before returning this receipt.
1677
2127
  */
1678
- function buildDeduplicatedResult(
2128
+ function applyRalplanPlanningStuckOverride(
2129
+ admission: RalplanAutoHandoffResolution,
2130
+ planningStuck: boolean,
2131
+ ): RalplanAutoHandoffResolution {
2132
+ return planningStuck ? { ...admission, effectiveTarget: "off", degradationReason: "planning_stuck" } : admission;
2133
+ }
2134
+
2135
+ async function buildDeduplicatedResult(
1679
2136
  resolved: ResolvedArtifactArgs,
1680
2137
  existing: ExistingStageArtifact,
1681
2138
  sha256: string,
@@ -1683,8 +2140,9 @@ function buildDeduplicatedResult(
1683
2140
  repositoryBinding: RepositoryBinding,
1684
2141
  laneVerdict?: LaneVerdictUpdate,
1685
2142
  persistedRoleState?: PersistedRoleStateUpdate,
1686
- ): RalplanCommandResult {
2143
+ ): Promise<RalplanCommandResult> {
1687
2144
  const payload: Record<string, unknown> = {
2145
+ session_id: resolved.sessionId,
1688
2146
  run_id: resolved.runId,
1689
2147
  path: existing.path,
1690
2148
  stage: resolved.stage,
@@ -1705,6 +2163,11 @@ function buildDeduplicatedResult(
1705
2163
  resolved.runId,
1706
2164
  "pending-approval.md",
1707
2165
  );
2166
+ const planningStuck = await readRalplanPlanningStuck(cwd, resolved.sessionId, resolved.runId);
2167
+ payload.auto_handoff = applyRalplanPlanningStuckOverride(
2168
+ existing.autoHandoff ?? unavailableRalplanFinalAdmission(),
2169
+ planningStuck,
2170
+ );
1708
2171
  }
1709
2172
  const stdout = resolved.json
1710
2173
  ? `${JSON.stringify(payload, null, 2)}\n`
@@ -1851,6 +2314,7 @@ async function handleConsensusHandoff(args: readonly string[], cwd: string): Pro
1851
2314
  });
1852
2315
 
1853
2316
  const summary = {
2317
+ session_id: resolved.sessionId,
1854
2318
  skill: "ralplan",
1855
2319
  mode,
1856
2320
  state_path: statePath,