@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,6 +1,7 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { shortenPath } from "../tools/render-utils";
4
5
 
5
6
  export type GjcLaunchWorktreeMode =
6
7
  | { enabled: false }
@@ -138,6 +139,107 @@ function hasBranchInUse(entries: GitWorktreeEntry[], branchName: string, worktre
138
139
  return entries.some(entry => entry.branchRef === expectedRef && path.resolve(entry.path) !== resolvedPath);
139
140
  }
140
141
 
142
+ function fileSystemErrorCode(error: unknown): string | null {
143
+ return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
144
+ ? error.code
145
+ : null;
146
+ }
147
+
148
+ function formatBucketPath(bucketPath: string): string {
149
+ return JSON.stringify(shortenPath(bucketPath));
150
+ }
151
+
152
+ function brokenBucketSymlinkError(bucketPath: string): Error {
153
+ return new Error(
154
+ [
155
+ "worktree_bucket_broken_symlink",
156
+ "The GJC launch worktree bucket is a symbolic link whose target cannot be resolved; it may be unmounted or offloaded cold storage.",
157
+ `Path: ${formatBucketPath(bucketPath)}`,
158
+ "Safe remediation: restore or remount the link target, or inspect and remove the dangling link with platform-appropriate filesystem tools, then relaunch. GJC did not delete or replace the entry.",
159
+ ].join("\n"),
160
+ );
161
+ }
162
+
163
+ function bucketNotDirectoryError(bucketPath: string, symlinkTarget = false): Error {
164
+ return new Error(
165
+ [
166
+ "worktree_bucket_not_directory",
167
+ symlinkTarget
168
+ ? "The GJC launch worktree bucket is a symbolic link whose target is not a directory."
169
+ : "The GJC launch worktree bucket path exists but is not a directory.",
170
+ `Path: ${formatBucketPath(bucketPath)}`,
171
+ "Safe remediation: inspect the obstructing entry and move or remove it with platform-appropriate filesystem tools, then relaunch. GJC did not delete or replace the entry.",
172
+ ].join("\n"),
173
+ );
174
+ }
175
+
176
+ function inspectBucketDir(bucketPath: string): "missing" | "usable" {
177
+ let entry: fs.Stats;
178
+ try {
179
+ entry = fs.lstatSync(bucketPath);
180
+ } catch (error) {
181
+ if (fileSystemErrorCode(error) === "ENOENT") return "missing";
182
+ throw new Error(
183
+ [
184
+ "worktree_bucket_inspection_failed",
185
+ `GJC could not inspect the launch worktree bucket${fileSystemErrorCode(error) ? ` (${fileSystemErrorCode(error)})` : ""}.`,
186
+ `Path: ${formatBucketPath(bucketPath)}`,
187
+ "Safe remediation: verify that the bucket parent is accessible, then relaunch. GJC did not modify the entry.",
188
+ ].join("\n"),
189
+ );
190
+ }
191
+ if (entry.isDirectory()) return "usable";
192
+ if (!entry.isSymbolicLink()) throw bucketNotDirectoryError(bucketPath);
193
+
194
+ let target: fs.Stats;
195
+ try {
196
+ target = fs.statSync(bucketPath);
197
+ } catch (error) {
198
+ const code = fileSystemErrorCode(error);
199
+ if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP") throw brokenBucketSymlinkError(bucketPath);
200
+ throw new Error(
201
+ [
202
+ "worktree_bucket_target_inspection_failed",
203
+ `GJC could not inspect the launch worktree bucket link target${code ? ` (${code})` : ""}.`,
204
+ `Path: ${formatBucketPath(bucketPath)}`,
205
+ "Safe remediation: verify that the link target is accessible, then relaunch. GJC did not modify the link.",
206
+ ].join("\n"),
207
+ );
208
+ }
209
+ if (target.isDirectory()) return "usable";
210
+ throw bucketNotDirectoryError(bucketPath, true);
211
+ }
212
+
213
+ function ensureBucketDirUsable(bucketPath: string): void {
214
+ inspectBucketDir(bucketPath);
215
+ try {
216
+ fs.mkdirSync(bucketPath, { recursive: true });
217
+ } catch (error) {
218
+ // The entry can change between lstat/stat and mkdir. Re-inspect so a
219
+ // racing broken link or non-directory is still reported actionably.
220
+ inspectBucketDir(bucketPath);
221
+ const code = fileSystemErrorCode(error);
222
+ throw new Error(
223
+ [
224
+ "worktree_bucket_create_failed",
225
+ `GJC could not create or reuse the launch worktree bucket${code ? ` (${code})` : ""}.`,
226
+ `Path: ${formatBucketPath(bucketPath)}`,
227
+ "Safe remediation: verify parent permissions and bucket accessibility, then relaunch. GJC did not delete or replace any entry.",
228
+ ].join("\n"),
229
+ );
230
+ }
231
+ if (inspectBucketDir(bucketPath) === "missing") {
232
+ throw new Error(
233
+ [
234
+ "worktree_bucket_changed_during_preflight",
235
+ "The GJC launch worktree bucket disappeared while launch was preparing it.",
236
+ `Path: ${formatBucketPath(bucketPath)}`,
237
+ "Safe remediation: stabilize the bucket mount or parent directory, then relaunch. GJC did not delete or replace any entry.",
238
+ ].join("\n"),
239
+ );
240
+ }
241
+ }
242
+
141
243
  function pruneStaleWorktreePath(repoRoot: string): void {
142
244
  runGit(repoRoot, ["worktree", "prune"]);
143
245
  }
