agent-dealer 0.2.0 → 1.0.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 (276) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +443 -0
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +454 -0
  3. package/bundle/server/dist/adapters/agent-deck.js +38 -50
  4. package/bundle/server/dist/adapters/agent-deck.test.js +63 -0
  5. package/bundle/server/dist/adapters/agent-health.js +230 -33
  6. package/bundle/server/dist/adapters/agent-health.test.js +237 -0
  7. package/bundle/server/dist/adapters/authority-lifecycle.js +289 -0
  8. package/bundle/server/dist/adapters/codex-scoped-config.js +103 -0
  9. package/bundle/server/dist/adapters/execution-authority.js +103 -0
  10. package/bundle/server/dist/adapters/execution-authority.test.js +166 -0
  11. package/bundle/server/dist/adapters/external.js +0 -1
  12. package/bundle/server/dist/adapters/git-worktree.js +514 -0
  13. package/bundle/server/dist/adapters/git-worktree.test.js +502 -0
  14. package/bundle/server/dist/adapters/github.js +170 -0
  15. package/bundle/server/dist/adapters/github.test.js +205 -0
  16. package/bundle/server/dist/adapters/linear-inbox.js +199 -30
  17. package/bundle/server/dist/adapters/linear-inbox.test.js +35 -0
  18. package/bundle/server/dist/adapters/linear-sync.js +3 -101
  19. package/bundle/server/dist/adapters/managed-repo.js +168 -0
  20. package/bundle/server/dist/adapters/managed-repo.test.js +63 -0
  21. package/bundle/server/dist/adapters/outbound-delivery.js +83 -0
  22. package/bundle/server/dist/adapters/outbound-delivery.test.js +53 -0
  23. package/bundle/server/dist/adapters/reflect-authority.js +43 -0
  24. package/bundle/server/dist/cli-env.js +26 -0
  25. package/bundle/server/dist/cli-env.test.js +14 -1
  26. package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
  27. package/bundle/server/dist/coordinator/admission.js +330 -0
  28. package/bundle/server/dist/coordinator/admission.test.js +338 -0
  29. package/bundle/server/dist/coordinator/args.js +128 -0
  30. package/bundle/server/dist/coordinator/args.test.js +132 -0
  31. package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
  32. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
  33. package/bundle/server/dist/coordinator/auto-merge.js +286 -0
  34. package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
  35. package/bundle/server/dist/coordinator/branch-progress.js +81 -0
  36. package/bundle/server/dist/coordinator/clock-jump.js +98 -0
  37. package/bundle/server/dist/coordinator/commands.js +1088 -0
  38. package/bundle/server/dist/coordinator/commands.test.js +554 -0
  39. package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
  40. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
  41. package/bundle/server/dist/coordinator/dependencies.js +242 -0
  42. package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
  43. package/bundle/server/dist/coordinator/developer-effect.js +959 -0
  44. package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
  45. package/bundle/server/dist/coordinator/dispatcher.js +58 -0
  46. package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
  47. package/bundle/server/dist/coordinator/effect-registry.js +17 -0
  48. package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
  49. package/bundle/server/dist/coordinator/failure-reason.js +273 -0
  50. package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
  51. package/bundle/server/dist/coordinator/guidance.js +33 -0
  52. package/bundle/server/dist/coordinator/guidance.test.js +110 -0
  53. package/bundle/server/dist/coordinator/human-resolution.js +88 -0
  54. package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
  55. package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
  56. package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
  57. package/bundle/server/dist/coordinator/latest-failure.js +100 -0
  58. package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
  59. package/bundle/server/dist/coordinator/metrics.js +35 -0
  60. package/bundle/server/dist/coordinator/metrics.test.js +52 -0
  61. package/bundle/server/dist/coordinator/permissions.js +127 -0
  62. package/bundle/server/dist/coordinator/permissions.test.js +184 -0
  63. package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
  64. package/bundle/server/dist/coordinator/process-liveness.js +191 -0
  65. package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
  66. package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
  67. package/bundle/server/dist/coordinator/projection.js +147 -0
  68. package/bundle/server/dist/coordinator/projection.test.js +121 -0
  69. package/bundle/server/dist/coordinator/prompts.js +164 -0
  70. package/bundle/server/dist/coordinator/prompts.test.js +177 -0
  71. package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
  72. package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
  73. package/bundle/server/dist/coordinator/recovery.js +466 -0
  74. package/bundle/server/dist/coordinator/recovery.test.js +164 -0
  75. package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
  76. package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
  77. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
  78. package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
  79. package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
  80. package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
  81. package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
  82. package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
  83. package/bundle/server/dist/coordinator/routing.js +160 -0
  84. package/bundle/server/dist/coordinator/routing.test.js +336 -0
  85. package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
  86. package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
  87. package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
  88. package/bundle/server/dist/coordinator/session-progress.js +351 -0
  89. package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
  90. package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
  91. package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
  92. package/bundle/server/dist/coordinator/spawn.js +49 -0
  93. package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
  94. package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
  95. package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
  96. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
  97. package/bundle/server/dist/coordinator/usage.js +40 -0
  98. package/bundle/server/dist/coordinator/usage.test.js +69 -0
  99. package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
  100. package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
  101. package/bundle/server/dist/coordinator/worker-loop.js +363 -0
  102. package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
  103. package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
  104. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
  105. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
  106. package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
  107. package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
  108. package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
  109. package/bundle/server/dist/db/index.js +359 -7
  110. package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
  111. package/bundle/server/dist/db/migrate-to-issues.js +824 -0
  112. package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
  113. package/bundle/server/dist/db/schema.sql +311 -1
  114. package/bundle/server/dist/db/schema.test.js +66 -0
  115. package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
  116. package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
  117. package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
  118. package/bundle/server/dist/index.js +58 -5
  119. package/bundle/server/dist/paths.js +40 -4
  120. package/bundle/server/dist/paths.test.js +42 -0
  121. package/bundle/server/dist/pid-marker-claim-child.js +18 -0
  122. package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
  123. package/bundle/server/dist/pid-marker.js +141 -0
  124. package/bundle/server/dist/pid-marker.test.js +208 -0
  125. package/bundle/server/dist/queue/approve-deliver.js +128 -28
  126. package/bundle/server/dist/queue/reflect-schedule.js +25 -0
  127. package/bundle/server/dist/queue/result-qa.js +1 -1
  128. package/bundle/server/dist/queue/send-gate.test.js +172 -36
  129. package/bundle/server/dist/repository/agents.js +55 -18
  130. package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
  131. package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
  132. package/bundle/server/dist/repository/artifacts.js +47 -0
  133. package/bundle/server/dist/repository/artifacts.test.js +48 -0
  134. package/bundle/server/dist/repository/authority-attempts.js +189 -0
  135. package/bundle/server/dist/repository/findings.js +70 -0
  136. package/bundle/server/dist/repository/findings.test.js +68 -0
  137. package/bundle/server/dist/repository/human-actions.js +142 -0
  138. package/bundle/server/dist/repository/human-actions.test.js +123 -0
  139. package/bundle/server/dist/repository/intake-settings.js +5 -59
  140. package/bundle/server/dist/repository/issues.js +305 -0
  141. package/bundle/server/dist/repository/issues.test.js +69 -0
  142. package/bundle/server/dist/repository/outbound-drafts.js +28 -0
  143. package/bundle/server/dist/repository/queue-entries.js +162 -0
  144. package/bundle/server/dist/repository/review-publications.js +153 -0
  145. package/bundle/server/dist/repository/review-publications.test.js +78 -0
  146. package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
  147. package/bundle/server/dist/repository/runs.js +4 -211
  148. package/bundle/server/dist/repository/runtime-availability.js +54 -0
  149. package/bundle/server/dist/repository/usage-events.js +60 -0
  150. package/bundle/server/dist/repository/usage-events.test.js +56 -0
  151. package/bundle/server/dist/repository/work-items.js +305 -0
  152. package/bundle/server/dist/repository/work-items.test.js +142 -0
  153. package/bundle/server/dist/repository/worker-sessions.js +219 -0
  154. package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
  155. package/bundle/server/dist/repository/workflow-events.js +193 -0
  156. package/bundle/server/dist/repository/workflow-events.test.js +89 -0
  157. package/bundle/server/dist/resolve-tsx-bin.js +19 -0
  158. package/bundle/server/dist/routes/human-actions.js +68 -0
  159. package/bundle/server/dist/routes/human-actions.test.js +284 -0
  160. package/bundle/server/dist/routes/index.js +29 -496
  161. package/bundle/server/dist/routes/issues.js +240 -0
  162. package/bundle/server/dist/routes/issues.test.js +451 -0
  163. package/bundle/server/dist/routes/queue.js +41 -0
  164. package/bundle/server/dist/routes/queue.test.js +78 -0
  165. package/bundle/server/dist/runners/claude-args.js +18 -30
  166. package/bundle/server/dist/runners/claude.js +19 -163
  167. package/bundle/server/dist/runners/claude.test.js +15 -70
  168. package/bundle/server/dist/runners/codex-args.js +59 -0
  169. package/bundle/server/dist/runners/codex-args.test.js +72 -0
  170. package/bundle/server/dist/runners/codex-jsonl.js +150 -0
  171. package/bundle/server/dist/runners/codex-jsonl.test.js +36 -0
  172. package/bundle/server/dist/runners/codex-resume.test.js +19 -0
  173. package/bundle/server/dist/runners/codex.js +46 -0
  174. package/bundle/server/dist/runners/models-codex.test.js +14 -0
  175. package/bundle/server/dist/runners/models.js +53 -1
  176. package/bundle/server/dist/runners/persist.js +4 -1
  177. package/bundle/server/dist/runners/process-registry.js +11 -0
  178. package/bundle/server/dist/runners/prompts.js +13 -280
  179. package/bundle/server/dist/runners/prompts.test.js +22 -153
  180. package/bundle/server/dist/runners/qa.js +17 -1
  181. package/bundle/server/dist/runners/reflect.js +4 -16
  182. package/bundle/server/dist/runners/spawn-cli.js +197 -0
  183. package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
  184. package/bundle/server/dist/runners/stream-json.js +0 -141
  185. package/bundle/server/dist/runners/usage-cap.js +242 -0
  186. package/bundle/server/dist/runners/usage-cap.test.js +97 -0
  187. package/bundle/server/dist/server-liveness.js +37 -0
  188. package/bundle/server/dist/server-liveness.test.js +82 -0
  189. package/bundle/server/dist/static-ui.test.js +44 -0
  190. package/bundle/server/package.json +4 -2
  191. package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
  192. package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
  193. package/bundle/server/static-ui/index.html +2 -2
  194. package/bundle/shared/dist/agents.d.ts +262 -133
  195. package/bundle/shared/dist/agents.js +85 -19
  196. package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
  197. package/bundle/shared/dist/cursor-auth-health.js +36 -0
  198. package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
  199. package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
  200. package/bundle/shared/dist/findings.d.ts +46 -0
  201. package/bundle/shared/dist/findings.js +17 -0
  202. package/bundle/shared/dist/findings.test.d.ts +1 -0
  203. package/bundle/shared/dist/findings.test.js +20 -0
  204. package/bundle/shared/dist/github-repo.d.ts +28 -0
  205. package/bundle/shared/dist/github-repo.js +98 -0
  206. package/bundle/shared/dist/github-repo.test.d.ts +1 -0
  207. package/bundle/shared/dist/github-repo.test.js +32 -0
  208. package/bundle/shared/dist/human-actions.d.ts +66 -0
  209. package/bundle/shared/dist/human-actions.js +51 -0
  210. package/bundle/shared/dist/human-actions.test.d.ts +1 -0
  211. package/bundle/shared/dist/human-actions.test.js +57 -0
  212. package/bundle/shared/dist/index.d.ts +567 -757
  213. package/bundle/shared/dist/index.js +22 -36
  214. package/bundle/shared/dist/issues.d.ts +385 -0
  215. package/bundle/shared/dist/issues.js +169 -0
  216. package/bundle/shared/dist/issues.test.d.ts +1 -0
  217. package/bundle/shared/dist/issues.test.js +72 -0
  218. package/bundle/shared/dist/outbound-draft.d.ts +6 -0
  219. package/bundle/shared/dist/outbound-draft.js +4 -0
  220. package/bundle/shared/dist/plan-triage.d.ts +6 -6
  221. package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
  222. package/bundle/shared/dist/profile-snapshot.js +145 -0
  223. package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
  224. package/bundle/shared/dist/profile-snapshot.test.js +67 -0
  225. package/bundle/shared/dist/queue-entries.d.ts +89 -0
  226. package/bundle/shared/dist/queue-entries.js +19 -0
  227. package/bundle/shared/dist/result-qa.d.ts +2 -2
  228. package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
  229. package/bundle/shared/dist/runtime-auth-health.js +186 -0
  230. package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
  231. package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
  232. package/bundle/shared/dist/runtime-availability.d.ts +51 -0
  233. package/bundle/shared/dist/runtime-availability.js +13 -0
  234. package/bundle/shared/dist/runtime.d.ts +1 -1
  235. package/bundle/shared/dist/runtime.js +1 -1
  236. package/bundle/shared/dist/usage-events.d.ts +36 -0
  237. package/bundle/shared/dist/usage-events.js +13 -0
  238. package/bundle/shared/dist/worker-sessions.d.ts +140 -0
  239. package/bundle/shared/dist/worker-sessions.js +70 -0
  240. package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
  241. package/bundle/shared/dist/worker-sessions.test.js +39 -0
  242. package/bundle/shared/dist/workflow.d.ts +78 -0
  243. package/bundle/shared/dist/workflow.js +59 -0
  244. package/bundle/shared/dist/workflow.test.d.ts +1 -0
  245. package/bundle/shared/dist/workflow.test.js +61 -0
  246. package/bundle/shared/package.json +1 -1
  247. package/dist/action.d.ts +1 -0
  248. package/dist/action.js +50 -0
  249. package/dist/action.test.d.ts +1 -0
  250. package/dist/action.test.js +70 -0
  251. package/dist/agent.d.ts +1 -0
  252. package/dist/agent.js +17 -0
  253. package/dist/agent.test.d.ts +1 -0
  254. package/dist/agent.test.js +19 -0
  255. package/dist/cli-check.d.ts +7 -0
  256. package/dist/cli-check.js +40 -0
  257. package/dist/doctor.js +59 -1
  258. package/dist/http.d.ts +5 -0
  259. package/dist/http.js +19 -0
  260. package/dist/index.js +28 -0
  261. package/dist/issue.d.ts +35 -0
  262. package/dist/issue.js +143 -0
  263. package/dist/issue.test.d.ts +1 -0
  264. package/dist/issue.test.js +240 -0
  265. package/dist/lifecycle.contract.test.d.ts +1 -0
  266. package/dist/lifecycle.contract.test.js +135 -0
  267. package/dist/queue.d.ts +11 -0
  268. package/dist/queue.js +60 -0
  269. package/dist/queue.test.d.ts +1 -0
  270. package/dist/queue.test.js +46 -0
  271. package/dist/test-fetch-stub.d.ts +6 -0
  272. package/dist/test-fetch-stub.js +31 -0
  273. package/package.json +2 -1
  274. package/templates/prod.env.example +1 -1
  275. package/bundle/server/static-ui/assets/index-B_7S7xJb.js +0 -79
  276. package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
