@gajae-code/coding-agent 0.18.12 → 0.19.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 (305) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/dist/types/capability/rule.d.ts +7 -2
  3. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  4. package/dist/types/cli/args.d.ts +7 -2
  5. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  6. package/dist/types/cli/customize-doctor.d.ts +4 -3
  7. package/dist/types/cli/public-command-errors.d.ts +1 -0
  8. package/dist/types/cli/root-flags.d.ts +0 -12
  9. package/dist/types/cli/update-cli.d.ts +7 -2
  10. package/dist/types/cli-main.d.ts +0 -6
  11. package/dist/types/commands/launch.d.ts +0 -6
  12. package/dist/types/commands/migrate.d.ts +1 -1
  13. package/dist/types/commands/sdk.d.ts +8 -0
  14. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  15. package/dist/types/config/autorouting-tier-map.d.ts +23 -1
  16. package/dist/types/config/config-hot-reload.d.ts +5 -0
  17. package/dist/types/config/file-lock.d.ts +44 -2
  18. package/dist/types/config/model-profile-activation.d.ts +19 -5
  19. package/dist/types/config/model-registry.d.ts +16 -3
  20. package/dist/types/config/model-resolver.d.ts +5 -4
  21. package/dist/types/config/settings-schema.d.ts +11 -1
  22. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  23. package/dist/types/customization/import.d.ts +2 -0
  24. package/dist/types/customization/types.d.ts +1 -1
  25. package/dist/types/dap/session.d.ts +11 -2
  26. package/dist/types/discovery/helpers.d.ts +7 -2
  27. package/dist/types/edit/index.d.ts +5 -0
  28. package/dist/types/edit/modes/patch.d.ts +9 -0
  29. package/dist/types/eval/py/runtime.d.ts +9 -0
  30. package/dist/types/export/ttsr.d.ts +19 -0
  31. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  32. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  33. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  34. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  36. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  37. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  38. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  39. package/dist/types/hindsight/content.d.ts +5 -6
  40. package/dist/types/internal-urls/types.d.ts +2 -0
  41. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  42. package/dist/types/main.d.ts +23 -18
  43. package/dist/types/migrate/action-planner.d.ts +10 -1
  44. package/dist/types/migrate/adapters/codex.d.ts +1 -1
  45. package/dist/types/migrate/adapters/index.d.ts +35 -3
  46. package/dist/types/migrate/hook-module.d.ts +23 -0
  47. package/dist/types/migrate/report.d.ts +2 -1
  48. package/dist/types/migrate/types.d.ts +69 -2
  49. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  50. package/dist/types/modes/components/model-selector.d.ts +43 -0
  51. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  52. package/dist/types/modes/utils/host-status.d.ts +15 -11
  53. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  54. package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
  55. package/dist/types/sdk/broker/broker.d.ts +22 -1
  56. package/dist/types/sdk/broker/ensure.d.ts +5 -2
  57. package/dist/types/sdk/broker/hop.d.ts +9 -0
  58. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  59. package/dist/types/sdk/broker/session-index.d.ts +2 -0
  60. package/dist/types/sdk/broker/transport.d.ts +2 -0
  61. package/dist/types/sdk/bus/index.d.ts +21 -0
  62. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
  63. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  64. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  65. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  66. package/dist/types/sdk/host/events.d.ts +4 -0
  67. package/dist/types/sdk/host/host.d.ts +2 -1
  68. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  69. package/dist/types/sdk/host/session-runtime.d.ts +8 -8
  70. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  71. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  72. package/dist/types/sdk/prompt-status.d.ts +11 -4
  73. package/dist/types/session/agent-session.d.ts +50 -8
  74. package/dist/types/session/session-manager.d.ts +16 -0
  75. package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
  76. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  77. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  78. package/dist/types/system-prompt.d.ts +3 -2
  79. package/dist/types/task/role-body.d.ts +11 -0
  80. package/dist/types/tools/awaiting-input.d.ts +28 -0
  81. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
  82. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  83. package/dist/types/tools/bash.d.ts +7 -0
  84. package/dist/types/tools/descriptors.d.ts +13 -2
  85. package/dist/types/tools/fetch.d.ts +6 -0
  86. package/dist/types/tools/irc.d.ts +5 -3
  87. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  88. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  89. package/dist/types/tools/write.d.ts +2 -0
  90. package/dist/types/utils/file-mentions.d.ts +5 -0
  91. package/dist/types/utils/image-limits.d.ts +7 -0
  92. package/dist/types/utils/image-loading.d.ts +0 -1
  93. package/dist/types/utils/open.d.ts +0 -1
  94. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  95. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  96. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  97. package/dist/types/utils/title-generator.d.ts +13 -3
  98. package/dist/types/web/search/providers/codex.d.ts +6 -7
  99. package/package.json +9 -8
  100. package/scripts/acp-conformance-agent.ts +12 -2
  101. package/scripts/generate-sdk-operation-inventory.ts +16 -0
  102. package/src/capability/fs.ts +31 -3
  103. package/src/capability/rule.ts +11 -2
  104. package/src/cli/append-system-prompt.ts +12 -0
  105. package/src/cli/args.ts +11 -7
  106. package/src/cli/customize-doctor-precedence.ts +31 -0
  107. package/src/cli/customize-doctor.ts +49 -39
  108. package/src/cli/fast-help.ts +0 -2
  109. package/src/cli/migrate-cli.ts +14 -3
  110. package/src/cli/public-command-errors.ts +2 -0
  111. package/src/cli/root-flags.ts +0 -2
  112. package/src/cli/update-cli.ts +247 -6
  113. package/src/commands/harness.ts +25 -15
  114. package/src/commands/migrate.ts +3 -3
  115. package/src/commands/sdk.ts +81 -11
  116. package/src/commit/agentic/agent.ts +6 -6
  117. package/src/commit/analysis/conventional.ts +14 -5
  118. package/src/commit/map-reduce/index.ts +7 -7
  119. package/src/commit/pipeline.ts +12 -12
  120. package/src/config/autorouting-tier-map.ts +20 -1
  121. package/src/config/config-file.ts +106 -3
  122. package/src/config/config-hot-reload.ts +8 -0
  123. package/src/config/file-lock.ts +1431 -304
  124. package/src/config/keybindings.ts +154 -7
  125. package/src/config/model-profile-activation.ts +177 -39
  126. package/src/config/model-registry.ts +380 -55
  127. package/src/config/model-resolver.ts +49 -21
  128. package/src/config/settings-schema.ts +13 -1
  129. package/src/config/settings.ts +2 -0
  130. package/src/coordinator-mcp/server.ts +37 -2
  131. package/src/crash/sanitize.ts +3 -1
  132. package/src/customization/import.ts +276 -7
  133. package/src/customization/inventory.ts +24 -8
  134. package/src/customization/types.ts +1 -0
  135. package/src/dap/client.ts +3 -2
  136. package/src/dap/session.ts +161 -36
  137. package/src/debug/crash-diagnostics.ts +87 -5
  138. package/src/debug/report-bundle.ts +71 -2
  139. package/src/discovery/builtin.ts +4 -3
  140. package/src/discovery/helpers.ts +24 -14
  141. package/src/edit/index.ts +191 -5
  142. package/src/edit/modes/patch.ts +27 -0
  143. package/src/eval/py/runtime.ts +44 -4
  144. package/src/exec/non-interactive-env.ts +0 -1
  145. package/src/export/html/index.ts +60 -20
  146. package/src/export/ttsr.ts +176 -1
  147. package/src/extensibility/extensions/loader.ts +2 -0
  148. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  149. package/src/extensibility/extensions/types.ts +5 -1
  150. package/src/extensibility/hooks/loader.ts +10 -2
  151. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  152. package/src/extensibility/slash-commands.ts +5 -0
  153. package/src/gjc-runtime/gc-runtime.ts +4 -1
  154. package/src/gjc-runtime/launch-tmux.ts +23 -13
  155. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  156. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  157. package/src/gjc-runtime/state-writer.ts +109 -5
  158. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  159. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  160. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  161. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  162. package/src/harness-control-plane/finalize.ts +11 -0
  163. package/src/harness-control-plane/receipt-spool.ts +105 -28
  164. package/src/harness-control-plane/storage.ts +124 -6
  165. package/src/hindsight/content.ts +116 -16
  166. package/src/hindsight/mental-models.ts +22 -10
  167. package/src/hindsight/state.ts +7 -4
  168. package/src/hooks/native-skill-hook.ts +3 -1
  169. package/src/internal-urls/artifact-protocol.ts +3 -1
  170. package/src/internal-urls/docs-index.generated.ts +14 -14
  171. package/src/internal-urls/local-protocol.ts +28 -3
  172. package/src/internal-urls/types.ts +2 -0
  173. package/src/lsp/client.ts +2 -1
  174. package/src/lsp/clients/biome-client.ts +2 -0
  175. package/src/lsp/clients/swiftlint-client.ts +2 -0
  176. package/src/main.ts +330 -92
  177. package/src/memories/index.ts +25 -2
  178. package/src/migrate/action-planner.ts +517 -2
  179. package/src/migrate/adapters/claude-code.ts +141 -3
  180. package/src/migrate/adapters/codex.ts +57 -5
  181. package/src/migrate/adapters/index.ts +240 -5
  182. package/src/migrate/adapters/opencode.ts +4 -0
  183. package/src/migrate/executor.ts +141 -3
  184. package/src/migrate/hook-module.ts +193 -0
  185. package/src/migrate/report.ts +45 -1
  186. package/src/migrate/types.ts +73 -3
  187. package/src/modes/acp/acp-agent.ts +10 -1
  188. package/src/modes/components/agent-dashboard.ts +20 -4
  189. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  190. package/src/modes/components/customization/import-wizard.ts +29 -3
  191. package/src/modes/components/model-selector.ts +157 -103
  192. package/src/modes/components/welcome.ts +3 -1
  193. package/src/modes/controllers/command-controller.ts +271 -16
  194. package/src/modes/controllers/event-controller.ts +1 -1
  195. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  196. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  197. package/src/modes/controllers/input-controller.ts +2 -0
  198. package/src/modes/controllers/plan-mode-controller.ts +49 -4
  199. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  200. package/src/modes/controllers/selector-controller.ts +14 -1
  201. package/src/modes/interactive-mode.ts +55 -4
  202. package/src/modes/prompt-suggestion-controller.ts +1 -1
  203. package/src/modes/runtime-init.ts +5 -1
  204. package/src/modes/utils/host-status.ts +21 -10
  205. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  206. package/src/prompts/system/irc-incoming.md +3 -1
  207. package/src/prompts/tools/irc.md +1 -1
  208. package/src/runtime-mcp/manager.ts +8 -3
  209. package/src/runtime-mcp/project-host-values.ts +12 -8
  210. package/src/runtime-mcp/tool-bridge.ts +68 -4
  211. package/src/runtime-mcp/transports/stdio.ts +2 -1
  212. package/src/sdk/broker/broker-exit.ts +2 -0
  213. package/src/sdk/broker/broker.ts +143 -4
  214. package/src/sdk/broker/ensure.ts +74 -6
  215. package/src/sdk/broker/hop.ts +13 -1
  216. package/src/sdk/broker/lifecycle-ledger.ts +3 -0
  217. package/src/sdk/broker/lifecycle.ts +102 -6
  218. package/src/sdk/broker/session-index.ts +29 -4
  219. package/src/sdk/broker/transport.ts +17 -1
  220. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  221. package/src/sdk/bus/index.ts +408 -14
  222. package/src/sdk/bus/session-control.ts +2 -0
  223. package/src/sdk/bus/slack-daemon.ts +105 -9
  224. package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
  225. package/src/sdk/bus/telegram-daemon.ts +10 -2
  226. package/src/sdk/bus/threaded-render.ts +20 -1
  227. package/src/sdk/cli/session-cli.ts +2 -0
  228. package/src/sdk/host/control/dispatch.ts +13 -1
  229. package/src/sdk/host/control/operations.ts +10 -1
  230. package/src/sdk/host/events.ts +67 -4
  231. package/src/sdk/host/host.ts +8 -2
  232. package/src/sdk/host/prompt-image-upload.ts +438 -0
  233. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  234. package/src/sdk/host/session-runtime.test.ts +1537 -24
  235. package/src/sdk/host/session-runtime.ts +332 -36
  236. package/src/sdk/host/surface-policy.ts +4 -0
  237. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  238. package/src/sdk/host/websocket-transport.ts +19 -7
  239. package/src/sdk/lifecycle/service.ts +14 -1
  240. package/src/sdk/prompt-status.ts +6 -4
  241. package/src/sdk/protocol/adapter-validation.ts +28 -1
  242. package/src/sdk/protocol/operation-inventory.generated.json +160 -0
  243. package/src/sdk/protocol/operation-registry.ts +33 -3
  244. package/src/sdk/session.ts +123 -36
  245. package/src/session/agent-session.ts +1614 -329
  246. package/src/session/history-storage.ts +182 -12
  247. package/src/session/internal/managed-session-scope.ts +47 -18
  248. package/src/session/internal/managed-session-storage.ts +71 -1
  249. package/src/session/session-manager.ts +90 -5
  250. package/src/session-import/redact.ts +53 -2
  251. package/src/skill-state/workflow-mutation-guard.ts +1498 -18
  252. package/src/slash-commands/builtin-registry.ts +6 -3
  253. package/src/slash-commands/helpers/mcp.ts +11 -3
  254. package/src/ssh/ssh-executor.ts +9 -2
  255. package/src/stt/recorder.ts +26 -9
  256. package/src/system-prompt.ts +61 -16
  257. package/src/task/executor.ts +12 -8
  258. package/src/task/role-body.ts +84 -0
  259. package/src/task/token-log-mode.test.ts +162 -0
  260. package/src/task/token-log.ts +91 -2
  261. package/src/tools/archive-reader.ts +650 -37
  262. package/src/tools/ask.ts +1 -0
  263. package/src/tools/ast-grep.ts +7 -2
  264. package/src/tools/auto-generated-guard.ts +12 -3
  265. package/src/tools/awaiting-input.ts +114 -0
  266. package/src/tools/bash-allowed-prefixes.ts +61 -1
  267. package/src/tools/bash-skill-urls.ts +58 -5
  268. package/src/tools/bash.ts +99 -26
  269. package/src/tools/browser/tab-worker.ts +39 -13
  270. package/src/tools/browser.ts +17 -7
  271. package/src/tools/cron.ts +10 -1
  272. package/src/tools/debug.ts +9 -2
  273. package/src/tools/descriptors.test.ts +4 -4
  274. package/src/tools/descriptors.ts +16 -4
  275. package/src/tools/eval.ts +15 -0
  276. package/src/tools/fetch.ts +90 -2
  277. package/src/tools/gh.ts +27 -12
  278. package/src/tools/image-gen.ts +20 -6
  279. package/src/tools/irc.ts +5 -3
  280. package/src/tools/monitor.ts +9 -1
  281. package/src/tools/output-meta.ts +51 -3
  282. package/src/tools/read.ts +9 -2
  283. package/src/tools/recipe/runner.ts +23 -12
  284. package/src/tools/render-utils.ts +2 -2
  285. package/src/tools/search.ts +3 -2
  286. package/src/tools/ssh.ts +274 -8
  287. package/src/tools/subagent.ts +10 -4
  288. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  289. package/src/tools/tool-catalog.generated.ts +1 -1
  290. package/src/tools/ultragoal-ask-guard.ts +13 -1
  291. package/src/tools/write.ts +7 -0
  292. package/src/tui/tree-list.ts +15 -1
  293. package/src/utils/commit-message-generator.ts +3 -2
  294. package/src/utils/external-editor.ts +21 -7
  295. package/src/utils/file-mentions.ts +46 -6
  296. package/src/utils/git.ts +38 -7
  297. package/src/utils/image-limits.ts +8 -0
  298. package/src/utils/image-loading.ts +1 -1
  299. package/src/utils/open.ts +73 -4
  300. package/src/utils/pasted-image-loading.ts +9 -8
  301. package/src/utils/prompt-suggestion.ts +5 -4
  302. package/src/utils/shell-snapshot.ts +9 -1
  303. package/src/utils/title-generator.ts +65 -20
  304. package/src/web/search/index.ts +2 -1
  305. package/src/web/search/providers/codex.ts +29 -32
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Validate the exact current ralplan final that an execution approval may target.
3
+ *
4
+ * The Ultragoal ask guard blocks every `ask` while a goal is incomplete or
5
+ * review-blocked, which is correct for ordinary questions but also deadlocks the
6
+ * one question that can legitimately unblock the work: the execution approval for
7
+ * an already-reviewed ralplan final (issue #6578). Granting that route needs proof
8
+ * that the final being approved is the current one and that its reviewers really
9
+ * passed it — never a caller-declared tag.
10
+ *
11
+ * The proof is reconstructed from what ralplan already persists:
12
+ *
13
+ * - `<session plans>/ralplan/<run-id>/index.jsonl` rows `{stage, stage_n, path, sha256}`
14
+ * - the artifact files those rows name, re-hashed here
15
+ * - the run state (`<session state>/ralplan-state.json`) for run id and phase
16
+ * - the resolved `autoHandoff` target, which must be `off`
17
+ *
18
+ * Anything missing, stale, rewritten, out of order, or outside this session's run
19
+ * directory is a typed refusal. This module only reads and hashes; it never
20
+ * mutates state and never grants anything by itself.
21
+ */
22
+ import * as fs from "node:fs/promises";
23
+ import * as path from "node:path";
24
+ import { isEnoent } from "@gajae-code/utils";
25
+ import { resolveRalplanAutoHandoff } from "./ralplan-runtime";
26
+ import { modeStatePath, sessionPlansDir } from "./session-layout";
27
+
28
+ /** Stages that open a consensus iteration; a final is only valid after one. */
29
+ const ITERATION_OPENER_STAGES: ReadonlySet<string> = new Set(["planner", "revision"]);
30
+
31
+ /** Reviewer stages whose verdicts must join the same iteration as the final. */
32
+ const REVIEW_LANE_STAGES: readonly string[] = ["architect", "critic"];
33
+
34
+ export interface RalplanIndexRow {
35
+ stage: string;
36
+ stageN: number;
37
+ path: string;
38
+ sha256: string;
39
+ }
40
+
41
+ export interface RalplanCurrentFinalDescriptor {
42
+ /** Run id the final belongs to. */
43
+ runId: string;
44
+ /** Absolute path of the final artifact. */
45
+ finalPath: string;
46
+ /** sha256 recorded for the final and re-verified against its bytes. */
47
+ finalSha256: string;
48
+ /** sha256 of the iteration-opening planner (or revision) artifact. */
49
+ plannerSha256: string;
50
+ /** Reviewer verdict hashes that joined this iteration. */
51
+ reviewerSha256: Record<string, string>;
52
+ /** Session that owns the run; a foreign session can never be granted. */
53
+ sessionId: string;
54
+ }
55
+
56
+ export type RalplanCurrentFinalRefusalCode =
57
+ | "missing_state"
58
+ | "run_not_final"
59
+ | "auto_handoff_enabled"
60
+ | "missing_index"
61
+ | "malformed_index"
62
+ | "no_iteration_opener"
63
+ | "final_not_last"
64
+ | "missing_reviewers"
65
+ | "artifact_missing"
66
+ | "artifact_outside_run"
67
+ | "hash_mismatch";
68
+
69
+ export interface RalplanCurrentFinalRefusal {
70
+ code: RalplanCurrentFinalRefusalCode;
71
+ /** Human-readable reason suitable for a guard error message. */
72
+ reason: string;
73
+ }
74
+
75
+ export type RalplanCurrentFinalResolution =
76
+ | { ok: true; descriptor: RalplanCurrentFinalDescriptor }
77
+ | { ok: false; refusal: RalplanCurrentFinalRefusal };
78
+
79
+ export interface RalplanCurrentFinalInput {
80
+ cwd: string;
81
+ /** Caller session id; the run must belong to it. */
82
+ sessionId: string;
83
+ /** Optional agent dir for settings resolution. */
84
+ agentDir?: string;
85
+ }
86
+
87
+ /**
88
+ * Whether the pending `ask` payload selects the ralplan execution-approval gate.
89
+ *
90
+ * This is a selector, not authority: it only decides which validation runs. The
91
+ * descriptor it leads to is the proof.
92
+ */
93
+ export function selectsRalplanApprovalGate(pendingQuestions: unknown): boolean {
94
+ const questions = (pendingQuestions as { questions?: unknown } | undefined)?.questions;
95
+ if (!Array.isArray(questions)) return false;
96
+ return questions.some(question => {
97
+ if (!question || typeof question !== "object" || Array.isArray(question)) return false;
98
+ const gate = (question as { workflowGate?: unknown }).workflowGate;
99
+ if (!gate || typeof gate !== "object" || Array.isArray(gate)) return false;
100
+ const record = gate as { stage?: unknown; kind?: unknown };
101
+ return record.stage === "ralplan" && record.kind === "approval";
102
+ });
103
+ }
104
+
105
+ function refusal(code: RalplanCurrentFinalRefusalCode, reason: string): RalplanCurrentFinalRefusal {
106
+ return { code, reason };
107
+ }
108
+
109
+ function parseIndexRow(line: string): RalplanIndexRow | undefined {
110
+ const trimmed = line.trim();
111
+ if (!trimmed) return undefined;
112
+ let parsed: unknown;
113
+ try {
114
+ parsed = JSON.parse(trimmed);
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
119
+ const record = parsed as Record<string, unknown>;
120
+ const { stage, path: artifactPath, sha256 } = record;
121
+ const stageN = record.stage_n;
122
+ if (typeof stage !== "string" || !stage.trim()) return undefined;
123
+ if (typeof stageN !== "number" || !Number.isInteger(stageN) || stageN <= 0) return undefined;
124
+ if (typeof artifactPath !== "string" || !artifactPath.trim()) return undefined;
125
+ if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(sha256)) return undefined;
126
+ return { stage: stage.trim(), stageN, path: artifactPath.trim(), sha256: sha256.toLowerCase() };
127
+ }
128
+
129
+ async function readRunState(statePath: string): Promise<{ runId: string; phase: string } | RalplanCurrentFinalRefusal> {
130
+ let text: string;
131
+ try {
132
+ text = await fs.readFile(statePath, "utf-8");
133
+ } catch (error) {
134
+ if (isEnoent(error)) return refusal("missing_state", `no ralplan run state at ${statePath}`);
135
+ return refusal("missing_state", `failed to read ralplan run state at ${statePath}`);
136
+ }
137
+ let parsed: unknown;
138
+ try {
139
+ parsed = JSON.parse(text);
140
+ } catch {
141
+ return refusal("malformed_index", `ralplan run state at ${statePath} is not valid JSON`);
142
+ }
143
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
144
+ return refusal("malformed_index", `ralplan run state at ${statePath} is not an object`);
145
+ }
146
+ const record = parsed as Record<string, unknown>;
147
+ const runId = typeof record.run_id === "string" ? record.run_id.trim() : "";
148
+ const phase = typeof record.current_phase === "string" ? record.current_phase.trim().toLowerCase() : "";
149
+ if (!runId) return refusal("malformed_index", `ralplan run state at ${statePath} has no run_id`);
150
+ return { runId, phase };
151
+ }
152
+
153
+ /**
154
+ * Resolve and verify the current ralplan final for `sessionId`.
155
+ *
156
+ * Fails closed: every check returns a refusal instead of degrading to a weaker
157
+ * proof, because the caller uses a success to lift the Ultragoal ask guard.
158
+ */
159
+ export async function resolveRalplanCurrentFinal(
160
+ input: RalplanCurrentFinalInput,
161
+ ): Promise<RalplanCurrentFinalResolution> {
162
+ const sessionId = input.sessionId.trim();
163
+ if (!sessionId) return { ok: false, refusal: refusal("missing_state", "no caller session id") };
164
+
165
+ const state = await readRunState(modeStatePath(input.cwd, sessionId, "ralplan"));
166
+ if (!("runId" in state)) return { ok: false, refusal: state };
167
+ if (state.phase !== "final" && state.phase !== "handoff") {
168
+ return {
169
+ ok: false,
170
+ refusal: refusal("run_not_final", `ralplan run ${state.runId} is in phase "${state.phase}", not final`),
171
+ };
172
+ }
173
+
174
+ // A final that would hand off automatically is not waiting for approval.
175
+ const autoHandoff = await resolveRalplanAutoHandoff(input.cwd, { agentDir: input.agentDir });
176
+ if (autoHandoff.effectiveTarget !== "off") {
177
+ return {
178
+ ok: false,
179
+ refusal: refusal(
180
+ "auto_handoff_enabled",
181
+ `ralplan autoHandoff is "${autoHandoff.effectiveTarget}"; a current final is only approvable while handoff is off`,
182
+ ),
183
+ };
184
+ }
185
+
186
+ const runDir = path.join(sessionPlansDir(input.cwd, sessionId), "ralplan", state.runId);
187
+ const indexPath = path.join(runDir, "index.jsonl");
188
+ let indexText: string;
189
+ try {
190
+ indexText = await fs.readFile(indexPath, "utf-8");
191
+ } catch (error) {
192
+ if (isEnoent(error)) return { ok: false, refusal: refusal("missing_index", `no ralplan index at ${indexPath}`) };
193
+ return { ok: false, refusal: refusal("missing_index", `failed to read ralplan index at ${indexPath}`) };
194
+ }
195
+
196
+ const rows: RalplanIndexRow[] = [];
197
+ for (const line of indexText.split("\n")) {
198
+ if (!line.trim()) continue;
199
+ const row = parseIndexRow(line);
200
+ if (!row) {
201
+ return { ok: false, refusal: refusal("malformed_index", `malformed ralplan index row in ${indexPath}`) };
202
+ }
203
+ rows.push(row);
204
+ }
205
+ if (rows.length === 0)
206
+ return { ok: false, refusal: refusal("missing_index", `ralplan index ${indexPath} is empty`) };
207
+
208
+ const finalRow = rows.reduce((latest, row) => (row.stageN > latest.stageN ? row : latest));
209
+ if (finalRow.stage !== "final") {
210
+ return {
211
+ ok: false,
212
+ refusal: refusal("final_not_last", `the newest ralplan stage is "${finalRow.stage}", not "final"`),
213
+ };
214
+ }
215
+
216
+ const earlierRows = rows.filter(row => row.stageN < finalRow.stageN);
217
+ const opener = earlierRows
218
+ .filter(row => ITERATION_OPENER_STAGES.has(row.stage))
219
+ .reduce<RalplanIndexRow | undefined>(
220
+ (latest, row) => (!latest || row.stageN > latest.stageN ? row : latest),
221
+ undefined,
222
+ );
223
+ if (!opener) {
224
+ return {
225
+ ok: false,
226
+ refusal: refusal("no_iteration_opener", "the final has no planner or revision stage before it"),
227
+ };
228
+ }
229
+
230
+ const reviewers: Record<string, string> = {};
231
+ for (const lane of REVIEW_LANE_STAGES) {
232
+ const laneRow = earlierRows
233
+ .filter(row => row.stage === lane && row.stageN > opener.stageN)
234
+ .reduce<RalplanIndexRow | undefined>(
235
+ (latest, row) => (!latest || row.stageN > latest.stageN ? row : latest),
236
+ undefined,
237
+ );
238
+ if (!laneRow) {
239
+ return {
240
+ ok: false,
241
+ refusal: refusal(
242
+ "missing_reviewers",
243
+ `no ${lane} verdict between the ${opener.stage} stage and the final; the final is not reviewed`,
244
+ ),
245
+ };
246
+ }
247
+ reviewers[lane] = laneRow.sha256;
248
+ }
249
+
250
+ // Hash every referenced artifact and require it to live inside this run.
251
+ for (const row of rows) {
252
+ const resolved = path.resolve(row.path);
253
+ const relative = path.relative(runDir, resolved);
254
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
255
+ return {
256
+ ok: false,
257
+ refusal: refusal("artifact_outside_run", `ralplan stage artifact ${row.path} is outside ${runDir}`),
258
+ };
259
+ }
260
+ let bytes: Buffer;
261
+ try {
262
+ bytes = await Bun.file(resolved)
263
+ .arrayBuffer()
264
+ .then(buffer => Buffer.from(buffer));
265
+ } catch (error) {
266
+ if (isEnoent(error)) {
267
+ return { ok: false, refusal: refusal("artifact_missing", `ralplan stage artifact ${row.path} is missing`) };
268
+ }
269
+ return { ok: false, refusal: refusal("artifact_missing", `cannot read ralplan stage artifact ${row.path}`) };
270
+ }
271
+ const actual = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
272
+ if (actual !== row.sha256) {
273
+ return {
274
+ ok: false,
275
+ refusal: refusal(
276
+ "hash_mismatch",
277
+ `ralplan stage artifact ${path.basename(row.path)} does not match its recorded hash`,
278
+ ),
279
+ };
280
+ }
281
+ }
282
+
283
+ return {
284
+ ok: true,
285
+ descriptor: {
286
+ runId: state.runId,
287
+ finalPath: path.resolve(finalRow.path),
288
+ finalSha256: finalRow.sha256,
289
+ plannerSha256: opener.sha256,
290
+ reviewerSha256: reviewers,
291
+ sessionId,
292
+ },
293
+ };
294
+ }
@@ -4,6 +4,7 @@ import * as fssync from "node:fs";
4
4
  import * as fs from "node:fs/promises";