@@ -267,7 +369,7 @@ export function ensureLaunchWorktree(
267
369
  throw new Error(`branch_in_use:${plan.branchName}`);
268
370
  }
269
371
 
270
- fs.mkdirSync(path.dirname(plan.worktreePath), { recursive: true });
372
+ ensureBucketDirUsable(path.dirname(plan.worktreePath));
271
373
  const branchAlreadyExisted = plan.branchName ? branchExists(plan.repoRoot, plan.branchName) : false;
272
374
  const args = ["worktree", "add"];
273
375
  if (plan.detached) args.push("--detach", plan.worktreePath, plan.baseRef);
@@ -92,6 +92,7 @@ const RALPLAN_STAGE_CODES: Record<string, string> = {
92
92
  revision: "R",
93
93
  architect: "A",
94
94
  critic: "C",
95
+ disposition: "X",
95
96
  adr: "D",
96
97
  "post-interview": "I",
97
98
  final: "F",
@@ -51,7 +51,7 @@ function ownerEnvironment(): {
51
51
  const generation = process.env[MANAGED_OWNER_GENERATION_ENV]?.trim();
52
52
  const runId = process.env[MANAGED_OWNER_RUN_ID_ENV]?.trim();
53
53
  const incarnation = process.env[MANAGED_OWNER_INCARNATION_ENV]?.trim();
54
- if (!stateDir && !sessionId && !generation && !runId && !incarnation) return null;
54
+ if (!stateDir && !generation && !runId && !incarnation) return null;
55
55
  if (!stateDir || !sessionId || !generation || !runId || !incarnation || !path.isAbsolute(stateDir))
56
56
  throw new Error("managed_owner_admission_metadata_invalid");
57
57
  for (const [value, label] of [
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Typed ralplan review conflicts and dispositions (#2902).
3
+ *
4
+ * Architect and Critic findings remain free-form in their stage markdown, but
5
+ * the join/revision path can record machine-checkable findings against stable
6
+ * plan targets. Incompatible actions on the same target produce open conflicts
7
+ * that block a clean join until an explicit disposition is recorded.
8
+ */
9
+
10
+ export const RALPLAN_REVIEW_CONFLICTS_SCHEMA = "ralplan.review_conflicts.v1" as const;
11
+
12
+ export type ReviewAction = "add" | "remove" | "change" | "clarify";
13
+ export type ReviewRole = "architect" | "critic";
14
+ export type ReviewSeverity = "info" | "watch" | "block";
15
+ export type DispositionChoice = "accept_architect" | "accept_critic" | "synthesize" | "defer_user" | "reject_both";
16
+
17
+ export interface ReviewSourceReceipt {
18
+ stage: "architect" | "critic";
19
+ stageN: number;
20
+ path: string;
21
+ sha256: string;
22
+ }
23
+
24
+ export interface ReviewFinding {
25
+ findingId: string;
26
+ targetId: string;
27
+ action: ReviewAction;
28
+ severity: ReviewSeverity;
29
+ evidence: string;
30
+ sourceRole: ReviewRole;
31
+ sourceReceipt: ReviewSourceReceipt;
32
+ proposedOwner?: string;
33
+ }
34
+
35
+ export interface ReviewConflict {
36
+ conflictId: string;
37
+ targetId: string;
38
+ findingIds: [string, string];
39
+ actions: [ReviewAction, ReviewAction];
40
+ sourceRoles: [ReviewRole, ReviewRole];
41
+ status: "open" | "dispositioned";
42
+ }
43
+
44
+ export interface ConflictDisposition {
45
+ conflictId: string;
46
+ choice: DispositionChoice;
47
+ rationale: string;
48
+ decisionOwner: string;
49
+ affectedSections: string[];
50
+ dispositionedAt?: string;
51
+ }
52
+
53
+ export interface ReviewConflictDocument {
54
+ schema: typeof RALPLAN_REVIEW_CONFLICTS_SCHEMA;
55
+ plannerStageN: number;
56
+ findings: ReviewFinding[];
57
+ conflicts: ReviewConflict[];
58
+ dispositions: ConflictDisposition[];
59
+ }
60
+
61
+ export interface JoinGateResult {
62
+ ok: boolean;
63
+ openConflictIds: string[];
64
+ missingDispositionIds: string[];
65
+ orphanDispositionIds: string[];
66
+ message: string;
67
+ }
68
+
69
+ const ACTIONS = new Set<ReviewAction>(["add", "remove", "change", "clarify"]);
70
+ const ROLES = new Set<ReviewRole>(["architect", "critic"]);
71
+ const SEVERITIES = new Set<ReviewSeverity>(["info", "watch", "block"]);
72
+ const DISPOSITIONS = new Set<DispositionChoice>([
73
+ "accept_architect",
74
+ "accept_critic",
75
+ "synthesize",
76
+ "defer_user",
77
+ "reject_both",
78
+ ]);
79
+
80
+ /** Pairs of actions that cannot both stand for the same plan target. */
81
+ const INCOMPATIBLE_ACTION_PAIRS = new Set<string>([
82
+ pairKey("add", "remove"),
83
+ pairKey("remove", "add"),
84
+ pairKey("remove", "change"),
85
+ pairKey("change", "remove"),
86
+ ]);
87
+
88
+ function pairKey(left: ReviewAction, right: ReviewAction): string {
89
+ return `${left}\u0000${right}`;
90
+ }
91
+
92
+ function isObject(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === "object" && value !== null && !Array.isArray(value);
94
+ }
95
+
96
+ function nonEmptyString(value: unknown, field: string): string {
97
+ if (typeof value !== "string" || value.trim() === "") {
98
+ throw new Error(`${field} must be a non-empty string`);
99
+ }
100
+ return value.trim();
101
+ }
102
+
103
+ function positiveInt(value: unknown, field: string): number {
104
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
105
+ throw new Error(`${field} must be an integer >= 1`);
106
+ }
107
+ return value;
108
+ }
109
+
110
+ function parseAction(value: unknown, field: string): ReviewAction {
111
+ const action = nonEmptyString(value, field);
112
+ if (!ACTIONS.has(action as ReviewAction)) {
113
+ throw new Error(`${field} must be one of: ${[...ACTIONS].join(", ")}`);
114
+ }
115
+ return action as ReviewAction;
116
+ }
117
+
118
+ function parseRole(value: unknown, field: string): ReviewRole {
119
+ const role = nonEmptyString(value, field);
120
+ if (!ROLES.has(role as ReviewRole)) {
121
+ throw new Error(`${field} must be one of: ${[...ROLES].join(", ")}`);
122
+ }
123
+ return role as ReviewRole;
124
+ }
125
+
126
+ function parseSeverity(value: unknown, field: string): ReviewSeverity {
127
+ const severity = nonEmptyString(value, field);
128
+ if (!SEVERITIES.has(severity as ReviewSeverity)) {
129
+ throw new Error(`${field} must be one of: ${[...SEVERITIES].join(", ")}`);
130
+ }
131
+ return severity as ReviewSeverity;
132
+ }
133
+
134
+ function parseDispositionChoice(value: unknown, field: string): DispositionChoice {
135
+ const choice = nonEmptyString(value, field);
136
+ if (!DISPOSITIONS.has(choice as DispositionChoice)) {
137
+ throw new Error(`${field} must be one of: ${[...DISPOSITIONS].join(", ")}`);
138
+ }
139
+ return choice as DispositionChoice;
140
+ }
141
+
142
+ function parseSourceReceipt(value: unknown, field: string): ReviewSourceReceipt {
143
+ if (!isObject(value)) throw new Error(`${field} must be an object`);
144
+ const stage = nonEmptyString(value.stage, `${field}.stage`);
145
+ if (stage !== "architect" && stage !== "critic") {
146
+ throw new Error(`${field}.stage must be architect or critic`);
147
+ }
148
+ return {
149
+ stage,
150
+ stageN: positiveInt(value.stageN ?? value.stage_n, `${field}.stageN`),
151
+ path: nonEmptyString(value.path, `${field}.path`),
152
+ sha256: nonEmptyString(value.sha256, `${field}.sha256`),
153
+ };
154
+ }
155
+
156
+ function parseFinding(value: unknown, index: number): ReviewFinding {
157
+ if (!isObject(value)) throw new Error(`findings[${index}] must be an object`);
158
+ const finding: ReviewFinding = {
159
+ findingId: nonEmptyString(value.findingId ?? value.finding_id, `findings[${index}].findingId`),
160
+ targetId: nonEmptyString(value.targetId ?? value.target_id, `findings[${index}].targetId`),
161
+ action: parseAction(value.action, `findings[${index}].action`),
162
+ severity: parseSeverity(value.severity, `findings[${index}].severity`),
163
+ evidence: nonEmptyString(value.evidence, `findings[${index}].evidence`),
164
+ sourceRole: parseRole(value.sourceRole ?? value.source_role, `findings[${index}].sourceRole`),
165
+ sourceReceipt: parseSourceReceipt(
166
+ value.sourceReceipt ?? value.source_receipt,
167
+ `findings[${index}].sourceReceipt`,
168
+ ),
169
+ };
170
+ const proposedOwner = value.proposedOwner ?? value.proposed_owner;
171
+ if (proposedOwner !== undefined) {
172
+ finding.proposedOwner = nonEmptyString(proposedOwner, `findings[${index}].proposedOwner`);
173
+ }
174
+ return finding;
175
+ }
176
+
177
+ function parseDisposition(value: unknown, index: number): ConflictDisposition {
178
+ if (!isObject(value)) throw new Error(`dispositions[${index}] must be an object`);
179
+ const affectedRaw = value.affectedSections ?? value.affected_sections;
180
+ if (!Array.isArray(affectedRaw) || affectedRaw.length === 0) {
181
+ throw new Error(`dispositions[${index}].affectedSections must be a non-empty string array`);
182
+ }
183
+ const affectedSections = affectedRaw.map((entry, i) =>
184
+ nonEmptyString(entry, `dispositions[${index}].affectedSections[${i}]`),
185
+ );
186
+ const disposition: ConflictDisposition = {
187
+ conflictId: nonEmptyString(value.conflictId ?? value.conflict_id, `dispositions[${index}].conflictId`),
188
+ choice: parseDispositionChoice(value.choice, `dispositions[${index}].choice`),
189
+ rationale: nonEmptyString(value.rationale, `dispositions[${index}].rationale`),
190
+ decisionOwner: nonEmptyString(
191
+ value.decisionOwner ?? value.decision_owner,
192
+ `dispositions[${index}].decisionOwner`,
193
+ ),
194
+ affectedSections,
195
+ };
196
+ const at = value.dispositionedAt ?? value.dispositioned_at;
197
+ if (at !== undefined) {
198
+ disposition.dispositionedAt = nonEmptyString(at, `dispositions[${index}].dispositionedAt`);
199
+ }
200
+ return disposition;
201
+ }
202
+
203
+ /** True when two actions on the same target cannot both remain. */
204
+ export function actionsAreIncompatible(left: ReviewAction, right: ReviewAction): boolean {
205
+ if (left === right) return false;
206
+ return INCOMPATIBLE_ACTION_PAIRS.has(pairKey(left, right));
207
+ }
208
+
209
+ /**
210
+ * Derive open conflicts from typed findings. Only cross-role incompatible pairs
211
+ * on the same targetId are conflicts (Architect remove vs Critic add, etc.).
212
+ */
213
+ export function detectReviewConflicts(findings: readonly ReviewFinding[]): ReviewConflict[] {
214
+ const byTarget = new Map<string, ReviewFinding[]>();
215
+ for (const finding of findings) {
216
+ const list = byTarget.get(finding.targetId) ?? [];
217
+ list.push(finding);
218
+ byTarget.set(finding.targetId, list);
219
+ }
220
+
221
+ const conflicts: ReviewConflict[] = [];
222
+ for (const [targetId, group] of byTarget) {
223
+ for (let i = 0; i < group.length; i++) {
224
+ for (let j = i + 1; j < group.length; j++) {
225
+ const a = group[i]!;
226
+ const b = group[j]!;
227
+ if (a.sourceRole === b.sourceRole) continue;
228
+ if (!actionsAreIncompatible(a.action, b.action)) continue;
229
+ const [left, right] = a.findingId <= b.findingId ? [a, b] : [b, a];
230
+ conflicts.push({
231
+ conflictId: `conflict:${targetId}:${left.findingId}:${right.findingId}`,
232
+ targetId,
233
+ findingIds: [left.findingId, right.findingId],
234
+ actions: [left.action, right.action],
235
+ sourceRoles: [left.sourceRole, right.sourceRole],
236
+ status: "open",
237
+ });
238
+ }
239
+ }
240
+ }
241
+ return conflicts.sort((x, y) => x.conflictId.localeCompare(y.conflictId));
242
+ }
243
+
244
+ /** Mark conflicts dispositioned when a matching disposition exists. */
245
+ export function applyDispositions(
246
+ conflicts: readonly ReviewConflict[],
247
+ dispositions: readonly ConflictDisposition[],
248
+ ): ReviewConflict[] {
249
+ const disposed = new Set(dispositions.map(d => d.conflictId));
250
+ return conflicts.map(conflict =>
251
+ disposed.has(conflict.conflictId) ? { ...conflict, status: "dispositioned" } : { ...conflict, status: "open" },
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Join gate: clean only when every derived conflict has an explicit disposition
257
+ * with rationale and decision owner, and no orphan dispositions reference unknown
258
+ * conflicts.
259
+ */
260
+ export function evaluateReviewJoinGate(
261
+ findings: readonly ReviewFinding[],
262
+ dispositions: readonly ConflictDisposition[],
263
+ precomputedConflicts?: readonly ReviewConflict[],
264
+ ): JoinGateResult {
265
+ const derived = precomputedConflicts ? precomputedConflicts.map(c => ({ ...c })) : detectReviewConflicts(findings);
266
+ const withStatus = applyDispositions(derived, dispositions);
267
+ const knownIds = new Set(withStatus.map(c => c.conflictId));
268
+ const openConflictIds = withStatus.filter(c => c.status === "open").map(c => c.conflictId);
269
+ const disposedIds = new Set(dispositions.map(d => d.conflictId));
270
+ const missingDispositionIds = openConflictIds.filter(id => !disposedIds.has(id));
271
+ const orphanDispositionIds = dispositions.map(d => d.conflictId).filter(id => !knownIds.has(id));
272
+
273
+ const ok = missingDispositionIds.length === 0 && orphanDispositionIds.length === 0;
274
+ let message: string;
275
+ if (ok && withStatus.length === 0) {
276
+ message = "No typed review conflicts; join is clean.";
277
+ } else if (ok) {
278
+ message = `All ${withStatus.length} typed review conflict(s) are dispositioned.`;
279
+ } else if (missingDispositionIds.length > 0) {
280
+ message = `Join blocked: ${missingDispositionIds.length} open conflict(s) lack disposition: ${missingDispositionIds.join(", ")}.`;
281
+ } else {
282
+ message = `Join blocked: disposition(s) reference unknown conflict id(s): ${orphanDispositionIds.join(", ")}.`;
283
+ }
284
+
285
+ return { ok, openConflictIds, missingDispositionIds, orphanDispositionIds, message };
286
+ }
287
+
288
+ /** Strip optional markdown fence and parse JSON. */
289
+ export function parseReviewConflictJson(raw: string): unknown {
290
+ const trimmed = raw.trim();
291
+ const fenced = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/u);
292
+ const body = fenced ? fenced[1]!.trim() : trimmed;
293
+ try {
294
+ return JSON.parse(body);
295
+ } catch (error) {
296
+ throw new Error(`disposition artifact must be JSON: ${error instanceof Error ? error.message : String(error)}`);
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Authoritative provenance required by `gjc ralplan --write --stage disposition`.
302
+ *
303
+ * Without this, a disposition document can claim arbitrary path/hash strings and
304
+ * join the wrong Architect/Critic pass (#3013 adversarial review).
305
+ */
306
+ export interface IndexedReviewArtifact {
307
+ path: string;
308
+ sha256: string;
309
+ }
310
+
311
+ export interface DispositionProvenanceContext {
312
+ /** CLI `--stage_n` for this disposition write; must equal `plannerStageN`. */
313
+ expectedStageN: number;
314
+ /**
315
+ * Persisted Architect/Critic (and other) stage artifacts from this run's
316
+ * `index.jsonl`, keyed by `${stage}\u0000${stageN}`.
317
+ */
318
+ indexedArtifacts: ReadonlyMap<string, IndexedReviewArtifact>;
319
+ }
320
+
321
+ /** Stable map key for a staged artifact identity in the run index. */
322
+ export function reviewArtifactIndexKey(stage: string, stageN: number): string {
323
+ return `${stage}\u0000${stageN}`;
324
+ }
325
+
326
+ /**
327
+ * Cross-check every finding's source receipt against the CLI stage number and
328
+ * the run's persisted Architect/Critic artifact index. Fail closed on mismatch
329
+ * or spoofed path/hash attestations.
330
+ */
331
+ export function assertDispositionProvenance(
332
+ doc: ReviewConflictDocument,
333
+ provenance: DispositionProvenanceContext,
334
+ ): void {
335
+ if (doc.plannerStageN !== provenance.expectedStageN) {
336
+ throw new Error(
337
+ `disposition provenance: plannerStageN=${doc.plannerStageN} does not match CLI --stage_n=${provenance.expectedStageN}`,
338
+ );
339
+ }
340
+
341
+ for (let i = 0; i < doc.findings.length; i++) {
342
+ const finding = doc.findings[i]!;
343
+ const field = `findings[${i}]`;
344
+ const receipt = finding.sourceReceipt;
345
+
346
+ if (receipt.stage !== finding.sourceRole) {
347
+ throw new Error(`${field}.sourceReceipt.stage=${receipt.stage} must equal sourceRole=${finding.sourceRole}`);
348
+ }
349
+ if (receipt.stageN !== doc.plannerStageN) {
350
+ throw new Error(
351
+ `${field}.sourceReceipt.stageN=${receipt.stageN} must equal plannerStageN=${doc.plannerStageN} (same-pass join)`,
352
+ );
353
+ }
354
+
355
+ const indexed = provenance.indexedArtifacts.get(reviewArtifactIndexKey(receipt.stage, receipt.stageN));
356
+ if (!indexed) {
357
+ throw new Error(
358
+ `${field}.sourceReceipt: no persisted ${receipt.stage} stage ${receipt.stageN} artifact in run index`,
359
+ );
360
+ }
361
+ if (indexed.path !== receipt.path) {
362
+ throw new Error(
363
+ `${field}.sourceReceipt.path does not match indexed ${receipt.stage} stage ${receipt.stageN} path`,
364
+ );
365
+ }
366
+ if (indexed.sha256 !== receipt.sha256) {
367
+ throw new Error(
368
+ `${field}.sourceReceipt.sha256 does not match indexed ${receipt.stage} stage ${receipt.stageN} sha256`,
369
+ );
370
+ }
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Parse and validate a disposition-stage document. Re-derives conflicts from
376
+ * findings when omitted, then fails closed unless every conflict is dispositioned
377
+ * (or findings produce zero conflicts and dispositions are empty).
378
+ *
379
+ * When `provenance` is provided (CLI write path), also enforces authoritative
380
+ * same-pass receipt checks against the run index.
381
+ */
382
+ export function parseReviewConflictDocument(
383
+ raw: string | unknown,
384
+ provenance?: DispositionProvenanceContext,
385
+ ): ReviewConflictDocument {
386
+ const value = typeof raw === "string" ? parseReviewConflictJson(raw) : raw;
387
+ if (!isObject(value)) throw new Error("disposition document must be a JSON object");
388
+
389
+ const schema = nonEmptyString(value.schema, "schema");
390
+ if (schema !== RALPLAN_REVIEW_CONFLICTS_SCHEMA) {
391
+ throw new Error(`schema must be ${RALPLAN_REVIEW_CONFLICTS_SCHEMA}`);
392
+ }
393
+
394
+ const plannerStageN = positiveInt(value.plannerStageN ?? value.planner_stage_n, "plannerStageN");
395
+ if (!Array.isArray(value.findings)) throw new Error("findings must be an array");
396
+ const findings = value.findings.map((entry, i) => parseFinding(entry, i));
397
+ const findingIds = new Set(findings.map(f => f.findingId));
398
+ if (findingIds.size !== findings.length) throw new Error("findingId values must be unique");
399
+
400
+ // Structural role/stage alignment is always required (even without index).
401
+ for (let i = 0; i < findings.length; i++) {
402
+ const f = findings[i]!;
403
+ if (f.sourceReceipt.stage !== f.sourceRole) {
404
+ throw new Error(
405
+ `findings[${i}].sourceReceipt.stage=${f.sourceReceipt.stage} must equal sourceRole=${f.sourceRole}`,
406
+ );
407
+ }
408
+ if (f.sourceReceipt.stageN !== plannerStageN) {
409
+ throw new Error(
410
+ `findings[${i}].sourceReceipt.stageN=${f.sourceReceipt.stageN} must equal plannerStageN=${plannerStageN}`,
411
+ );
412
+ }
413
+ }
414
+
415
+ const dispositions = Array.isArray(value.dispositions)
416
+ ? value.dispositions.map((entry, i) => parseDisposition(entry, i))
417
+ : [];
418
+ const dispositionConflictIds = new Set(dispositions.map(d => d.conflictId));
419
+ if (dispositionConflictIds.size !== dispositions.length) {
420
+ throw new Error("disposition conflictId values must be unique");
421
+ }
422
+
423
+ const derived = detectReviewConflicts(findings);
424
+ const provided = Array.isArray(value.conflicts) ? value.conflicts : undefined;
425
+ let conflicts: ReviewConflict[];
426
+ if (provided === undefined) {
427
+ conflicts = applyDispositions(derived, dispositions);
428
+ } else {
429
+ // Accept provided conflict ids only when they match derived pairs.
430
+ const derivedById = new Map(derived.map(c => [c.conflictId, c]));
431
+ conflicts = provided.map((entry, i) => {
432
+ if (!isObject(entry)) throw new Error(`conflicts[${i}] must be an object`);
433
+ const conflictId = nonEmptyString(entry.conflictId ?? entry.conflict_id, `conflicts[${i}].conflictId`);
434
+ const derivedConflict = derivedById.get(conflictId);
435
+ if (!derivedConflict) {
436
+ throw new Error(`conflicts[${i}] ${conflictId} is not derived from findings`);
437
+ }
438
+ return derivedConflict;
439
+ });
440
+ // Include any derived conflicts omitted from the payload so join cannot skip them.
441
+ for (const derivedConflict of derived) {
442
+ if (!conflicts.some(c => c.conflictId === derivedConflict.conflictId)) {
443
+ conflicts.push(derivedConflict);
444
+ }
445
+ }
446
+ conflicts = applyDispositions(conflicts, dispositions);
447
+ }
448
+
449
+ const gate = evaluateReviewJoinGate(findings, dispositions, conflicts);
450
+ if (!gate.ok) {
451
+ throw new Error(gate.message);
452
+ }
453
+
454
+ const doc: ReviewConflictDocument = {
455
+ schema: RALPLAN_REVIEW_CONFLICTS_SCHEMA,
456
+ plannerStageN,
457
+ findings,
458
+ conflicts,
459
+ dispositions,
460
+ };
461
+
462
+ if (provenance) {
463
+ assertDispositionProvenance(doc, provenance);
464
+ }
465
+
466
+ return doc;
467
+ }
468
+
469
+ /** Canonical JSON serialization for disposition-stage artifacts. */
470
+ export function serializeReviewConflictDocument(doc: ReviewConflictDocument): string {
471
+ return `${JSON.stringify(doc, null, 2)}\n`;
472
+ }