@@ -0,0 +1,234 @@
1
+ // packages/server/src/coordinator/failure-reason.test.ts
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { PRESUMED_DEAD_REASON, classifyRunnerLogFailure, parseErrorJsonReason, presumedDeadReclaimReason, reasonForDirtyWorktree, reasonForSessionCrash, reasonForWorkerFailedEvent, } from "./failure-reason.js";
9
+ const KEYCHAIN_STDERR = `Cursor couldn't save your login to the macOS keychain (errSecDuplicateItem, security exit code 45).
10
+ The keychain item is stuck. Delete it and sign in again:
11
+ security delete-generic-password -s cursor-access-token -a cursor-user
12
+ agent login
13
+ `;
14
+ /** Verbatim CLI captures — see packages/shared/src/fixtures/runtime-auth/README.md. */
15
+ const CAPTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../shared/src/fixtures/runtime-auth");
16
+ function capture(name) {
17
+ return fs.readFileSync(path.join(CAPTURES, name), "utf8");
18
+ }
19
+ function writeLog(body) {
20
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-fail-reason-"));
21
+ const logPath = path.join(dir, "session.ndjson");
22
+ fs.writeFileSync(logPath, body);
23
+ return logPath;
24
+ }
25
+ /** The NOT-133 sessions' entire log: a spawn that produced nothing but this stderr trailer. */
26
+ function writeAuthDeathLog() {
27
+ return writeLog(`\n--- stderr ---\n${capture("cursor-agent-print-logged-out.txt")}`);
28
+ }
29
+ test("classifyRunnerLogFailure maps keychain stderr trailer to auth/keychain reason", () => {
30
+ const logPath = writeLog(`{"type":"assistant"}\n--- stderr ---\n${KEYCHAIN_STDERR}`);
31
+ const reason = classifyRunnerLogFailure(logPath);
32
+ assert.ok(reason);
33
+ assert.match(reason, /keychain|errSecDuplicateItem/i);
34
+ assert.match(reason, /auth/i);
35
+ });
36
+ test("keychain classification does not depend on the session's recorded runtime", () => {
37
+ // worker_sessions.runtime is nullable and can disagree with whatever wrote the log; the
38
+ // keychain signature could only have come from Cursor either way.
39
+ const logPath = writeLog(`{"type":"assistant"}\n--- stderr ---\n${KEYCHAIN_STDERR}`);
40
+ for (const runtime of [undefined, "claude_code", "codex_local", "cursor_local"]) {
41
+ assert.match(classifyRunnerLogFailure(logPath, runtime), /keychain|errSecDuplicateItem/i);
42
+ }
43
+ });
44
+ test("classifyRunnerLogFailure maps reconnect exhaustion", () => {
45
+ const logPath = writeLog("stream error: failed to reconnect after 5 attempts\n");
46
+ assert.match(classifyRunnerLogFailure(logPath), /reconnect exhausted/i);
47
+ });
48
+ test("reasonForDirtyWorktree prefers keychain classification while preserving dirty-tree note", () => {
49
+ const logPath = writeLog(`\n--- stderr ---\n${KEYCHAIN_STDERR}`);
50
+ const reason = reasonForDirtyWorktree(logPath);
51
+ assert.match(reason, /keychain|errSecDuplicateItem/i);
52
+ assert.match(reason, /Worktree preserved/i);
53
+ });
54
+ test("NOT-133: a cursor session that died on auth is named, not 'failed or crashed'", () => {
55
+ const reason = reasonForSessionCrash({
56
+ timedOut: false,
57
+ logPath: writeAuthDeathLog(),
58
+ runtime: "cursor_local",
59
+ });
60
+ assert.notEqual(reason, "Developer session failed or crashed.");
61
+ assert.match(reason, /Cursor auth required mid-run/);
62
+ // The strip has to carry the remediation, not just the diagnosis.
63
+ assert.match(reason, /cursor-agent login/);
64
+ assert.match(reason, /CURSOR_API_KEY/);
65
+ });
66
+ test("NOT-133: the same log classifies without being told which runtime wrote it", () => {
67
+ // The recovery/detail path only has a log path — it must not fall back to the generic reason.
68
+ assert.match(classifyRunnerLogFailure(writeAuthDeathLog()), /Cursor auth required mid-run/);
69
+ });
70
+ test("NOT-133: codex and claude auth deaths are named too", () => {
71
+ const codex = classifyRunnerLogFailure(writeLog(`\n--- stderr ---\n${capture("codex-exec-logged-out.txt")}`), "codex_local");
72
+ assert.match(codex, /Codex auth required mid-run/);
73
+ assert.match(codex, /codex login/);
74
+ const claude = classifyRunnerLogFailure(writeLog(`\n--- stderr ---\n${capture("claude-print-logged-out.txt")}`), "claude_code");
75
+ assert.match(claude, /Claude Code auth required mid-run/);
76
+ assert.match(claude, /claude auth login/);
77
+ });
78
+ test("NOT-133: a runtime-less Claude log is named Claude, not Cursor", () => {
79
+ // worker_sessions.runtime is nullable; "Not logged in · Please run /login" is Claude's, but
80
+ // Cursor's pattern list carries "not logged in" too, so order-of-trial named Cursor here.
81
+ const reason = classifyRunnerLogFailure(writeLog(`\n--- stderr ---\n${capture("claude-print-logged-out.txt")}`));
82
+ assert.match(reason, /Claude Code auth required mid-run/);
83
+ assert.match(reason, /claude auth login/);
84
+ assert.doesNotMatch(reason, /cursor/i);
85
+ });
86
+ test("NOT-133: a runtime-less Codex log is named Codex, not Cursor", () => {
87
+ const reason = classifyRunnerLogFailure(writeLog(`\n--- stderr ---\n${capture("codex-exec-logged-out.txt")}`));
88
+ assert.match(reason, /Codex auth required mid-run/);
89
+ assert.match(reason, /codex login/);
90
+ assert.doesNotMatch(reason, /cursor/i);
91
+ });
92
+ test("NOT-133: a runtime-less log that names no CLI reports auth without guessing one", () => {
93
+ // `cursor-agent status` and `codex login status` both print exactly "Not logged in".
94
+ const reason = classifyRunnerLogFailure(writeLog(`\n--- stderr ---\n${capture("codex-login-status-logged-out.txt")}`));
95
+ assert.match(reason, /Runtime auth required mid-run/);
96
+ assert.match(reason, /cursor-agent login/);
97
+ assert.match(reason, /codex login/);
98
+ assert.match(reason, /claude auth login/);
99
+ // Still better than the thing NOT-133 was filed about.
100
+ assert.notEqual(reason, "Developer session failed or crashed.");
101
+ });
102
+ test("NOT-133: a recorded runtime still names that CLI for the shared `Not logged in`", () => {
103
+ const logPath = writeLog(`\n--- stderr ---\n${capture("codex-login-status-logged-out.txt")}`);
104
+ assert.match(classifyRunnerLogFailure(logPath, "codex_local"), /Codex auth required mid-run/);
105
+ assert.match(classifyRunnerLogFailure(logPath, "cursor_local"), /Cursor auth required mid-run/);
106
+ });
107
+ test("NOT-133: a log that names its own CLI outranks a wrong recorded runtime", () => {
108
+ // worker_sessions.runtime can disagree with what ran (nullable column, older rows, an agent
109
+ // whose runtime was edited between attempts). Telling the operator `cursor-agent login`
110
+ // because the row said cursor_local, when Claude printed the failure, is the same class of
111
+ // wrong answer NOT-133 is about — a confident remediation for the wrong CLI.
112
+ const claudeLog = writeLog(`\n--- stderr ---\n${capture("claude-print-logged-out.txt")}`);
113
+ for (const wrong of ["cursor_local", "codex_local"]) {
114
+ const reason = classifyRunnerLogFailure(claudeLog, wrong);
115
+ assert.match(reason, /Claude Code auth required mid-run/, `recorded ${wrong} must not win`);
116
+ assert.match(reason, /claude auth login/);
117
+ assert.doesNotMatch(reason, /cursor|codex/i);
118
+ }
119
+ const codexLog = writeLog(`\n--- stderr ---\n${capture("codex-exec-logged-out.txt")}`);
120
+ assert.match(classifyRunnerLogFailure(codexLog, "cursor_local"), /Codex auth required mid-run/);
121
+ const cursorLog = writeAuthDeathLog();
122
+ assert.match(classifyRunnerLogFailure(cursorLog, "claude_code"), /Cursor auth required mid-run/);
123
+ });
124
+ /**
125
+ * A worker transcript that *discusses* auth failures — the NOT-133 session itself quotes every
126
+ * capture in this directory — must not be read as one. Every runner spawns its CLI in a
127
+ * structured output mode, so transcript prose always arrives inside stream events; only
128
+ * stderr and terminal error events describe how the session died.
129
+ */
130
+ const TRANSCRIPT_QUOTING_AUTH_PROSE = [
131
+ JSON.stringify({
132
+ type: "assistant",
133
+ message: {
134
+ content: [
135
+ {
136
+ type: "text",
137
+ text: `The classifier missed the real string. Captures:\n` +
138
+ `${capture("cursor-agent-print-logged-out.txt")}` +
139
+ `${capture("claude-print-logged-out.txt")}` +
140
+ `${capture("codex-exec-logged-out.txt")}` +
141
+ `${capture("cursor-agent-print-invalid-api-key.txt")}`,
142
+ },
143
+ ],
144
+ },
145
+ }),
146
+ // Codex's native JSONL transcript shape carries the same prose in `item.text`.
147
+ JSON.stringify({
148
+ type: "item.completed",
149
+ item: { type: "agent_message", text: capture("claude-print-invalid-api-key.txt") },
150
+ }),
151
+ JSON.stringify({ type: "result", is_error: true, result: "exit 1" }),
152
+ ].join("\n");
153
+ test("NOT-133: auth prose inside a non-error transcript event is not an auth failure", () => {
154
+ const logPath = writeLog(`${TRANSCRIPT_QUOTING_AUTH_PROSE}\n`);
155
+ for (const runtime of [undefined, "cursor_local", "codex_local", "claude_code"]) {
156
+ assert.equal(classifyRunnerLogFailure(logPath, runtime), null, `transcript prose must not classify as auth for runtime=${runtime}`);
157
+ }
158
+ assert.equal(reasonForSessionCrash({ timedOut: false, logPath, runtime: "cursor_local" }), "Developer session failed or crashed.");
159
+ });
160
+ test("NOT-133: the same prose in a terminal error result IS an auth failure", () => {
161
+ // The narrowed haystack must not lose the signal: a CLI that reports its auth failure as
162
+ // the session's error result, rather than on stderr, still has to be classified.
163
+ const logPath = writeLog(`${JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "starting" }] } })}\n` +
164
+ `${JSON.stringify({
165
+ type: "result",
166
+ is_error: true,
167
+ result: capture("cursor-agent-print-logged-out.txt"),
168
+ })}\n`);
169
+ assert.match(classifyRunnerLogFailure(logPath), /Cursor auth required mid-run/);
170
+ assert.match(classifyRunnerLogFailure(logPath, "cursor_local"), /CURSOR_API_KEY/);
171
+ });
172
+ test("NOT-133: a Codex turn.failed error message is classified", () => {
173
+ const logPath = writeLog(`${JSON.stringify({ type: "thread.started", thread_id: "t1" })}\n` +
174
+ `${JSON.stringify({
175
+ type: "turn.failed",
176
+ error: { message: capture("codex-exec-logged-out.txt") },
177
+ })}\n`);
178
+ assert.match(classifyRunnerLogFailure(logPath, "codex_local"), /Codex auth required mid-run/);
179
+ });
180
+ test("NOT-133: an error event carries its message whether nested or flat", () => {
181
+ // `error` arrives as a bare string in some events and `{ message }` in others; reading
182
+ // only one shape silently loses the failure and falls back to "failed or crashed".
183
+ for (const error of [
184
+ capture("cursor-agent-print-logged-out.txt"),
185
+ { message: capture("cursor-agent-print-logged-out.txt") },
186
+ ]) {
187
+ const logPath = writeLog(`${JSON.stringify({ type: "error", error })}\n`);
188
+ assert.match(classifyRunnerLogFailure(logPath), /Cursor auth required mid-run/);
189
+ }
190
+ });
191
+ test("NOT-133: a timestamped plain-text CLI line is still classified", () => {
192
+ // Not every CLI diagnostic is a JSON event; a bracketed prefix must not make one look
193
+ // like a transcript line and get skipped.
194
+ const logPath = writeLog(`[2026-09-16T05:05:53Z] ${capture("cursor-agent-print-logged-out.txt")}`);
195
+ assert.match(classifyRunnerLogFailure(logPath), /Cursor auth required mid-run/);
196
+ });
197
+ test("NOT-133: a worker.failed event for an auth death carries the auth reason", () => {
198
+ const reason = reasonForWorkerFailedEvent({
199
+ outcome: { kind: "session_failed" },
200
+ routeReason: "Developer session failed or crashed.",
201
+ logPath: writeAuthDeathLog(),
202
+ runtime: "cursor_local",
203
+ });
204
+ assert.match(reason, /Cursor auth required mid-run/);
205
+ });
206
+ test("reasonForSessionCrash falls back when log is clean", () => {
207
+ assert.equal(reasonForSessionCrash({ timedOut: false, logPath: writeLog('{"type":"result"}\n') }), "Developer session failed or crashed.");
208
+ });
209
+ test("parseErrorJsonReason reads recovery presumed-dead shape", () => {
210
+ assert.equal(parseErrorJsonReason(JSON.stringify({ reason: PRESUMED_DEAD_REASON })), PRESUMED_DEAD_REASON);
211
+ });
212
+ test("presumedDeadReclaimReason names the role that is actually being re-run (NOT-129)", () => {
213
+ // A reviewer item has no branch of its own to publish, so republish is always null for it —
214
+ // the message must not claim the developer is being re-run, nor mention a branch.
215
+ const reviewer = presumedDeadReclaimReason("reviewer", null);
216
+ assert.match(reviewer, /presumed dead/);
217
+ assert.match(reviewer, /re-running the reviewer/);
218
+ assert.doesNotMatch(reviewer, /branch|developer/);
219
+ assert.match(presumedDeadReclaimReason("developer", null), /nothing on the branch to publish, re-running the developer/);
220
+ assert.match(presumedDeadReclaimReason("developer", { branch: "issue-1", commits: 2, alreadyPushed: false }), /republishing 2 unpushed commits on issue-1/);
221
+ assert.match(presumedDeadReclaimReason("developer", { branch: "issue-1", commits: 1, alreadyPushed: true }), /issue-1 is already on origin, re-verifying the PR/);
222
+ });
223
+ test("reasonForWorkerFailedEvent prefers explicit outcome.reason", () => {
224
+ assert.equal(reasonForWorkerFailedEvent({
225
+ outcome: { kind: "dirty_worktree", reason: "Cursor macOS keychain/auth died mid-run." },
226
+ routeReason: "generic dirty",
227
+ }), "Cursor macOS keychain/auth died mid-run.");
228
+ });
229
+ test("reasonForWorkerFailedEvent uses session errorJson for presumed dead", () => {
230
+ assert.equal(reasonForWorkerFailedEvent({
231
+ outcome: { kind: "session_failed" },
232
+ sessionErrorJson: JSON.stringify({ reason: PRESUMED_DEAD_REASON }),
233
+ }), PRESUMED_DEAD_REASON);
234
+ });
@@ -0,0 +1,33 @@
1
+ // packages/server/src/coordinator/guidance.ts
2
+ //
3
+ // NOT-64 "guidance semantics": a one-shot developer/reviewer CLI process never inherits a
4
+ // running session (see design doc "Guidance semantics" — every round is a fresh process
5
+ // with a fresh worktree, and resuming a session across a possibly multi-day human-review
6
+ // gap is not a contract these worktree/CLI lifecycles support). So "guidance applies to
7
+ // the next worker" just means: which guidance.added text hasn't been shown to any prompt
8
+ // yet. That's everything added after the issue's previous worker session's own snapshot
9
+ // moment, up through THIS session's own snapshot moment.
10
+ //
11
+ // The upper bound matters: worker-loop.ts marks a session "running" (startSession) and
12
+ // records its `worker.started` event *before* the effect handler's worktree setup and
13
+ // deck bind — both of which take real wall-clock time before this function is actually
14
+ // called to build the prompt. Guidance posted during that setup window arrives after the
15
+ // session is already dispatched and (per the UI contract) must be deferred to the *next*
16
+ // worker, not swept into this one just because the query ran a few seconds late.
17
+ //
18
+ // The window is anchored to each session's `worker.started` event via a rowid cursor, not
19
+ // a `created_at`/`started_at` timestamp: timestamps only have millisecond precision and a
20
+ // guidance event landing in the same millisecond as the cutoff is a real, reproduced
21
+ // collision (review round 2) that a `ts <=` comparison gets wrong either way it's drawn.
22
+ // `workflow_events.rowid` is a strictly monotonic insertion-order integer, so it can never
23
+ // collide regardless of how close two events land in wall-clock time.
24
+ import { listWorkerSessionsForIssue } from "../repository/worker-sessions.js";
25
+ import { listGuidanceMarkdownForIssue, workerStartedEventCursor } from "../repository/workflow-events.js";
26
+ export function guidanceForNextSession(issueId, currentSessionId) {
27
+ const sessions = listWorkerSessionsForIssue(issueId);
28
+ const idx = sessions.findIndex((s) => s.id === currentSessionId);
29
+ const previous = idx > 0 ? sessions[idx - 1] : null;
30
+ const sinceCursor = previous ? workerStartedEventCursor(previous.id) : null;
31
+ const untilCursor = workerStartedEventCursor(currentSessionId);
32
+ return listGuidanceMarkdownForIssue(issueId, sinceCursor, untilCursor);
33
+ }
@@ -0,0 +1,110 @@
1
+ // packages/server/src/coordinator/guidance.test.ts
2
+ import { test, before } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-guidance-"));
8
+ const { migrate } = await import("../db/index.js");
9
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
10
+ const { createIssue } = await import("../repository/issues.js");
11
+ const { createWorkerSession, startSession } = await import("../repository/worker-sessions.js");
12
+ const { appendWorkflowEvent } = await import("../repository/workflow-events.js");
13
+ const { guidanceForNextSession } = await import("./guidance.js");
14
+ before(() => {
15
+ migrate();
16
+ });
17
+ function seedIssue() {
18
+ return createIssue({
19
+ title: "Guidance host issue",
20
+ repo: "acme/app",
21
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
22
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
23
+ baseBranch: "main",
24
+ maxReviewRounds: 3,
25
+ maxInfraAttempts: 3,
26
+ source: "manual"
27
+ }).id;
28
+ }
29
+ /** Mirrors worker-loop.ts's real transaction: create → startSession → the `worker.started`
30
+ * event, all before the effect handler (and this module) ever runs — so every session a
31
+ * test creates has the same rowid-cursor anchor `guidanceForNextSession` relies on. */
32
+ function addSession(issueId, role, round) {
33
+ const session = createWorkerSession({ issueId, role, round, agentId: BUILTIN_AGENT_CLAUDE_ID, runtime: "claude_code" });
34
+ startSession(session.id);
35
+ appendWorkflowEvent({ issueId, workerSessionId: session.id, type: "worker.started", actorType: "system", stage: "developing", round });
36
+ return session;
37
+ }
38
+ function sleep(ms) {
39
+ return new Promise((r) => setTimeout(r, ms));
40
+ }
41
+ test("first session picks up all guidance added before it, since there is no prior session", async () => {
42
+ const issueId = seedIssue();
43
+ appendWorkflowEvent({ issueId, type: "guidance.added", actorType: "human", stage: "ready", payload: { markdown: "Pre-start note" } });
44
+ await sleep(5);
45
+ const round1Dev = addSession(issueId, "developer", 1);
46
+ assert.deepStrictEqual(guidanceForNextSession(issueId, round1Dev.id), ["Pre-start note"]);
47
+ });
48
+ test("a later round only sees guidance added since the immediately preceding session", async () => {
49
+ // Each guidanceForNextSession call happens at the point it actually would in production
50
+ // — right before that session's own prompt is built, before any *later* guidance exists
51
+ // — not retroactively after the whole issue history has played out.
52
+ const issueId = seedIssue();
53
+ const round1Dev = addSession(issueId, "developer", 1);
54
+ assert.deepStrictEqual(guidanceForNextSession(issueId, round1Dev.id), []);
55
+ await sleep(5);
56
+ appendWorkflowEvent({ issueId, type: "guidance.added", actorType: "human", stage: "developing", payload: { markdown: "During round 1" } });
57
+ await sleep(5);
58
+ const round1Reviewer = addSession(issueId, "reviewer", 1);
59
+ assert.deepStrictEqual(guidanceForNextSession(issueId, round1Reviewer.id), ["During round 1"]);
60
+ await sleep(5);
61
+ appendWorkflowEvent({ issueId, type: "guidance.added", actorType: "human", stage: "reviewing", payload: { markdown: "During review" } });
62
+ await sleep(5);
63
+ const round2Dev = addSession(issueId, "developer", 2);
64
+ assert.deepStrictEqual(guidanceForNextSession(issueId, round2Dev.id), ["During review"]);
65
+ });
66
+ test("guidance added while a session is running is deferred to the next session, not lost", async () => {
67
+ const issueId = seedIssue();
68
+ const round1Dev = addSession(issueId, "developer", 1);
69
+ assert.deepStrictEqual(guidanceForNextSession(issueId, round1Dev.id), []);
70
+ await sleep(5);
71
+ // Simulates guidance posted mid-session — it must not appear for round1Dev (already
72
+ // spawned) and must appear for whatever session is enqueued next.
73
+ appendWorkflowEvent({ issueId, type: "guidance.added", actorType: "human", stage: "developing", payload: { markdown: "Mid-session note" } });
74
+ await sleep(5);
75
+ const round1Reviewer = addSession(issueId, "reviewer", 1);
76
+ assert.deepStrictEqual(guidanceForNextSession(issueId, round1Reviewer.id), ["Mid-session note"]);
77
+ });
78
+ // PR #11 review round 1: worker-loop.ts marks a session "running" (startSession) BEFORE
79
+ // its worktree setup and deck bind, both of which take real wall-clock time before
80
+ // guidanceForNextSession is actually called to build the prompt. Guidance posted in that
81
+ // gap must still be deferred to the *next* session, not swept into this one just because
82
+ // the call happened a few seconds late.
83
+ test("guidance posted after the session starts running (but before its prompt is actually built) is deferred to the next session", async () => {
84
+ const issueId = seedIssue();
85
+ const round1Dev = addSession(issueId, "developer", 1); // create + startSession + worker.started, atomically
86
+ // Simulates guidance posted during worktree setup / deck bind, i.e. after the session
87
+ // already started but before this function is actually invoked for it.
88
+ appendWorkflowEvent({ issueId, type: "guidance.added", actorType: "human", stage: "developing", payload: { markdown: "Posted during worktree setup" } });
89
+ // Called "late", as if setup took a few seconds — must not see guidance posted after
90
+ // this session's own worker.started event.
91
+ assert.deepStrictEqual(guidanceForNextSession(issueId, round1Dev.id), []);
92
+ const round1Reviewer = addSession(issueId, "reviewer", 1);
93
+ assert.deepStrictEqual(guidanceForNextSession(issueId, round1Reviewer.id), ["Posted during worktree setup"]);
94
+ });
95
+ // PR #11 review round 2: `ts` only has millisecond precision, so a `ts <=` boundary can
96
+ // wrongly include (or a `ts <` boundary wrongly exclude) a guidance event that lands in
97
+ // the exact same millisecond as the session's own snapshot event. No sleeps here at all —
98
+ // everything happens back-to-back so it's likely to collide on `ts` — proving the rowid
99
+ // cursor is what actually decides the boundary, not wall-clock time.
100
+ test("a millisecond-colliding guidance event is still correctly bucketed by insertion order, not timestamp", () => {
101
+ const issueId = seedIssue();
102
+ const round1Dev = addSession(issueId, "developer", 1);
103
+ appendWorkflowEvent({ issueId, type: "guidance.added", actorType: "human", stage: "developing", payload: { markdown: "Right after round1Dev started" } });
104
+ const round1Reviewer = addSession(issueId, "reviewer", 1);
105
+ appendWorkflowEvent({ issueId, type: "guidance.added", actorType: "human", stage: "reviewing", payload: { markdown: "Right after round1Reviewer started" } });
106
+ const round2Dev = addSession(issueId, "developer", 2);
107
+ assert.deepStrictEqual(guidanceForNextSession(issueId, round1Dev.id), []);
108
+ assert.deepStrictEqual(guidanceForNextSession(issueId, round1Reviewer.id), ["Right after round1Dev started"]);
109
+ assert.deepStrictEqual(guidanceForNextSession(issueId, round2Dev.id), ["Right after round1Reviewer started"]);
110
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The only choices resolveHumanActionOutcome accepts per action type — the stored "response
3
+ * options." `reflection_interaction_required` and `outbound_delivery_interaction_required`
4
+ * are listed here only so this map stays total over `HumanActionType`; neither is ever
5
+ * routed through `resolveHumanActionOutcome` (NOT-94's reflect-trigger.ts and NOT-95's
6
+ * `resolveOutboundDeliveryAction` resolve them directly — the former runs after the issue is
7
+ * already `done`, the latter is Run-scoped with no issue/workflow at all; both must never
8
+ * reopen an issue or enqueue developer/reviewer work).
9
+ */
10
+ const VALID_CHOICES = {
11
+ final_review: ["complete", "repair", "close"],
12
+ attempts_exhausted: ["retry", "close"],
13
+ policy_escalation: ["resume", "close"],
14
+ product_scope_decision: ["resume"],
15
+ deck_interaction_required: ["resume", "close"],
16
+ reflection_interaction_required: ["retry", "dismiss"],
17
+ outbound_delivery_interaction_required: ["retry_send", "reject"],
18
+ };
19
+ /**
20
+ * Validates a raw (actionType, choice) pair against that action type's allowed response
21
+ * options before it's ever treated as a typed HumanResolution. Returns null for anything
22
+ * not in the allowed set — callers must reject the request rather than guess at intent.
23
+ *
24
+ * `reflection_interaction_required` is deliberately rejected here even though
25
+ * `VALID_CHOICES` lists it (PR #21 review): it has no corresponding `HumanResolution`
26
+ * variant, so casting it through would be a type lie, and its only legal resolver is
27
+ * `resolveReflectionInteractionAction` (reflect-trigger.ts), never `resolveHumanActionOutcome`.
28
+ * `outbound_delivery_interaction_required` is rejected for the same reason (NOT-95): it is
29
+ * Run-scoped, has no Issue/workflow_instance to advance, and its only legal resolver is
30
+ * `resolveOutboundDeliveryAction` (queue/approve-deliver.ts).
31
+ */
32
+ export function parseHumanResolution(actionType, choice) {
33
+ if (actionType === "reflection_interaction_required" || actionType === "outbound_delivery_interaction_required") {
34
+ return null;
35
+ }
36
+ if (!(actionType in VALID_CHOICES))
37
+ return null;
38
+ if (!VALID_CHOICES[actionType].includes(choice))
39
+ return null;
40
+ return { actionType, choice };
41
+ }
42
+ /**
43
+ * Implements PRD §6.3's human pass-the-ball outcomes: continue, repair, complete, or close.
44
+ * v1 (open decision #2 in the spec) treats every "resume/retry/repair" choice as starting
45
+ * another round rather than distinguishing a true resume from a fresh repair round.
46
+ *
47
+ * Every branch is explicit — an unrecognized choice throws rather than silently falling
48
+ * through to "close" (the previous shape's `? X : close` ternaries meant any typo or bad
49
+ * input silently closed the issue). Callers should validate with parseHumanResolution
50
+ * first, making these throws unreachable in practice; they exist as defense in depth.
51
+ */
52
+ export function resolveHumanActionOutcome(resolution) {
53
+ switch (resolution.actionType) {
54
+ case "final_review":
55
+ if (resolution.choice === "complete")
56
+ return { issueStatus: "done", workflowOutcome: "done", triggerReflect: true };
57
+ if (resolution.choice === "repair")
58
+ return { issueStatus: "repairing", startNewRound: true, roundKind: "review" };
59
+ if (resolution.choice === "close")
60
+ return { issueStatus: "closed", workflowOutcome: "closed" };
61
+ throw new Error(`Unrecognized final_review choice: ${resolution.choice}`);
62
+ case "attempts_exhausted":
63
+ // A "retry" must grant one more round, not just re-spend the exhausted one —
64
+ // otherwise the very next changes_requested re-creates attempts_exhausted immediately.
65
+ if (resolution.choice === "retry")
66
+ return { issueStatus: "repairing", startNewRound: true, roundKind: "review_grant" };
67
+ if (resolution.choice === "close")
68
+ return { issueStatus: "closed", workflowOutcome: "closed" };
69
+ throw new Error(`Unrecognized attempts_exhausted choice: ${resolution.choice}`);
70
+ case "policy_escalation":
71
+ // An infra escalation resuming is not a review-round spend — reset the infra budget instead.
72
+ if (resolution.choice === "resume")
73
+ return { issueStatus: "developing", startNewRound: true, roundKind: "infra" };
74
+ if (resolution.choice === "close")
75
+ return { issueStatus: "closed", workflowOutcome: "closed" };
76
+ throw new Error(`Unrecognized policy_escalation choice: ${resolution.choice}`);
77
+ case "product_scope_decision":
78
+ return { issueStatus: "developing", startNewRound: true, roundKind: "none" };
79
+ case "deck_interaction_required":
80
+ // Same semantics as policy_escalation:resume — an authority/control-plane hiccup is
81
+ // not a review-round spend; a fresh attempt mints its own new authority when it runs.
82
+ if (resolution.choice === "resume")
83
+ return { issueStatus: "developing", startNewRound: true, roundKind: "infra" };
84
+ if (resolution.choice === "close")
85
+ return { issueStatus: "closed", workflowOutcome: "closed" };
86
+ throw new Error(`Unrecognized deck_interaction_required choice: ${resolution.choice}`);
87
+ }
88
+ }
@@ -0,0 +1,72 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolveHumanActionOutcome, parseHumanResolution } from "./human-resolution.js";
4
+ // Reviewer finding #8: an unrecognized choice must be rejected, not silently treated as "close".
5
+ test("parseHumanResolution rejects a choice not in that action type's allowed set", () => {
6
+ assert.equal(parseHumanResolution("final_review", "bogus"), null);
7
+ assert.equal(parseHumanResolution("attempts_exhausted", "complete"), null); // valid for final_review, not this type
8
+ });
9
+ test("parseHumanResolution rejects an unknown action type", () => {
10
+ assert.equal(parseHumanResolution("not_a_real_action_type", "complete"), null);
11
+ });
12
+ test("parseHumanResolution accepts a valid (actionType, choice) pair", () => {
13
+ assert.deepStrictEqual(parseHumanResolution("final_review", "repair"), { actionType: "final_review", choice: "repair" });
14
+ });
15
+ // PR #21 review finding #3: reflection_interaction_required has no HumanResolution variant
16
+ // — it must never parse successfully here even though VALID_CHOICES lists its choices (kept
17
+ // there only so that Record<HumanActionType, ...> stays total). Its only legal resolver is
18
+ // resolveReflectionInteractionAction (reflect-trigger.ts).
19
+ test("parseHumanResolution rejects reflection_interaction_required even though VALID_CHOICES lists its choices", () => {
20
+ assert.equal(parseHumanResolution("reflection_interaction_required", "retry"), null);
21
+ assert.equal(parseHumanResolution("reflection_interaction_required", "dismiss"), null);
22
+ });
23
+ test("resolveHumanActionOutcome throws rather than silently closing on an invalid choice reaching it directly", () => {
24
+ assert.throws(() => resolveHumanActionOutcome({ actionType: "final_review", choice: "bogus" }), /Unrecognized final_review choice/);
25
+ });
26
+ test("final_review complete marks the issue done and triggers reflect", () => {
27
+ const result = resolveHumanActionOutcome({ actionType: "final_review", choice: "complete" });
28
+ assert.equal(result.issueStatus, "done");
29
+ assert.equal(result.workflowOutcome, "done");
30
+ assert.equal(result.triggerReflect, true);
31
+ });
32
+ test("final_review repair sends the issue back for another round without reflect", () => {
33
+ const result = resolveHumanActionOutcome({ actionType: "final_review", choice: "repair" });
34
+ assert.equal(result.issueStatus, "repairing");
35
+ assert.equal(result.startNewRound, true);
36
+ assert.equal(result.triggerReflect, undefined);
37
+ });
38
+ test("final_review close closes the workflow without accepting the work", () => {
39
+ const result = resolveHumanActionOutcome({ actionType: "final_review", choice: "close" });
40
+ assert.equal(result.issueStatus, "closed");
41
+ assert.equal(result.workflowOutcome, "closed");
42
+ assert.equal(result.triggerReflect, undefined);
43
+ });
44
+ test("attempts_exhausted retry starts a new round (v1: equivalent to another repair round)", () => {
45
+ const result = resolveHumanActionOutcome({ actionType: "attempts_exhausted", choice: "retry" });
46
+ assert.equal(result.issueStatus, "repairing");
47
+ assert.equal(result.startNewRound, true);
48
+ });
49
+ test("attempts_exhausted close ends the issue", () => {
50
+ const result = resolveHumanActionOutcome({ actionType: "attempts_exhausted", choice: "close" });
51
+ assert.equal(result.issueStatus, "closed");
52
+ });
53
+ test("policy_escalation resume continues development", () => {
54
+ const result = resolveHumanActionOutcome({ actionType: "policy_escalation", choice: "resume" });
55
+ assert.equal(result.issueStatus, "developing");
56
+ assert.equal(result.startNewRound, true);
57
+ });
58
+ test("product_scope_decision resume continues development from the pre-start gate", () => {
59
+ const result = resolveHumanActionOutcome({ actionType: "product_scope_decision", choice: "resume" });
60
+ assert.equal(result.issueStatus, "developing");
61
+ });
62
+ test("deck_interaction_required resume starts a fresh infra round, not a review-round spend", () => {
63
+ const result = resolveHumanActionOutcome({ actionType: "deck_interaction_required", choice: "resume" });
64
+ assert.equal(result.issueStatus, "developing");
65
+ assert.equal(result.startNewRound, true);
66
+ assert.equal(result.roundKind, "infra");
67
+ });
68
+ test("deck_interaction_required close ends the issue", () => {
69
+ const result = resolveHumanActionOutcome({ actionType: "deck_interaction_required", choice: "close" });
70
+ assert.equal(result.issueStatus, "closed");
71
+ assert.equal(result.workflowOutcome, "closed");
72
+ });
@@ -0,0 +1,13 @@
1
+ const FORECASTS = {
2
+ ready: () => ({ now: "Ready to start", next: "Start the developer round" }),
3
+ developing: (i) => ({ now: `Developer implementing round ${i.currentRound}`, next: "Verify the handoff once the session completes" }),
4
+ reviewing: (i) => ({ now: `Reviewer evaluating round ${i.currentRound}`, next: "Route the reviewer's verdict" }),
5
+ repairing: (i) => ({ now: `Developer repairing round ${i.currentRound}`, next: "Verify the handoff once the session completes" }),
6
+ final_review: () => ({ now: "Awaiting final human review", next: "Human resolves: complete, repair, or close" }),
7
+ needs_human: () => ({ now: "Waiting on a human action", next: "Resolve the open action to resume" }),
8
+ done: () => ({ now: "Done", next: "" }),
9
+ closed: () => ({ now: "Closed", next: "" }),
10
+ };
11
+ export function computeIntentForecast(issue) {
12
+ return FORECASTS[issue.status](issue);
13
+ }
@@ -0,0 +1,34 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { computeIntentForecast } from "./intent-forecast.js";
4
+ const BASE = {
5
+ id: "i1", source: "manual", externalId: null, externalLabel: null, externalUrl: null,
6
+ title: "T", description: null, acceptanceCriteria: null, repo: "/r", baseBranch: "main",
7
+ status: "ready", currentOwner: "system", currentIntent: null,
8
+ developerAgentId: null, reviewerAgentId: null, maxReviewRounds: 3, currentRound: 1,
9
+ branch: null, baseSha: null, headSha: null, prNumber: null, prUrl: null,
10
+ createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z",
11
+ };
12
+ test("ready issue forecasts starting the developer round", () => {
13
+ const forecast = computeIntentForecast(BASE);
14
+ assert.match(forecast.now, /Ready/i);
15
+ assert.match(forecast.next, /developer/i);
16
+ });
17
+ test("developing issue forecasts handoff verification", () => {
18
+ const forecast = computeIntentForecast({ ...BASE, status: "developing", currentOwner: "developer" });
19
+ assert.match(forecast.now, /Developer/i);
20
+ assert.match(forecast.next, /verify|handoff|pr/i);
21
+ });
22
+ test("reviewing issue mentions the round number", () => {
23
+ const forecast = computeIntentForecast({ ...BASE, status: "reviewing", currentOwner: "reviewer", currentRound: 2 });
24
+ assert.match(forecast.now, /Reviewer/i);
25
+ assert.ok(forecast.now.includes("2") || forecast.next.includes("2"));
26
+ });
27
+ test("needs_human issue forecasts waiting on a decision", () => {
28
+ const forecast = computeIntentForecast({ ...BASE, status: "needs_human", currentOwner: "human" });
29
+ assert.match(forecast.now, /human|action/i);
30
+ });
31
+ test("done issue has no next step", () => {
32
+ const forecast = computeIntentForecast({ ...BASE, status: "done" });
33
+ assert.equal(forecast.next, "");
34
+ });