@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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 (274) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/SECURITY.md +5 -0
  5. package/package.json +112 -4
  6. package/packages/board/CHANGELOG.md +340 -0
  7. package/packages/board/CONTRIBUTING.md +7 -0
  8. package/packages/board/LICENSE +21 -0
  9. package/packages/board/README.md +88 -0
  10. package/packages/board/SECURITY.md +19 -0
  11. package/packages/board/docs/how-it-works.md +31 -0
  12. package/packages/board/docs/reference.md +278 -0
  13. package/packages/board/package.json +81 -0
  14. package/packages/board/runner/job-runner-legacy.mjs +68 -0
  15. package/packages/board/runner/job-runner.mjs +834 -0
  16. package/packages/board/runner/pty-runner-legacy.mjs +50 -0
  17. package/packages/board/runner/pty-runner.mjs +2175 -0
  18. package/packages/board/runner/state-coordinator.mjs +673 -0
  19. package/packages/board/runner/state-runner.mjs +203 -0
  20. package/packages/board/runner/title-runner.mjs +80 -0
  21. package/packages/board/src/commands/agent-board.ts +394 -0
  22. package/packages/board/src/commands/attach-decision.mjs +66 -0
  23. package/packages/board/src/commands/attach-flow.ts +251 -0
  24. package/packages/board/src/commands/bg.ts +103 -0
  25. package/packages/board/src/commands/board-model.ts +21 -0
  26. package/packages/board/src/core/atomic.mjs +184 -0
  27. package/packages/board/src/core/attach-delete.mjs +24 -0
  28. package/packages/board/src/core/attach-frame-proof.mjs +870 -0
  29. package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
  30. package/packages/board/src/core/attach-frame-stream.mjs +266 -0
  31. package/packages/board/src/core/attach-view-proof.mjs +709 -0
  32. package/packages/board/src/core/auto-state.mjs +377 -0
  33. package/packages/board/src/core/background-runs.mjs +273 -0
  34. package/packages/board/src/core/code-refs-store.mjs +315 -0
  35. package/packages/board/src/core/code-refs.mjs +913 -0
  36. package/packages/board/src/core/config.mjs +77 -0
  37. package/packages/board/src/core/control-protocol.mjs +482 -0
  38. package/packages/board/src/core/coordinator-client.mjs +633 -0
  39. package/packages/board/src/core/coordinator-journal.mjs +282 -0
  40. package/packages/board/src/core/coordinator-protocol.mjs +14 -0
  41. package/packages/board/src/core/cwd-stats.mjs +114 -0
  42. package/packages/board/src/core/dashboard-render.mjs +10 -0
  43. package/packages/board/src/core/derive.mjs +117 -0
  44. package/packages/board/src/core/diagnostics.mjs +133 -0
  45. package/packages/board/src/core/double-press.mjs +26 -0
  46. package/packages/board/src/core/editor-state-reporter.mjs +112 -0
  47. package/packages/board/src/core/events.mjs +286 -0
  48. package/packages/board/src/core/evidence.mjs +245 -0
  49. package/packages/board/src/core/follow-up-queue.mjs +211 -0
  50. package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
  51. package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
  52. package/packages/board/src/core/frame-witness-update.mjs +35 -0
  53. package/packages/board/src/core/heuristics.mjs +313 -0
  54. package/packages/board/src/core/hold-input.mjs +27 -0
  55. package/packages/board/src/core/host-coordination.mjs +300 -0
  56. package/packages/board/src/core/host-crash.mjs +79 -0
  57. package/packages/board/src/core/host-probe.mjs +207 -0
  58. package/packages/board/src/core/host-protocol.mjs +73 -0
  59. package/packages/board/src/core/ids.mjs +35 -0
  60. package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
  61. package/packages/board/src/core/invocation.mjs +43 -0
  62. package/packages/board/src/core/launch-options.mjs +418 -0
  63. package/packages/board/src/core/launch.mjs +157 -0
  64. package/packages/board/src/core/load-config.mjs +4 -0
  65. package/packages/board/src/core/locks.mjs +530 -0
  66. package/packages/board/src/core/paths.mjs +183 -0
  67. package/packages/board/src/core/pid.mjs +75 -0
  68. package/packages/board/src/core/prewarm-schedule.mjs +41 -0
  69. package/packages/board/src/core/process-identity-probe.mjs +228 -0
  70. package/packages/board/src/core/prompt-transport.mjs +13 -0
  71. package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
  72. package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
  73. package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
  74. package/packages/board/src/core/pty-attach-render.mjs +229 -0
  75. package/packages/board/src/core/pty-links.mjs +81 -0
  76. package/packages/board/src/core/pty-scroll.mjs +522 -0
  77. package/packages/board/src/core/pty-support.mjs +327 -0
  78. package/packages/board/src/core/repo.mjs +103 -0
  79. package/packages/board/src/core/rows.mjs +414 -0
  80. package/packages/board/src/core/screen-log-gc.mjs +198 -0
  81. package/packages/board/src/core/screen-log.mjs +160 -0
  82. package/packages/board/src/core/session-view.mjs +174 -0
  83. package/packages/board/src/core/slash-commands.mjs +52 -0
  84. package/packages/board/src/core/state-commands.mjs +757 -0
  85. package/packages/board/src/core/status-consistency.mjs +98 -0
  86. package/packages/board/src/core/steering-prompts.mjs +34 -0
  87. package/packages/board/src/core/steering.mjs +133 -0
  88. package/packages/board/src/core/stop-latch.mjs +41 -0
  89. package/packages/board/src/core/store.mjs +523 -0
  90. package/packages/board/src/core/suspend.mjs +36 -0
  91. package/packages/board/src/core/terminal-attach-client.mjs +981 -0
  92. package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
  93. package/packages/board/src/core/terminal-model.mjs +222 -0
  94. package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
  95. package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
  96. package/packages/board/src/core/terminal-snapshot.mjs +440 -0
  97. package/packages/board/src/core/test-knobs.mjs +28 -0
  98. package/packages/board/src/core/title.mjs +43 -0
  99. package/packages/board/src/core/types.mjs +414 -0
  100. package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
  101. package/packages/board/src/core/worktree.mjs +65 -0
  102. package/packages/board/src/index.ts +303 -0
  103. package/packages/board/src/runtime/service.mjs +3222 -0
  104. package/packages/board/src/ui/chrome.ts +20 -0
  105. package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
  106. package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
  107. package/packages/board/src/ui/dashboard.ts +2237 -0
  108. package/packages/board/src/ui/double-left.ts +30 -0
  109. package/packages/board/src/ui/external-editor.ts +25 -0
  110. package/packages/board/src/ui/focus.ts +32 -0
  111. package/packages/board/src/ui/pty-attach.ts +3184 -0
  112. package/packages/board/src/ui/waiting.ts +30 -0
  113. package/packages/commands/LICENSE +21 -0
  114. package/packages/commands/README.md +19 -0
  115. package/packages/commands/package.json +22 -0
  116. package/packages/commands/src/index.ts +43 -0
  117. package/packages/commands/src/security-review.ts +109 -0
  118. package/packages/frame/CHANGELOG.md +113 -0
  119. package/packages/frame/CONTRIBUTING.md +7 -0
  120. package/packages/frame/LICENSE +22 -0
  121. package/packages/frame/README.md +105 -0
  122. package/packages/frame/SECURITY.md +16 -0
  123. package/packages/frame/docs/how-it-works.md +53 -0
  124. package/packages/frame/docs/reference.md +78 -0
  125. package/packages/frame/package.json +72 -0
  126. package/packages/frame/src/data/git.ts +90 -0
  127. package/packages/frame/src/data/github.ts +140 -0
  128. package/packages/frame/src/data/gitlab.ts +63 -0
  129. package/packages/frame/src/data/mode.ts +49 -0
  130. package/packages/frame/src/data/past-prompts.ts +57 -0
  131. package/packages/frame/src/data/runner.ts +32 -0
  132. package/packages/frame/src/data/snapshot.ts +223 -0
  133. package/packages/frame/src/data/start-notes.ts +104 -0
  134. package/packages/frame/src/data/telemetry.ts +337 -0
  135. package/packages/frame/src/data/todos.ts +162 -0
  136. package/packages/frame/src/data/totals.ts +94 -0
  137. package/packages/frame/src/data/visits.ts +45 -0
  138. package/packages/frame/src/index.ts +696 -0
  139. package/packages/frame/src/kit/LICENSE +21 -0
  140. package/packages/frame/src/kit/app/attribution.ts +30 -0
  141. package/packages/frame/src/kit/app/builder.ts +186 -0
  142. package/packages/frame/src/kit/app/claim.ts +27 -0
  143. package/packages/frame/src/kit/app/feature.ts +91 -0
  144. package/packages/frame/src/kit/app/host.ts +214 -0
  145. package/packages/frame/src/kit/app/renderers.ts +62 -0
  146. package/packages/frame/src/kit/app/scope.ts +125 -0
  147. package/packages/frame/src/kit/board/model.ts +337 -0
  148. package/packages/frame/src/kit/contract.ts +79 -0
  149. package/packages/frame/src/kit/contracts/accounts.ts +33 -0
  150. package/packages/frame/src/kit/contracts/ask.ts +120 -0
  151. package/packages/frame/src/kit/contracts/board.ts +6 -0
  152. package/packages/frame/src/kit/contracts/by.ts +22 -0
  153. package/packages/frame/src/kit/contracts/calls.ts +46 -0
  154. package/packages/frame/src/kit/contracts/events.ts +89 -0
  155. package/packages/frame/src/kit/contracts/mode.ts +26 -0
  156. package/packages/frame/src/kit/contracts/names.ts +12 -0
  157. package/packages/frame/src/kit/contracts/peers.ts +77 -0
  158. package/packages/frame/src/kit/contracts/prompts.ts +8 -0
  159. package/packages/frame/src/kit/contracts/screen.ts +75 -0
  160. package/packages/frame/src/kit/contracts/skills.ts +81 -0
  161. package/packages/frame/src/kit/contracts/sources.ts +81 -0
  162. package/packages/frame/src/kit/contracts/views.ts +42 -0
  163. package/packages/frame/src/kit/control.ts +59 -0
  164. package/packages/frame/src/kit/dark-theme.ts +41 -0
  165. package/packages/frame/src/kit/decode.ts +210 -0
  166. package/packages/frame/src/kit/events/log.ts +313 -0
  167. package/packages/frame/src/kit/format.ts +25 -0
  168. package/packages/frame/src/kit/host.ts +4 -0
  169. package/packages/frame/src/kit/index.ts +25 -0
  170. package/packages/frame/src/kit/legacy-tasks.ts +39 -0
  171. package/packages/frame/src/kit/overlay-images.ts +36 -0
  172. package/packages/frame/src/kit/paths.ts +16 -0
  173. package/packages/frame/src/kit/screen/client.ts +74 -0
  174. package/packages/frame/src/kit/screen/feature.ts +64 -0
  175. package/packages/frame/src/kit/screen/model.ts +182 -0
  176. package/packages/frame/src/kit/screen/view.ts +838 -0
  177. package/packages/frame/src/kit/settings/files.ts +90 -0
  178. package/packages/frame/src/kit/settings/setting.ts +57 -0
  179. package/packages/frame/src/kit/settings/store.ts +192 -0
  180. package/packages/frame/src/kit/sources/file.ts +129 -0
  181. package/packages/frame/src/kit/sources/follow.ts +155 -0
  182. package/packages/frame/src/kit/testing.ts +260 -0
  183. package/packages/frame/src/kit/totals.ts +72 -0
  184. package/packages/frame/src/kit/ui/entry.ts +75 -0
  185. package/packages/frame/src/kit/ui/frame.ts +212 -0
  186. package/packages/frame/src/kit/ui/glyphs.ts +132 -0
  187. package/packages/frame/src/kit/ui/scroll.ts +14 -0
  188. package/packages/frame/src/render/diff.ts +157 -0
  189. package/packages/frame/src/render/fit.ts +86 -0
  190. package/packages/frame/src/render/format.ts +108 -0
  191. package/packages/frame/src/render/frame.ts +106 -0
  192. package/packages/frame/src/render/glyphs.ts +189 -0
  193. package/packages/frame/src/render/keys.ts +43 -0
  194. package/packages/frame/src/render/lead.ts +37 -0
  195. package/packages/frame/src/render/paint.ts +135 -0
  196. package/packages/frame/src/render/safe.ts +19 -0
  197. package/packages/frame/src/render/segments.ts +735 -0
  198. package/packages/frame/src/render/streaming.ts +223 -0
  199. package/packages/frame/src/render/timing.ts +106 -0
  200. package/packages/frame/src/render/tools.ts +167 -0
  201. package/packages/frame/src/settings.ts +105 -0
  202. package/packages/frame/src/ui/editor.ts +272 -0
  203. package/packages/frame/src/ui/effort.ts +36 -0
  204. package/packages/frame/src/ui/footer.ts +165 -0
  205. package/packages/frame/src/ui/header.ts +362 -0
  206. package/packages/frame/src/ui/history-search.ts +23 -0
  207. package/packages/frame/src/ui/interrupt.ts +21 -0
  208. package/packages/frame/src/ui/outputs.ts +170 -0
  209. package/packages/frame/src/ui/pi-entries.ts +298 -0
  210. package/packages/frame/src/ui/previews.ts +72 -0
  211. package/packages/frame/src/ui/queue.ts +75 -0
  212. package/packages/frame/src/ui/screen.ts +59 -0
  213. package/packages/frame/src/ui/timings.ts +48 -0
  214. package/packages/frame/src/ui/todos.ts +205 -0
  215. package/packages/frame/src/ui/tool-frame.ts +595 -0
  216. package/packages/frame/src/ui/working.ts +25 -0
  217. package/packages/frame/src/view/state.ts +97 -0
  218. package/packages/frame/src/view/transcript.ts +175 -0
  219. package/packages/frame/src/view/view.ts +162 -0
  220. package/packages/memory/CONTRIBUTING.md +7 -0
  221. package/packages/memory/LICENSE +21 -0
  222. package/packages/memory/README.md +44 -0
  223. package/packages/memory/SECURITY.md +20 -0
  224. package/packages/memory/package.json +55 -0
  225. package/packages/memory/skills/review-memory/SKILL.md +48 -0
  226. package/packages/memory/src/index.ts +163 -0
  227. package/packages/memory/src/prompt.ts +25 -0
  228. package/packages/memory/src/review.ts +150 -0
  229. package/packages/memory/src/secrets.ts +22 -0
  230. package/packages/memory/src/store.ts +173 -0
  231. package/packages/modes/CHANGELOG.md +104 -0
  232. package/packages/modes/CONTRIBUTING.md +7 -0
  233. package/packages/modes/LICENSE +21 -0
  234. package/packages/modes/README.md +174 -0
  235. package/packages/modes/SECURITY.md +17 -0
  236. package/packages/modes/package.json +55 -0
  237. package/packages/modes/skills/review-permissions/SKILL.md +54 -0
  238. package/packages/modes/src/bash.ts +218 -0
  239. package/packages/modes/src/deny-off.ts +62 -0
  240. package/packages/modes/src/git-safety.ts +72 -0
  241. package/packages/modes/src/index.ts +338 -0
  242. package/packages/modes/src/interview.ts +206 -0
  243. package/packages/modes/src/judge-gate.ts +113 -0
  244. package/packages/modes/src/modes.ts +121 -0
  245. package/packages/modes/src/review/apply.mjs +156 -0
  246. package/packages/modes/src/review/collect.mjs +208 -0
  247. package/packages/modes/src/review/tools.ts +71 -0
  248. package/packages/modes/src/sandbox.ts +37 -0
  249. package/packages/router/CHANGELOG.md +8 -0
  250. package/packages/router/LICENSE +21 -0
  251. package/packages/router/README.md +90 -0
  252. package/packages/router/package.json +51 -0
  253. package/packages/router/src/config.ts +65 -0
  254. package/packages/router/src/index.ts +172 -0
  255. package/packages/router/src/judge.ts +51 -0
  256. package/packages/router/src/route.ts +67 -0
  257. package/packages/web/CHANGELOG.md +36 -0
  258. package/packages/web/LICENSE +21 -0
  259. package/packages/web/README.md +87 -0
  260. package/packages/web/SECURITY.md +13 -0
  261. package/packages/web/package.json +59 -0
  262. package/packages/web/src/config.ts +109 -0
  263. package/packages/web/src/dnsfilter.ts +97 -0
  264. package/packages/web/src/extract.ts +65 -0
  265. package/packages/web/src/index.ts +217 -0
  266. package/packages/web/src/markdown.ts +122 -0
  267. package/packages/web/src/net.ts +109 -0
  268. package/packages/web/src/reader.ts +73 -0
  269. package/packages/web/src/sanitize.ts +50 -0
  270. package/packages/web/src/search.ts +76 -0
  271. package/packages/web/src/secrets.ts +22 -0
  272. package/packages/web/src/validate.ts +186 -0
  273. package/src/index.ts +156 -0
  274. package/src/settings.ts +79 -0