5
5
  import * as path from "node:path";
6
6
  import { isSettingsInitialized, Settings } from "../config/settings";
7
+ import { sanitizeImportedString } from "../session-import/redact";
7
8
  import { syncSkillActiveState } from "../skill-state/active-state";
8
9
  import { buildRalplanHudSummary } from "../skill-state/workflow-hud";
9
10
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
@@ -2319,6 +2320,97 @@ function buildIndexedReviewArtifacts(indexText: string | undefined): Map<string,
2319
2320
  return map;
2320
2321
  }
2321
2322
 
2323
+ function scrubImported(value: string): string {
2324
+ return sanitizeImportedString(value);
2325
+ }
2326
+
2327
+ /** Scrub, then trim the same way the disposition parser does. An empty result is not an id. */
2328
+ function canonicalDispositionId(value: string): string {
2329
+ const canonical = scrubImported(value).trim();
2330
+ if (canonical === "") {
2331
+ throw new RalplanCommandError(
2332
+ 2,
2333
+ "refusing to persist ralplan disposition: credential scrub removed an identifier",
2334
+ );
2335
+ }
2336
+ return canonical;
2337
+ }
2338
+
2339
+ /**
2340
+ * Scrub a validated disposition, then rebuild conflict ids from the scrubbed
2341
+ * finding and target ids. Only `sourceReceipt.path` and `sourceReceipt.sha256`
2342
+ * stay literal. A result that no longer parses is refused instead of stored.
2343
+ */
2344
+ function scrubDispositionDocument(normalized: string): string {
2345
+ const doc = parseReviewConflictDocument(normalized);
2346
+ const findingIds = new Map(
2347
+ doc.findings.map(finding => [finding.findingId, canonicalDispositionId(finding.findingId)]),
2348
+ );
2349
+ const targetIds = new Map<string, string>();
2350
+ const originalsByScrubbedTarget = new Map<string, string>();
2351
+ for (const finding of doc.findings) {
2352
+ const canonical = canonicalDispositionId(finding.targetId);
2353
+ const prior = originalsByScrubbedTarget.get(canonical);
2354
+ if (prior !== undefined && prior !== finding.targetId) {
2355
+ throw new RalplanCommandError(
2356
+ 2,
2357
+ "refusing to persist ralplan disposition: distinct target ids collapse after credential scrub",
2358
+ );
2359
+ }
2360
+ originalsByScrubbedTarget.set(canonical, finding.targetId);
2361
+ targetIds.set(finding.targetId, canonical);
2362
+ }
2363
+ const priorConflicts = doc.conflicts.map(conflict => ({
2364
+ conflictId: conflict.conflictId,
2365
+ targetId: conflict.targetId,
2366
+ findingIds: [...conflict.findingIds] as [string, string],
2367
+ }));
2368
+ for (const finding of doc.findings) {
2369
+ finding.findingId = findingIds.get(finding.findingId) ?? finding.findingId;
2370
+ finding.targetId = targetIds.get(finding.targetId) ?? finding.targetId;
2371
+ finding.evidence = scrubImported(finding.evidence);
2372
+ if (finding.proposedOwner !== undefined) finding.proposedOwner = scrubImported(finding.proposedOwner);
2373
+ }
2374
+ for (const disposition of doc.dispositions) {
2375
+ const prior = priorConflicts.find(conflict => conflict.conflictId === disposition.conflictId);
2376
+ if (!prior) {
2377
+ throw new RalplanCommandError(2, "refusing to persist ralplan disposition: conflict id did not survive scrub");
2378
+ }
2379
+ const targetId = targetIds.get(prior.targetId) ?? canonicalDispositionId(prior.targetId);
2380
+ const mapped = prior.findingIds.map(id => findingIds.get(id) ?? canonicalDispositionId(id));
2381
+ const [left, right] = mapped[0]! <= mapped[1]! ? [mapped[0]!, mapped[1]!] : [mapped[1]!, mapped[0]!];
2382
+ disposition.conflictId = `conflict:${targetId}:${left}:${right}`;
2383
+ disposition.rationale = scrubImported(disposition.rationale);
2384
+ disposition.decisionOwner = scrubImported(disposition.decisionOwner);
2385
+ disposition.affectedSections = disposition.affectedSections.map(section => scrubImported(section));
2386
+ if (disposition.dispositionedAt !== undefined)
2387
+ disposition.dispositionedAt = scrubImported(disposition.dispositionedAt);
2388
+ }
2389
+ const raw = JSON.parse(serializeReviewConflictDocument(doc)) as Record<string, unknown>;
2390
+ delete raw.conflicts;
2391
+ let rewritten: ReturnType<typeof parseReviewConflictDocument>;
2392
+ try {
2393
+ rewritten = parseReviewConflictDocument(`${JSON.stringify(raw, null, 2)}\n`);
2394
+ } catch (error) {
2395
+ throw new RalplanCommandError(
2396
+ 2,
2397
+ `refusing to persist ralplan disposition: credential scrub broke the document (${error instanceof Error ? error.message : String(error)})`,
2398
+ );
2399
+ }
2400
+ return serializeReviewConflictDocument(rewritten);
2401
+ }
2402
+
2403
+ /**
2404
+ * Credential scrub for a ralplan stage body. The result is what
2405
+ * `gjc ralplan --write` hashes and stores.
2406
+ */
2407
+ export function scrubRalplanPersistedBody(stage: string, content: string): string {
2408
+ const normalized = content.endsWith("\n") ? content : `${content}\n`;
2409
+ if (stage === "disposition") return scrubDispositionDocument(normalized);
2410
+ const scrubbed = sanitizeImportedString(normalized);
2411
+ return scrubbed.endsWith("\n") ? scrubbed : `${scrubbed}\n`;
2412
+ }
2413
+
2322
2414
  async function handleArtifactWrite(
2323
2415
  args: readonly string[],
2324
2416
  cwd: string,
@@ -2380,7 +2472,9 @@ async function handleArtifactWrite(
2380
2472
  resolved.stage === "disposition"
2381
2473
  ? normalizeDispositionArtifact(resolved.artifact, resolved.stageN, indexLoad.rawText)
2382
2474
  : resolved.artifact;
2383
- const content = normalizedArtifact.endsWith("\n") ? normalizedArtifact : `${normalizedArtifact}\n`;
2475
+ // Scrub before the content hash. The ledger, dedupe, and pending-approval copy
2476
+ // all trust this digest, so it has to name the bytes that actually land on disk.
2477
+ const content = scrubRalplanPersistedBody(resolved.stage, normalizedArtifact);
2384
2478
  const sha256 = createHash("sha256").update(content).digest("hex");
2385
2479
 
2386
2480
  // Duplicate-write guard: a second `--write` for the same (stage, stage_n) must not
@@ -1158,10 +1158,113 @@ export async function updateJsonAtomic<T = unknown>(
1158
1158
  );
1159
1159
  }
1160
1160
 
1161
+ async function assertAppendRegularFile(filePath: string): Promise<void> {
1162
+ try {
1163
+ const stat = await fs.lstat(filePath);
1164
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("state append path is not a regular file");
1165
+ } catch (error) {
1166
+ if (!isErrno(error, "ENOENT")) throw error;
1167
+ }
1168
+ }
1169
+
1170
+ /**
1171
+ * Append bytes the same way `appendAuditEntry` opens its log: mode 0600, `O_NOFOLLOW`
1172
+ * when the path already exists, and `O_EXCL` when it does not. An existing mode 0666
1173
+ * file, and any other group or other permission bits, is tightened to 0600 before the write.
1174
+ */
1175
+ async function tightenReadOnlyAppendMode(filePath: string): Promise<void> {
1176
+ if (process.platform === "win32") return;
1177
+ const noFollow = fsConstants.O_NOFOLLOW ?? 0;
1178
+ const initialStat = await fs.lstat(filePath, { bigint: true });
1179
+ if (initialStat.isSymbolicLink() || !initialStat.isFile())
1180
+ throw new Error("state append path is not a regular file");
1181
+ // A duplicate idempotent append must not require a writable file. Owner-only
1182
+ // modes such as 0400 stay as they are; only group or other bits are tightened.
1183
+ const handle = await fs.open(filePath, fsConstants.O_RDONLY | noFollow);
1184
+ try {
1185
+ const openedStat = await handle.stat({ bigint: true });
1186
+ const pathStat = await fs.lstat(filePath, { bigint: true });
1187
+ const sameObject = (left: nodeFs.BigIntStats, right: nodeFs.BigIntStats) =>
1188
+ left.dev === right.dev && left.ino === right.ino && left.nlink === right.nlink;
1189
+ if (
1190
+ openedStat.isSymbolicLink() ||
1191
+ !openedStat.isFile() ||
1192
+ pathStat.isSymbolicLink() ||
1193
+ !pathStat.isFile() ||
1194
+ !sameObject(initialStat, openedStat) ||
1195
+ !sameObject(openedStat, pathStat)
1196
+ )
1197
+ throw new Error("state append path identity changed before append");
1198
+ if ((Number(openedStat.mode) & 0o077) === 0) return;
1199
+ await handle.chmod(0o600);
1200
+ const tightened = await handle.stat({ bigint: true });
1201
+ if ((Number(tightened.mode) & 0o777) !== 0o600)
1202
+ throw new Error("state append path could not be tightened to mode 0600");
1203
+ } finally {
1204
+ await handle.close();
1205
+ }
1206
+ }
1207
+
1208
+ async function appendOwnerOnlyText(filePath: string, text: string): Promise<void> {
1209
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
1210
+ await assertAppendRegularFile(filePath);
1211
+ const noFollow = process.platform === "win32" ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
1212
+ let handle: fs.FileHandle | undefined;
1213
+ let initialStat: nodeFs.BigIntStats | undefined;
1214
+ // O_EXCL matches appendAuditEntry for a missing path. Concurrent appends can
1215
+ // create the same file together, so an EEXIST loser retries the existing-file
1216
+ // open, which carries O_NOFOLLOW and will not follow a symlink.
1217
+ for (let attempt = 0; attempt < 2; attempt++) {
1218
+ initialStat = undefined;
1219
+ try {
1220
+ initialStat = await fs.lstat(filePath, { bigint: true });
1221
+ if (initialStat.isSymbolicLink() || !initialStat.isFile())
1222
+ throw new Error("state append path is not a regular file");
1223
+ } catch (error) {
1224
+ if (!isErrno(error, "ENOENT")) throw error;
1225
+ }
1226
+ const flags = initialStat
1227
+ ? fsConstants.O_WRONLY | fsConstants.O_APPEND | noFollow
1228
+ : fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_EXCL;
1229
+ try {
1230
+ handle = await fs.open(filePath, flags, 0o600);
1231
+ break;
1232
+ } catch (error) {
1233
+ if (isErrno(error, "ELOOP")) throw new Error("state append path is not a regular file");
1234
+ if (initialStat === undefined && isErrno(error, "EEXIST") && attempt === 0) continue;
1235
+ throw error;
1236
+ }
1237
+ }
1238
+ if (!handle) throw new Error("state append path identity changed before append");
1239
+ try {
1240
+ const openedStat = await handle.stat({ bigint: true });
1241
+ const pathStat = await fs.lstat(filePath, { bigint: true });
1242
+ const sameObject = (left: nodeFs.BigIntStats, right: nodeFs.BigIntStats) =>
1243
+ left.dev === right.dev && left.ino === right.ino && left.nlink === right.nlink;
1244
+ if (
1245
+ openedStat.isSymbolicLink() ||
1246
+ !openedStat.isFile() ||
1247
+ pathStat.isSymbolicLink() ||
1248
+ !pathStat.isFile() ||
1249
+ (initialStat !== undefined && !sameObject(initialStat, openedStat)) ||
1250
+ !sameObject(openedStat, pathStat)
1251
+ )
1252
+ throw new Error("state append path identity changed before append");
1253
+ if (process.platform !== "win32" && (Number(openedStat.mode) & 0o077) !== 0) {
1254
+ await handle.chmod(0o600);
1255
+ const tightened = await handle.stat({ bigint: true });
1256
+ if ((Number(tightened.mode) & 0o777) !== 0o600)
1257
+ throw new Error("state append path could not be tightened to mode 0600");
1258
+ }
1259
+ await handle.writeFile(text, "utf-8");
1260
+ } finally {
1261
+ await handle?.close();
1262
+ }
1263
+ }
1264
+
1161
1265
  export async function appendJsonl(targetPath: string, entry: unknown, options?: StateWriterOptions): Promise<string> {
1162
1266
  const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
1163
- await fs.mkdir(path.dirname(filePath), { recursive: true });
1164
- await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf-8");
1267
+ await appendOwnerOnlyText(filePath, `${JSON.stringify(entry)}\n`);
1165
1268
  await maybeAudit(filePath, options);
1166
1269
  return filePath;
1167
1270
  }
@@ -1259,12 +1362,14 @@ export async function appendJsonlIdempotent(
1259
1362
  return lockResolvedWorkflowTarget(
1260
1363
  filePath,
1261
1364
  async () => {
1365
+ await assertAppendRegularFile(filePath);
1262
1366
  const existing = await readJsonlEntries(filePath);
1263
1367
  const duplicate = findJsonlDuplicate(existing, entry, options);
1264
1368
  if (duplicate !== undefined) {
1369
+ await tightenReadOnlyAppendMode(filePath);
1265
1370
  return { path: filePath, appended: false, duplicate };
1266
1371
  }
1267
- await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf-8");
1372
+ await appendOwnerOnlyText(filePath, `${JSON.stringify(entry)}\n`);
1268
1373
  await maybeAudit(filePath, options);
1269
1374
  return { path: filePath, appended: true };
1270
1375
  },
@@ -1274,8 +1379,7 @@ export async function appendJsonlIdempotent(
1274
1379
 
1275
1380
  export async function appendText(targetPath: string, text: string, options?: StateWriterOptions): Promise<string> {
1276
1381
  const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
1277
- await fs.mkdir(path.dirname(filePath), { recursive: true });
1278
- await fs.appendFile(filePath, text, "utf-8");
1382
+ await appendOwnerOnlyText(filePath, text);
1279
1383
  await maybeAudit(filePath, options);
1280
1384
  return filePath;
1281
1385
  }