@@ -0,0 +1,757 @@
1
+ /**
2
+ * Pure decision layer for View State Coordinator commands (issue #91, spec D3).
3
+ *
4
+ * This module is the single source of truth for "which semantic-state mutations
5
+ * are allowed". The coordinator process shell (runner/state-coordinator.mjs)
6
+ * owns all side effects — journal, socket, file writes — and calls into this
7
+ * module for every decision. No fs, no net, no Date.now(): every timestamp is
8
+ * taken from an explicit `now` argument or from command payload fields, so
9
+ * decisions are deterministic and exhaustively unit-testable (same pattern as
10
+ * host-coordination.mjs from issue #70).
11
+ *
12
+ * Delegation contract (rules are never copied here):
13
+ * - `auto_state_classified` → auto-state.mjs applyAutoStateToViewState/Status
14
+ * run on deep clones; changed fields are diffed into field patches.
15
+ * - `run_finalized` → events.mjs finalizeRun + projectViewState run on a deep
16
+ * clone of the on-disk status; diffs produce the status/state patches.
17
+ * - `run_progress` / `followup_started` → payload.statusPatch merges onto the
18
+ * status clone, then projectViewState recomputes the state side (shared
19
+ * applyStatusProjection helper).
20
+ * - `reconcile_finalize` → stamps finalizeRun's exact field names on the
21
+ * status (endedAt/exitCode/processState/pid) but does NOT run finalizeRun:
22
+ * the reconciler's explicitly observed semanticState governs, and a full
23
+ * finalize would recompute it from a possibly stale preview.
24
+ *
25
+ * Reject reasons: "unknown_view" | "revision_conflict" | "stale_run" |
26
+ * "manual_fence" | "busy" | "no_change" | "field_not_allowed" |
27
+ * "source_not_allowed".
28
+ */
29
+ import {
30
+ applyAutoStateToStatus,
31
+ applyAutoStateToViewState,
32
+ isManualVerdict,
33
+ } from "./auto-state.mjs";
34
+ import { finalizeRun, projectViewState } from "./events.mjs";
35
+
36
+ /** Command kinds accepted by the View State Coordinator (issue #91 scope). */
37
+ export const STATE_COMMAND_KINDS = Object.freeze([
38
+ "mark_completed",
39
+ "mark_holding",
40
+ "clear_holding",
41
+ "auto_state_classified",
42
+ "run_finalized",
43
+ "mark_queued",
44
+ "run_started",
45
+ "run_progress",
46
+ "reconcile_finalize",
47
+ "host_run_failed",
48
+ "archive_view",
49
+ "adopt_session",
50
+ "sync_foreground",
51
+ "plan_ready",
52
+ "followup_started",
53
+ "patch_fields",
54
+ ]);
55
+
56
+ /**
57
+ * Kinds the coordinator applies WITHOUT journaling (plan D3/Task-3 decision:
58
+ * run_progress is a periodic self-healing snapshot — ~4 writes/sec/run would
59
+ * bloat the journal unboundedly; a lost beat is overwritten by the next one).
60
+ * Transient commands still materialize and bump materializedRevision, but they
61
+ * are not replayed and not deduped.
62
+ */
63
+ export const TRANSIENT_KINDS = Object.freeze(["run_progress"]);
64
+
65
+ /**
66
+ * Decision-layer reject reasons: journaled, authoritative coordinator verdicts
67
+ * with NO recovery semantics (a retry would be decided the same way). The
68
+ * complement — transport ambiguity ("timeout"/"connection_reset"/
69
+ * "connection_failed"/"coordinator_unavailable") — is the only class where
70
+ * "outcome unknown, replay will recover" diagnostics are honest (issue #91
71
+ * hygiene). validateCommand envelope errors are first-party programmer errors
72
+ * and are not in this set.
73
+ */
74
+ export const DECIDED_REJECT_REASONS = Object.freeze(new Set([
75
+ "manual_fence",
76
+ "stale_run",
77
+ "no_change",
78
+ "busy",
79
+ "revision_conflict",
80
+ "unknown_view",
81
+ "unknown_kind",
82
+ "field_not_allowed",
83
+ "source_not_allowed",
84
+ ]));
85
+
86
+ /**
87
+ * Diagnostic fields for a non-applied command result (issue #91 hygiene):
88
+ * decided rejects are info-level skips — the coordinator's verdict is
89
+ * authoritative, so "outcome unknown, replay will recover" would be a lie.
90
+ * Transport ambiguity keeps the warn with the caller's recovery hint.
91
+ * Pure: shapes only, no I/O.
92
+ * @param {string} code diagnostic code stem (e.g. "run_started")
93
+ * @param {string} label human phrase for the message (e.g. "Run bootstrap")
94
+ * @param {string|null} reason rejected reason from the command result
95
+ * @param {string} ambiguousTail recovery hint appended ONLY for ambiguous reasons
96
+ * @returns {{ level: "info"|"warn", code: string, message: string }}
97
+ */
98
+ export function commandRejectDiagnostic(code, label, reason, ambiguousTail) {
99
+ const decided = reason != null && DECIDED_REJECT_REASONS.has(reason);
100
+ return decided
101
+ ? { level: "info", code: `${code}_skipped`, message: `${label} skipped by the coordinator (${reason}); its decision is authoritative` }
102
+ : { level: "warn", code: `${code}_ambiguous`, message: `${label} outcome unknown (${reason}); if the command was journaled, coordinator replay will recover it; ${ambiguousTail}` };
103
+ }
104
+
105
+ /**
106
+ * Per-source whitelist for `patch_fields` (metadata/evidence-mirror merges).
107
+ * Anything not listed here is rejected with "field_not_allowed" — semantic
108
+ * fields must travel through their dedicated kinds so the guard table in the
109
+ * plan stays exhaustive.
110
+ * @typedef {{ state: string[], status: string[] }} PatchableFields
111
+ * @type {Record<string, PatchableFields>}
112
+ */
113
+ export const PATCHABLE_FIELDS = Object.freeze({
114
+ // summary/latestAssistantPreview: the post-exit model-summary persist routes
115
+ // through patch_fields (controller ruling R1 — plan oversight, Task 3).
116
+ "job-runner": Object.freeze({ state: Object.freeze(["review", "evidenceSummary", "summary", "latestAssistantPreview"]), status: Object.freeze(["evidenceSummary", "summary", "latestAssistantPreview"]) }),
117
+ "state-runner": Object.freeze({ state: Object.freeze(["review", "evidenceSummary"]), status: Object.freeze(["evidenceSummary"]) }),
118
+ // summary (issue #230): a warm host that died with a paused background
119
+ // workflow run leaves the row silent — reconcile announces the interruption
120
+ // by patching summary. lastVisitedAt (markVisited) is the visit-recency
121
+ // stamp. Both are metadata mirrors; semantic fields keep their own kinds.
122
+ "service": Object.freeze({ state: Object.freeze(["lastVisitedAt", "summary"]), status: Object.freeze([]) }),
123
+ // lastVisitedAt (markVisited): visiting is a user action, so it routes as
124
+ // dashboard-user — the manual fence only fences non-human sources, and
125
+ // legacy stamped lastVisitedAt unconditionally (visit-recency tracking
126
+ // must keep working on rows carrying a manual verdict).
127
+ "dashboard-user": Object.freeze({ state: Object.freeze(["lastVisitedAt"]), status: Object.freeze([]) }),
128
+ });
129
+
130
+ /** Who may originate a state command. Non-human sources are fenced by manual verdicts. */
131
+ export const COMMAND_SOURCES = Object.freeze([
132
+ "dashboard-user",
133
+ "service",
134
+ "job-runner",
135
+ "state-runner",
136
+ "pty-runner",
137
+ ]);
138
+
139
+ /**
140
+ * A semantic-state mutation request routed through the coordinator.
141
+ * @typedef {Object} StateCommand
142
+ * @property {"state_command"} type
143
+ * @property {string} commandId Stable id — journal replays return the original result.
144
+ * @property {string} viewId
145
+ * @property {string} [runId] Active run this command belongs to (stale-run fenced).
146
+ * @property {typeof COMMAND_SOURCES[number]} source
147
+ * @property {number|null} [expectedRevision] Optimistic concurrency on materializedRevision.
148
+ * @property {typeof STATE_COMMAND_KINDS[number]} kind
149
+ * @property {Record<string, unknown>} payload Kind-specific; validated per kind.
150
+ */
151
+
152
+ /**
153
+ * Validate the command envelope plus kind-specific payload presence. Pure.
154
+ * @param {any} raw
155
+ * @returns {{ ok: true, command: object } | { ok: false, error: string }}
156
+ */
157
+ export function validateCommand(raw) {
158
+ if (!raw || raw.type !== "state_command") return { ok: false, error: "bad_type" };
159
+ // Transient kinds have no idempotency semantics — the shell neither dedupes
160
+ // nor replays them, so commandId is optional there (echoed in the reply only).
161
+ if ((typeof raw.commandId !== "string" || !raw.commandId) && !TRANSIENT_KINDS.includes(raw.kind)) {
162
+ return { ok: false, error: "missing_commandId" };
163
+ }
164
+ if (typeof raw.viewId !== "string" || !raw.viewId) return { ok: false, error: "missing_viewId" };
165
+ if (!STATE_COMMAND_KINDS.includes(raw.kind)) return { ok: false, error: "unknown_kind" };
166
+ if (!COMMAND_SOURCES.includes(raw.source)) return { ok: false, error: "unknown_source" };
167
+ if (raw.expectedRevision != null && typeof raw.expectedRevision !== "number") return { ok: false, error: "bad_expectedRevision" };
168
+ if (raw.runId != null && typeof raw.runId !== "string") return { ok: false, error: "bad_runId" };
169
+ if (raw.kind === "auto_state_classified") {
170
+ const classification = raw.payload?.classification;
171
+ if (!classification || typeof classification !== "object") return { ok: false, error: "missing_classification" };
172
+ if (typeof classification.classifiedAt !== "number") return { ok: false, error: "bad_classification" };
173
+ }
174
+ if (raw.kind === "run_finalized") {
175
+ if (!raw.payload || typeof raw.payload !== "object") return { ok: false, error: "missing_payload" };
176
+ if (typeof raw.payload.exitCode !== "number" && raw.payload.exitCode !== null) return { ok: false, error: "missing_exitCode" };
177
+ if (raw.payload.endedAt != null && typeof raw.payload.endedAt !== "number") return { ok: false, error: "bad_endedAt" };
178
+ if (raw.payload.lastAgentActivityAt != null && typeof raw.payload.lastAgentActivityAt !== "number") return { ok: false, error: "bad_lastAgentActivityAt" };
179
+ if (raw.payload.stoppedByUser != null && typeof raw.payload.stoppedByUser !== "boolean") return { ok: false, error: "bad_stoppedByUser" };
180
+ if (raw.payload.stopReason != null && typeof raw.payload.stopReason !== "string") return { ok: false, error: "bad_stopReason" };
181
+ }
182
+ switch (raw.kind) {
183
+ case "mark_queued":
184
+ // runId may be null (PTY host launch pins no run — legacy
185
+ // markQueued(id, null)); the key must be present, and when non-null
186
+ // it must be a non-empty string.
187
+ if (!raw.payload || !("runId" in raw.payload)) return { ok: false, error: "missing_runId" };
188
+ if (raw.payload.runId != null && (typeof raw.payload.runId !== "string" || !raw.payload.runId)) return { ok: false, error: "missing_runId" };
189
+ break;
190
+ case "run_started": {
191
+ // Non-empty string (not just != null): a degenerate runId passes the
192
+ // null guard, then the statusRunId binding silently drops the status
193
+ // half while the ack still says "applied" (Task 2 review P2-A).
194
+ if (typeof raw.runId !== "string" || !raw.runId) return { ok: false, error: "missing_runId" };
195
+ const status = raw.payload?.status;
196
+ if (!status || typeof status !== "object") return { ok: false, error: "missing_status" };
197
+ if (typeof status.runId !== "string" || status.runId !== raw.runId) return { ok: false, error: "bad_status" };
198
+ break;
199
+ }
200
+ case "run_progress":
201
+ if (!raw.payload || typeof raw.payload.statusPatch !== "object" || raw.payload.statusPatch == null) return { ok: false, error: "missing_statusPatch" };
202
+ break;
203
+ case "followup_started":
204
+ // The command carries NO runId (a null runId skips the generic stale-run
205
+ // guard against the finished parent run); the NEW run's id travels here
206
+ // and governs the state-side currentRunId (ruling R2).
207
+ if (!raw.payload || typeof raw.payload.statusPatch !== "object" || raw.payload.statusPatch == null) return { ok: false, error: "missing_statusPatch" };
208
+ if (typeof raw.payload.newRunId !== "string" || !raw.payload.newRunId) return { ok: false, error: "missing_newRunId" };
209
+ break;
210
+ case "reconcile_finalize": {
211
+ // Project mode (service.mjs dead-runner path): the run's terminal status
212
+ // exists but the row was never materialized from it — the status itself
213
+ // is the verdict, so semanticState/summary are derived, not passed.
214
+ if (raw.payload?.project === true) {
215
+ if (raw.payload.reason != null && typeof raw.payload.reason !== "string") return { ok: false, error: "bad_reason" };
216
+ break;
217
+ }
218
+ const semanticState = raw.payload?.semanticState;
219
+ if (semanticState !== "failed" && semanticState !== "idle") return { ok: false, error: "bad_semanticState" };
220
+ // The reconciler's summary is caller-provided (legacy parity: both
221
+ // service reconcile sites always stamp a summary, ruling R4).
222
+ if (typeof raw.payload?.summary !== "string" || !raw.payload.summary) return { ok: false, error: "missing_summary" };
223
+ if (raw.payload.reason != null && typeof raw.payload.reason !== "string") return { ok: false, error: "bad_reason" };
224
+ if (raw.payload.exitCode != null && typeof raw.payload.exitCode !== "number") return { ok: false, error: "bad_exitCode" };
225
+ break;
226
+ }
227
+ case "host_run_failed":
228
+ if (raw.payload?.error != null && typeof raw.payload.error !== "string") return { ok: false, error: "bad_error" };
229
+ if (raw.payload?.exitCode != null && typeof raw.payload.exitCode !== "number") return { ok: false, error: "bad_exitCode" };
230
+ break;
231
+ case "sync_foreground":
232
+ if (!raw.payload?.projection || typeof raw.payload.projection !== "object") return { ok: false, error: "missing_projection" };
233
+ break;
234
+ case "plan_ready":
235
+ // The producer (job-runner's plan-ready pass) fires post-finalization and
236
+ // re-points the row at the plan-producing run (ruling R3).
237
+ if (raw.payload?.question != null && typeof raw.payload.question !== "string") return { ok: false, error: "bad_question" };
238
+ if (typeof raw.payload?.runId !== "string" || !raw.payload.runId) return { ok: false, error: "missing_runId" };
239
+ break;
240
+ case "patch_fields": {
241
+ const hasState = raw.payload?.state != null && typeof raw.payload.state === "object";
242
+ const hasStatus = raw.payload?.status != null && typeof raw.payload.status === "object";
243
+ if (!hasState && !hasStatus) return { ok: false, error: "missing_payload" };
244
+ break;
245
+ }
246
+ }
247
+ return { ok: true, command: raw };
248
+ }
249
+
250
+ /**
251
+ * Decide whether a command may mutate the view state, and which field patches
252
+ * to apply. Pure: deep-clones inputs before delegating, never mutates arguments,
253
+ * never touches the clock (pass `now` for wall-clock timestamps; falls back to
254
+ * payload-provided timestamps for determinism).
255
+ *
256
+ * @param {object} command
257
+ * @param {object|null} currentState ViewState as currently materialized (state.json).
258
+ * @param {object|null} currentStatus RunStatus for command.runId as currently
259
+ * materialized (status.json), when a status file exists; null otherwise.
260
+ * @param {number} [now] Wall-clock epoch ms supplied by the coordinator shell.
261
+ * @returns {{ action: "apply", mutate: { state?: object, status?: object }, reason: string }
262
+ * | { action: "reject", reason: string }}
263
+ * `mutate.state`/`mutate.status` are sparse field patches — only fields whose
264
+ * value actually changed. The coordinator merges them onto the materialized
265
+ * files and stamps the shared materializedRevision.
266
+ */
267
+ export function decideStateTransition(command, currentState, currentStatus, now = undefined) {
268
+ if (!currentState) return reject("unknown_view");
269
+ if (command.expectedRevision != null && command.expectedRevision !== (currentState.materializedRevision ?? 0)) {
270
+ return reject("revision_conflict");
271
+ }
272
+ // Generic stale-run guard: a command for a run other than the row's current
273
+ // run is stale. run_started is EXEMPT — it carries its own liveness-scoped
274
+ // guard below (launch-order inversion, cold-start race): the runner is
275
+ // authoritative that its run just started, so a bootstrap landing on a
276
+ // not-alive row applies and re-pins currentRunId. Only two SIMULTANEOUS live
277
+ // runs on one view are the hazard this guard exists for.
278
+ if (
279
+ command.kind !== "run_started" &&
280
+ command.runId &&
281
+ currentState.currentRunId &&
282
+ command.runId !== currentState.currentRunId
283
+ ) {
284
+ return reject("stale_run");
285
+ }
286
+ // Manual verdicts are user judgments: only a human source may act on a
287
+ // fenced row (this is the #46 invariant — late classifications lose).
288
+ if (command.source !== "dashboard-user" && isManualVerdict(currentState)) {
289
+ return reject("manual_fence");
290
+ }
291
+ switch (command.kind) {
292
+ case "mark_completed": {
293
+ if (currentState.processState === "alive") return reject("busy");
294
+ // autoState: null on both artifacts is the manual-verdict fence
295
+ // signal that later auto-state commands (and the auto-state rules
296
+ // themselves) key off — see isManualVerdict().
297
+ return {
298
+ action: "apply",
299
+ reason: "manual_completion",
300
+ mutate: {
301
+ state: {
302
+ semanticState: "completed",
303
+ processState: "exited",
304
+ needsInput: false,
305
+ hasError: false,
306
+ question: null,
307
+ pendingQuestions: [],
308
+ error: null,
309
+ autoState: null,
310
+ },
311
+ status: { autoState: null },
312
+ },
313
+ };
314
+ }
315
+ case "mark_holding": {
316
+ // Manual-only (spec D14): an on-hold verdict is a human judgment; any
317
+ // automated source is rejected before the fence can matter.
318
+ if (command.source !== "dashboard-user") return reject("source_not_allowed");
319
+ if (currentState.processState === "alive") return reject("busy");
320
+ // autoState: null on both artifacts is the manual-verdict fence signal
321
+ // (see isManualVerdict). Full field-set patch: idempotent under the
322
+ // coordinator's sparse merge, mirrors mark_completed's shape.
323
+ return {
324
+ action: "apply",
325
+ reason: "manual_holding",
326
+ mutate: {
327
+ state: {
328
+ semanticState: "holding",
329
+ processState: "exited",
330
+ needsInput: false,
331
+ hasError: false,
332
+ question: null,
333
+ pendingQuestions: [],
334
+ error: null,
335
+ autoState: null,
336
+ },
337
+ status: { autoState: null },
338
+ },
339
+ };
340
+ }
341
+ case "clear_holding": {
342
+ if (command.source !== "dashboard-user") return reject("source_not_allowed");
343
+ if (currentState.semanticState !== "holding") return reject("no_change");
344
+ if (currentState.processState === "alive") return reject("busy");
345
+ // Un-hold returns the row to automated management (idle = the row is
346
+ // waiting for the next directive; queued follow-ups may drain again).
347
+ return {
348
+ action: "apply",
349
+ reason: "manual_resume",
350
+ mutate: {
351
+ state: {
352
+ semanticState: "idle",
353
+ processState: "exited",
354
+ needsInput: false,
355
+ hasError: false,
356
+ question: null,
357
+ pendingQuestions: [],
358
+ error: null,
359
+ autoState: null,
360
+ },
361
+ status: { autoState: null },
362
+ },
363
+ };
364
+ }
365
+ case "auto_state_classified": {
366
+ const classification = command.payload?.classification;
367
+ const at = now ?? classification?.classifiedAt;
368
+ const stateClone = cloneJson(currentState);
369
+ const statusClone = cloneJson(currentStatus);
370
+ const stateChanged = applyAutoStateToViewState(stateClone, classification, at);
371
+ const statusChanged = statusClone ? applyAutoStateToStatus(statusClone, classification, at) : false;
372
+ if (!stateChanged && !statusChanged) return reject("no_change");
373
+ return { action: "apply", reason: command.kind, mutate: buildPatches(currentState, stateClone, stateChanged, currentStatus, statusClone, statusChanged) };
374
+ }
375
+ case "run_finalized": {
376
+ const payload = command.payload ?? {};
377
+ // Only a live run record for this view's current run may be finalized;
378
+ // anything else is stale (duplicate finalize commands included).
379
+ if (!currentStatus) return reject("stale_run");
380
+ if (currentState.currentRunId !== command.runId) return reject("stale_run");
381
+ if (currentState.processState !== "alive") return reject("stale_run");
382
+ const at = now ?? payload.endedAt ?? 0;
383
+ const statusClone = cloneJson(currentStatus);
384
+ // Overlay fresher fields the throttled on-disk write may lag behind.
385
+ // stopReason must be overlaid onto the status itself: finalizeSemanticState
386
+ // reads status.stopReason (finalizeRun's opts have no stopReason slot), so
387
+ // a fresh "aborted" reported by the runner would otherwise be lost and the
388
+ // run would land on "idle" instead of "failed".
389
+ if (typeof payload.latestAssistantPreview === "string") statusClone.latestAssistantPreview = payload.latestAssistantPreview;
390
+ if (payload.lastAgentActivityAt != null) statusClone.lastAgentActivityAt = payload.lastAgentActivityAt;
391
+ if (payload.stopReason != null) statusClone.stopReason = payload.stopReason;
392
+ finalizeRun(statusClone, {
393
+ exitCode: payload.exitCode,
394
+ stoppedByUser: payload.stoppedByUser,
395
+ stopReason: payload.stopReason,
396
+ openEnded: payload.openEnded,
397
+ }, at);
398
+ const projectedState = projectViewState(statusClone, at, currentState);
399
+ return {
400
+ action: "apply",
401
+ reason: command.kind,
402
+ mutate: {
403
+ state: diffFields(currentState, projectedState),
404
+ status: diffFields(currentStatus, statusClone),
405
+ },
406
+ };
407
+ }
408
+ case "mark_queued": {
409
+ return {
410
+ action: "apply",
411
+ reason: command.kind,
412
+ mutate: {
413
+ state: {
414
+ currentRunId: command.payload.runId,
415
+ semanticState: "queued",
416
+ processState: "alive",
417
+ summary: "Queued",
418
+ needsInput: false,
419
+ hasError: false,
420
+ question: null,
421
+ pendingQuestions: [],
422
+ error: null,
423
+ autoState: null,
424
+ },
425
+ },
426
+ };
427
+ }
428
+ case "run_started": {
429
+ // Liveness-scoped stale guard (the generic guard above exempts this
430
+ // kind): only TWO LIVE RUNS on one view are the hazard — a bootstrap for
431
+ // a different runId while this row already runs something else is
432
+ // out-of-order and rejected. When the row is NOT alive, the runner is
433
+ // authoritative that its run just started: apply and re-pin
434
+ // currentRunId. This covers the cold-start race where run_started lands
435
+ // before mark_queued (the runner was spawned first), and re-launches of
436
+ // rows still pointing at the previous run.
437
+ if (
438
+ currentState.processState === "alive" &&
439
+ currentState.currentRunId &&
440
+ currentState.currentRunId !== command.runId
441
+ ) {
442
+ return reject("stale_run");
443
+ }
444
+ const statusAfter = cloneJson(command.payload.status);
445
+ return {
446
+ action: "apply",
447
+ reason: command.kind,
448
+ mutate: {
449
+ state: {
450
+ processState: "alive",
451
+ semanticState: "working",
452
+ currentRunId: command.runId,
453
+ },
454
+ status: diffFields(currentStatus, statusAfter),
455
+ },
456
+ };
457
+ }
458
+ case "run_progress": {
459
+ // Liveness semantics: only the row's current live run may move forward.
460
+ // Late/duplicate progress from a finished run is dropped (next run's
461
+ // progress supersedes it anyway — this kind is transient by design).
462
+ //
463
+ // A missing status file splits two ways (F2, split by the hard-down
464
+ // residual closure): a QUALIFIED beat from the row's current live run —
465
+ // full-shaped patch carrying processState/semanticState and the same
466
+ // runId — bootstraps the file, because the coordinator may have been
467
+ // down through the runner's boot window (mark_queued applied,
468
+ // run_started lost): without the bootstrap the row can never converge
469
+ // (beats and finalize both reject forever). Anything else — exited or
470
+ // re-pointed rows, sparse patches that would materialize an
471
+ // undefined-shaped status — stays stale_run (F2's original exposure).
472
+ if (!currentStatus) {
473
+ // P2 hardening: the bootstrap is keyed on command.runId (it becomes the
474
+ // status-file identity), so a degenerate null==null row match must never
475
+ // bootstrap — the shell would silently drop the status half. The check
476
+ // lives here beside its sibling liveness guards (runId semantics for
477
+ // run_progress are decision-layer; envelope validation stays minimal,
478
+ // matching the generic stale-run guard's decision-side read).
479
+ const rowMatches = typeof command.runId === "string" && command.runId.length > 0
480
+ && currentState.currentRunId === command.runId
481
+ && currentState.processState === "alive";
482
+ if (!rowMatches || !beatPatchQualifiesForBootstrap(command)) return reject("stale_run");
483
+ // The patch itself is the base: it is the runner's authoritative
484
+ // full in-memory status, so applyStatusProjection's merge-onto-empty
485
+ // materializes exactly the patch (validated full-shaped above).
486
+ return applyStatusProjection(command, currentState, null, now);
487
+ }
488
+ if (currentState.currentRunId !== command.runId || currentState.processState !== "alive") return reject("stale_run");
489
+ return applyStatusProjection(command, currentState, currentStatus, now);
490
+ }
491
+ case "followup_started": {
492
+ // No liveness guard and no command.runId: the follow-up starts from a
493
+ // just-finalized parent run, so the generic stale-run guard must not fire
494
+ // against it. The NEW run's identity (payload.newRunId) governs the
495
+ // state-side currentRunId regardless of what the status patch carries,
496
+ // and a follow-up starting is definitionally the row running again —
497
+ // pin processState so a sparse bootstrap patch can never materialize
498
+ // an undefined (key-dropping) processState on a re-pointed row.
499
+ const result = applyStatusProjection(command, currentState, currentStatus, now);
500
+ return {
501
+ ...result,
502
+ mutate: { ...result.mutate, state: { ...result.mutate.state, currentRunId: command.payload.newRunId, processState: "alive" } },
503
+ };
504
+ }
505
+ case "reconcile_finalize": {
506
+ if (currentState.processState !== "alive") return reject("no_change");
507
+ const at = now ?? 0;
508
+ if (command.payload.project === true) {
509
+ // Project mode: faithfully re-materialize the row from the run's
510
+ // terminal status (projectViewState delegation — never copied rules).
511
+ // A missing status means there is nothing to project — stale.
512
+ if (!currentStatus) return reject("stale_run");
513
+ const projected = projectViewState(cloneJson(currentStatus), at, currentState);
514
+ return { action: "apply", reason: command.kind, mutate: { state: diffFields(currentState, projected) } };
515
+ }
516
+ const failed = command.payload.semanticState === "failed";
517
+ const stateClone = cloneJson(currentState);
518
+ stateClone.semanticState = command.payload.semanticState;
519
+ stateClone.processState = "exited";
520
+ // Derived-field clearing at legacy parity (service.mjs reconcile sites,
521
+ // ruling R4): both legacy branches always stamp these fields.
522
+ stateClone.needsInput = false;
523
+ stateClone.hasError = failed;
524
+ stateClone.question = null;
525
+ stateClone.pendingQuestions = [];
526
+ stateClone.error = command.payload.reason ?? null;
527
+ stateClone.summary = command.payload.summary;
528
+ const mutate = { state: diffFields(currentState, stateClone) };
529
+ if (currentStatus) {
530
+ const statusClone = cloneJson(currentStatus);
531
+ // Same field names finalizeRun stamps, minus the semantic recomputation:
532
+ // the reconciler observed the outcome explicitly and its verdict governs.
533
+ statusClone.endedAt = at;
534
+ statusClone.exitCode = command.payload.exitCode ?? null;
535
+ statusClone.processState = "exited";
536
+ statusClone.pid = null;
537
+ statusClone.semanticState = command.payload.semanticState;
538
+ mutate.status = diffFields(currentStatus, statusClone);
539
+ }
540
+ return { action: "apply", reason: command.kind, mutate };
541
+ }
542
+ case "host_run_failed": {
543
+ // No kind-specific guard: the generic manual_fence above is exactly the
544
+ // PR #1 residual-risk closure — a late host crash must not flip a row
545
+ // the user already completed by hand.
546
+ const message = command.payload?.error ?? "PTY host failed";
547
+ const mutate = {
548
+ state: {
549
+ semanticState: "failed",
550
+ processState: "exited",
551
+ // Legacy parity with markRowFailedDirect (runner/pty-runner-legacy.mjs):
552
+ // both summary and error carry the message, hasError/needsInput are
553
+ // stamped so row rendering and warm-host eviction match the direct era.
554
+ summary: message,
555
+ hasError: true,
556
+ needsInput: false,
557
+ error: command.payload?.error ?? null,
558
+ },
559
+ };
560
+ if (currentStatus) {
561
+ mutate.status = {
562
+ semanticState: "failed",
563
+ processState: "exited",
564
+ error: command.payload?.error ?? null,
565
+ };
566
+ }
567
+ return { action: "apply", reason: command.kind, mutate };
568
+ }
569
+ case "archive_view": {
570
+ // Busy rows are allowed: archiving a working row stops it (matches the
571
+ // legacy archiveView behavior this kind replaces).
572
+ return {
573
+ action: "apply",
574
+ reason: command.kind,
575
+ mutate: {
576
+ state: {
577
+ semanticState: "stopped",
578
+ processState: "exited",
579
+ needsInput: false,
580
+ hasError: false,
581
+ question: null,
582
+ pendingQuestions: [],
583
+ error: null,
584
+ autoState: null,
585
+ summary: "Stopped",
586
+ },
587
+ },
588
+ };
589
+ }
590
+ case "adopt_session": {
591
+ if (currentState.processState === "alive") return reject("busy");
592
+ // Derived-field clearing at legacy parity (service.mjs adoptSession reuse
593
+ // path, ruling R4). autoState is intentionally untouched: the legacy
594
+ // adopt path leaves it alone.
595
+ return {
596
+ action: "apply",
597
+ reason: command.kind,
598
+ mutate: {
599
+ state: {
600
+ semanticState: "idle",
601
+ processState: "exited",
602
+ needsInput: false,
603
+ hasError: false,
604
+ question: null,
605
+ pendingQuestions: [],
606
+ error: null,
607
+ summary: "Backgrounded session",
608
+ },
609
+ },
610
+ };
611
+ }
612
+ case "sync_foreground": {
613
+ // Foreground mirrors never own a background run: the projection is the
614
+ // caller's, but currentRunId stays null regardless of what it carries.
615
+ const stateClone = cloneJson(currentState);
616
+ Object.assign(stateClone, command.payload.projection);
617
+ stateClone.currentRunId = null;
618
+ return { action: "apply", reason: command.kind, mutate: { state: diffFields(currentState, stateClone) } };
619
+ }
620
+ case "plan_ready": {
621
+ // The producer (job-runner's plan-ready pass) fires POST-finalization:
622
+ // the run has exited by the time a plan is ready for approval, so a live
623
+ // row means out-of-order delivery — drop it (ruling R3 inverts the guard).
624
+ if (currentState.processState === "alive") return reject("no_change");
625
+ // Exact legacy parity with runner/job-runner.mjs's plan-ready write.
626
+ return {
627
+ action: "apply",
628
+ reason: command.kind,
629
+ mutate: {
630
+ state: {
631
+ semanticState: "needs_input",
632
+ processState: "exited",
633
+ needsInput: true,
634
+ question: command.payload?.question ?? "Approve this plan?",
635
+ summary: "Plan ready for approval",
636
+ currentRunId: command.payload.runId,
637
+ },
638
+ },
639
+ };
640
+ }
641
+ case "patch_fields": {
642
+ const allowed = PATCHABLE_FIELDS[command.source];
643
+ if (!allowed) return reject("field_not_allowed");
644
+ const requestedState = command.payload.state ?? {};
645
+ const requestedStatus = command.payload.status ?? {};
646
+ for (const key of Object.keys(requestedState)) {
647
+ if (!allowed.state.includes(key)) return reject("field_not_allowed");
648
+ }
649
+ for (const key of Object.keys(requestedStatus)) {
650
+ if (!allowed.status.includes(key)) return reject("field_not_allowed");
651
+ }
652
+ const stateClone = cloneJson(currentState);
653
+ Object.assign(stateClone, requestedState);
654
+ const statePatch = diffFields(currentState, stateClone);
655
+ let statusPatch;
656
+ if (currentStatus && Object.keys(requestedStatus).length > 0) {
657
+ const statusClone = cloneJson(currentStatus);
658
+ Object.assign(statusClone, requestedStatus);
659
+ statusPatch = diffFields(currentStatus, statusClone);
660
+ }
661
+ if (Object.keys(statePatch).length === 0 && (!statusPatch || Object.keys(statusPatch).length === 0)) {
662
+ return reject("no_change");
663
+ }
664
+ return { action: "apply", reason: command.kind, mutate: statusPatch ? { state: statePatch, status: statusPatch } : { state: statePatch } };
665
+ }
666
+ default:
667
+ // validateCommand already rejects unknown kinds; defensive only.
668
+ return reject("unknown_kind");
669
+ }
670
+ }
671
+
672
+ /** @param {string} reason @returns {{ action: "reject", reason: string }} */
673
+ function reject(reason) {
674
+ return { action: "reject", reason };
675
+ }
676
+
677
+ /** JSON round-trip clone: patches stay JSON-serializable (they are written to disk). */
678
+ function cloneJson(value) {
679
+ return value == null ? value : JSON.parse(JSON.stringify(value));
680
+ }
681
+
682
+ /**
683
+ * Shallow top-level field diff (deep-equality per field via JSON form), so a
684
+ * cloned-but-unchanged nested object never lands in a patch.
685
+ * @param {object|null} before
686
+ * @param {object} after
687
+ */
688
+ function diffFields(before, after) {
689
+ const patch = {};
690
+ for (const key of Object.keys(after)) {
691
+ if (JSON.stringify(before?.[key]) !== JSON.stringify(after[key])) patch[key] = after[key];
692
+ }
693
+ return patch;
694
+ }
695
+
696
+ /** @param {boolean} stateChanged @param {boolean} statusChanged */
697
+ function buildPatches(currentState, stateClone, stateChanged, currentStatus, statusClone, statusChanged) {
698
+ const mutate = {};
699
+ if (stateChanged) mutate.state = diffFields(currentState, stateClone);
700
+ if (statusChanged) mutate.status = diffFields(currentStatus, statusClone);
701
+ return mutate;
702
+ }
703
+
704
+ /**
705
+ * Whether a run_progress beat may bootstrap a missing status file: the patch
706
+ * must be full-shaped — carrying processState and semanticState (so the
707
+ * materialized status never has an undefined shape) and pinned to the same
708
+ * runId as the command (so a foreign run's snapshot can never masquerade as
709
+ * this run's bootstrap). Sparse or mismatched patches stay stale_run.
710
+ * @param {object} command
711
+ */
712
+ function beatPatchQualifiesForBootstrap(command) {
713
+ const patch = command.payload?.statusPatch;
714
+ return Boolean(
715
+ patch &&
716
+ typeof patch === "object" &&
717
+ typeof patch.processState === "string" &&
718
+ typeof patch.semanticState === "string" &&
719
+ patch.runId === command.runId,
720
+ );
721
+ }
722
+
723
+ /**
724
+ * Shared body for the status-patch kinds (`run_progress`, `followup_started`):
725
+ * merge payload.statusPatch onto the materialized status, then let
726
+ * projectViewState recompute the state side — the projection rules live in
727
+ * events.mjs and are never copied here (same delegation contract as
728
+ * run_finalized).
729
+ *
730
+ * A missing status file is tolerated: the patch merges onto an empty object so
731
+ * the coordinator materializes a fresh status from the patch fields (this is
732
+ * the followup_started bootstrap path — a new run's status file does not exist
733
+ * yet). The caller should send enough fields to make that object meaningful.
734
+ *
735
+ * Pure; timestamp comes from `now`, falling back to the patch's
736
+ * lastActivityAt (payload-carried determinism, same rule as run_finalized).
737
+ *
738
+ * @param {object} command
739
+ * @param {object} currentState
740
+ * @param {object|null} currentStatus
741
+ * @param {number|undefined} now
742
+ */
743
+ function applyStatusProjection(command, currentState, currentStatus, now) {
744
+ const patch = command.payload.statusPatch;
745
+ const at = now ?? patch.lastActivityAt ?? 0;
746
+ const statusClone = cloneJson(currentStatus) ?? {};
747
+ Object.assign(statusClone, patch);
748
+ const projectedState = projectViewState(statusClone, at, currentState);
749
+ return {
750
+ action: "apply",
751
+ reason: command.kind,
752
+ mutate: {
753
+ state: diffFields(currentState, projectedState),
754
+ status: diffFields(currentStatus ?? {}, statusClone),
755
+ },
756
+ };
757
+ }