@telora/daemon 0.17.36 → 0.17.42

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 (266) hide show
  1. package/build-info.json +5 -3
  2. package/dist/assembly-engine.d.ts +6 -0
  3. package/dist/assembly-engine.d.ts.map +1 -1
  4. package/dist/assembly-engine.js +19 -0
  5. package/dist/assembly-engine.js.map +1 -1
  6. package/dist/assembly-resolvers.d.ts +17 -8
  7. package/dist/assembly-resolvers.d.ts.map +1 -1
  8. package/dist/assembly-resolvers.js +19 -8
  9. package/dist/assembly-resolvers.js.map +1 -1
  10. package/dist/cli/session-state.d.ts +10 -0
  11. package/dist/cli/session-state.d.ts.map +1 -1
  12. package/dist/cli/session-state.js +31 -0
  13. package/dist/cli/session-state.js.map +1 -1
  14. package/dist/completion/completion-decision.d.ts +83 -0
  15. package/dist/completion/completion-decision.d.ts.map +1 -0
  16. package/dist/completion/completion-decision.js +48 -0
  17. package/dist/completion/completion-decision.js.map +1 -0
  18. package/dist/completion/event-escalations.d.ts +97 -0
  19. package/dist/completion/event-escalations.d.ts.map +1 -0
  20. package/dist/completion/event-escalations.js +213 -0
  21. package/dist/completion/event-escalations.js.map +1 -0
  22. package/dist/completion/event.d.ts +1 -72
  23. package/dist/completion/event.d.ts.map +1 -1
  24. package/dist/completion/event.js +148 -322
  25. package/dist/completion/event.js.map +1 -1
  26. package/dist/completion/exit-classification.d.ts +82 -0
  27. package/dist/completion/exit-classification.d.ts.map +1 -0
  28. package/dist/completion/exit-classification.js +61 -0
  29. package/dist/completion/exit-classification.js.map +1 -0
  30. package/dist/completion/index.d.ts +14 -5
  31. package/dist/completion/index.d.ts.map +1 -1
  32. package/dist/completion/index.js +14 -5
  33. package/dist/completion/index.js.map +1 -1
  34. package/dist/completion/merge-phase.d.ts +114 -0
  35. package/dist/completion/merge-phase.d.ts.map +1 -0
  36. package/dist/completion/merge-phase.js +198 -0
  37. package/dist/completion/merge-phase.js.map +1 -0
  38. package/dist/completion/review-exit-phase.d.ts +82 -0
  39. package/dist/completion/review-exit-phase.d.ts.map +1 -0
  40. package/dist/completion/review-exit-phase.js +228 -0
  41. package/dist/completion/review-exit-phase.js.map +1 -0
  42. package/dist/completion/status-advance-phase.d.ts +61 -0
  43. package/dist/completion/status-advance-phase.d.ts.map +1 -0
  44. package/dist/completion/status-advance-phase.js +182 -0
  45. package/dist/completion/status-advance-phase.js.map +1 -0
  46. package/dist/completion/team-completion.d.ts +28 -269
  47. package/dist/completion/team-completion.d.ts.map +1 -1
  48. package/dist/completion/team-completion.js +145 -676
  49. package/dist/completion/team-completion.js.map +1 -1
  50. package/dist/daemon-process.d.ts +18 -2
  51. package/dist/daemon-process.d.ts.map +1 -1
  52. package/dist/daemon-process.js +7 -2
  53. package/dist/daemon-process.js.map +1 -1
  54. package/dist/directive/close-loop-stage.d.ts +50 -0
  55. package/dist/directive/close-loop-stage.d.ts.map +1 -0
  56. package/dist/directive/close-loop-stage.js +196 -0
  57. package/dist/directive/close-loop-stage.js.map +1 -0
  58. package/dist/directive/directive-assembly.d.ts +33 -0
  59. package/dist/directive/directive-assembly.d.ts.map +1 -0
  60. package/dist/directive/directive-assembly.js +77 -0
  61. package/dist/directive/directive-assembly.js.map +1 -0
  62. package/dist/directive/directive-dispatch.d.ts +103 -0
  63. package/dist/directive/directive-dispatch.d.ts.map +1 -0
  64. package/dist/directive/directive-dispatch.js +279 -0
  65. package/dist/directive/directive-dispatch.js.map +1 -0
  66. package/dist/directive/phase-sync.d.ts +89 -0
  67. package/dist/directive/phase-sync.d.ts.map +1 -0
  68. package/dist/directive/phase-sync.js +173 -0
  69. package/dist/directive/phase-sync.js.map +1 -0
  70. package/dist/directive-executor.d.ts +21 -223
  71. package/dist/directive-executor.d.ts.map +1 -1
  72. package/dist/directive-executor.js +28 -687
  73. package/dist/directive-executor.js.map +1 -1
  74. package/dist/focus-engine.d.ts.map +1 -1
  75. package/dist/focus-engine.js +8 -0
  76. package/dist/focus-engine.js.map +1 -1
  77. package/dist/focus-executor.d.ts +29 -8
  78. package/dist/focus-executor.d.ts.map +1 -1
  79. package/dist/focus-executor.js +29 -10
  80. package/dist/focus-executor.js.map +1 -1
  81. package/dist/focus-stage-lifecycle.d.ts +7 -5
  82. package/dist/focus-stage-lifecycle.d.ts.map +1 -1
  83. package/dist/focus-stage-lifecycle.js +11 -6
  84. package/dist/focus-stage-lifecycle.js.map +1 -1
  85. package/dist/index.js +8 -0
  86. package/dist/index.js.map +1 -1
  87. package/dist/pipeline-config.d.ts +13 -0
  88. package/dist/pipeline-config.d.ts.map +1 -1
  89. package/dist/pipeline-config.js +15 -0
  90. package/dist/pipeline-config.js.map +1 -1
  91. package/dist/resolvers/agent-escalations.d.ts +8 -1
  92. package/dist/resolvers/agent-escalations.d.ts.map +1 -1
  93. package/dist/resolvers/agent-escalations.js +25 -22
  94. package/dist/resolvers/agent-escalations.js.map +1 -1
  95. package/dist/resolvers/agent-session-summaries.d.ts +8 -1
  96. package/dist/resolvers/agent-session-summaries.d.ts.map +1 -1
  97. package/dist/resolvers/agent-session-summaries.js +21 -18
  98. package/dist/resolvers/agent-session-summaries.js.map +1 -1
  99. package/dist/resolvers/delivery-acceptance-criteria.d.ts +7 -1
  100. package/dist/resolvers/delivery-acceptance-criteria.d.ts.map +1 -1
  101. package/dist/resolvers/delivery-acceptance-criteria.js +14 -11
  102. package/dist/resolvers/delivery-acceptance-criteria.js.map +1 -1
  103. package/dist/resolvers/delivery-description.d.ts +7 -1
  104. package/dist/resolvers/delivery-description.d.ts.map +1 -1
  105. package/dist/resolvers/delivery-description.js +14 -11
  106. package/dist/resolvers/delivery-description.js.map +1 -1
  107. package/dist/resolvers/delivery-issues.d.ts +8 -1
  108. package/dist/resolvers/delivery-issues.d.ts.map +1 -1
  109. package/dist/resolvers/delivery-issues.js +51 -48
  110. package/dist/resolvers/delivery-issues.js.map +1 -1
  111. package/dist/resolvers/delivery-listing.d.ts +16 -1
  112. package/dist/resolvers/delivery-listing.d.ts.map +1 -1
  113. package/dist/resolvers/delivery-listing.js +36 -33
  114. package/dist/resolvers/delivery-listing.js.map +1 -1
  115. package/dist/resolvers/delivery-tech-context.d.ts +7 -1
  116. package/dist/resolvers/delivery-tech-context.d.ts.map +1 -1
  117. package/dist/resolvers/delivery-tech-context.js +14 -11
  118. package/dist/resolvers/delivery-tech-context.js.map +1 -1
  119. package/dist/resolvers/deployment-profile.d.ts +8 -1
  120. package/dist/resolvers/deployment-profile.d.ts.map +1 -1
  121. package/dist/resolvers/deployment-profile.js +20 -17
  122. package/dist/resolvers/deployment-profile.js.map +1 -1
  123. package/dist/resolvers/focus-anchoring-injections.d.ts +26 -1
  124. package/dist/resolvers/focus-anchoring-injections.d.ts.map +1 -1
  125. package/dist/resolvers/focus-anchoring-injections.js +91 -88
  126. package/dist/resolvers/focus-anchoring-injections.js.map +1 -1
  127. package/dist/resolvers/focus-context.d.ts +9 -1
  128. package/dist/resolvers/focus-context.d.ts.map +1 -1
  129. package/dist/resolvers/focus-context.js +38 -35
  130. package/dist/resolvers/focus-context.js.map +1 -1
  131. package/dist/resolvers/focus-injections.d.ts +13 -1
  132. package/dist/resolvers/focus-injections.d.ts.map +1 -1
  133. package/dist/resolvers/focus-injections.js +60 -57
  134. package/dist/resolvers/focus-injections.js.map +1 -1
  135. package/dist/resolvers/focus-last-review-report.d.ts +2 -1
  136. package/dist/resolvers/focus-last-review-report.d.ts.map +1 -1
  137. package/dist/resolvers/focus-last-review-report.js +33 -30
  138. package/dist/resolvers/focus-last-review-report.js.map +1 -1
  139. package/dist/resolvers/focus-reality-tree.d.ts +15 -1
  140. package/dist/resolvers/focus-reality-tree.d.ts.map +1 -1
  141. package/dist/resolvers/focus-reality-tree.js +35 -32
  142. package/dist/resolvers/focus-reality-tree.js.map +1 -1
  143. package/dist/resolvers/git-diff-against-base.d.ts +8 -1
  144. package/dist/resolvers/git-diff-against-base.d.ts.map +1 -1
  145. package/dist/resolvers/git-diff-against-base.js +33 -30
  146. package/dist/resolvers/git-diff-against-base.js.map +1 -1
  147. package/dist/resolvers/guards-evaluation-results.d.ts +7 -1
  148. package/dist/resolvers/guards-evaluation-results.d.ts.map +1 -1
  149. package/dist/resolvers/guards-evaluation-results.js +25 -22
  150. package/dist/resolvers/guards-evaluation-results.js.map +1 -1
  151. package/dist/resolvers/index.d.ts +22 -40
  152. package/dist/resolvers/index.d.ts.map +1 -1
  153. package/dist/resolvers/index.js +112 -41
  154. package/dist/resolvers/index.js.map +1 -1
  155. package/dist/resolvers/loop-context.d.ts +18 -1
  156. package/dist/resolvers/loop-context.d.ts.map +1 -1
  157. package/dist/resolvers/loop-context.js +21 -18
  158. package/dist/resolvers/loop-context.js.map +1 -1
  159. package/dist/resolvers/loop-delivery-index.d.ts +17 -1
  160. package/dist/resolvers/loop-delivery-index.d.ts.map +1 -1
  161. package/dist/resolvers/loop-delivery-index.js +51 -48
  162. package/dist/resolvers/loop-delivery-index.js.map +1 -1
  163. package/dist/resolvers/loop-documents.d.ts +9 -1
  164. package/dist/resolvers/loop-documents.d.ts.map +1 -1
  165. package/dist/resolvers/loop-documents.js +22 -19
  166. package/dist/resolvers/loop-documents.js.map +1 -1
  167. package/dist/resolvers/loop-expected-effects.d.ts +11 -1
  168. package/dist/resolvers/loop-expected-effects.d.ts.map +1 -1
  169. package/dist/resolvers/loop-expected-effects.js +53 -50
  170. package/dist/resolvers/loop-expected-effects.js.map +1 -1
  171. package/dist/resolvers/loop-frt-statement.d.ts +11 -1
  172. package/dist/resolvers/loop-frt-statement.d.ts.map +1 -1
  173. package/dist/resolvers/loop-frt-statement.js +28 -25
  174. package/dist/resolvers/loop-frt-statement.js.map +1 -1
  175. package/dist/resolvers/loop-injection.d.ts +10 -1
  176. package/dist/resolvers/loop-injection.d.ts.map +1 -1
  177. package/dist/resolvers/loop-injection.js +38 -35
  178. package/dist/resolvers/loop-injection.js.map +1 -1
  179. package/dist/resolvers/loop-persona.d.ts +20 -1
  180. package/dist/resolvers/loop-persona.d.ts.map +1 -1
  181. package/dist/resolvers/loop-persona.js +20 -17
  182. package/dist/resolvers/loop-persona.js.map +1 -1
  183. package/dist/resolvers/loop-questions.d.ts +8 -1
  184. package/dist/resolvers/loop-questions.d.ts.map +1 -1
  185. package/dist/resolvers/loop-questions.js +39 -36
  186. package/dist/resolvers/loop-questions.js.map +1 -1
  187. package/dist/resolvers/loop-upstream-udes.d.ts +11 -1
  188. package/dist/resolvers/loop-upstream-udes.d.ts.map +1 -1
  189. package/dist/resolvers/loop-upstream-udes.js +74 -71
  190. package/dist/resolvers/loop-upstream-udes.js.map +1 -1
  191. package/dist/resolvers/prd-context.d.ts +2 -0
  192. package/dist/resolvers/prd-context.d.ts.map +1 -1
  193. package/dist/resolvers/prd-context.js +16 -13
  194. package/dist/resolvers/prd-context.js.map +1 -1
  195. package/dist/resolvers/prd-persona.d.ts +2 -0
  196. package/dist/resolvers/prd-persona.d.ts.map +1 -1
  197. package/dist/resolvers/prd-persona.js +6 -3
  198. package/dist/resolvers/prd-persona.js.map +1 -1
  199. package/dist/resolvers/reality-metrics.d.ts +8 -1
  200. package/dist/resolvers/reality-metrics.d.ts.map +1 -1
  201. package/dist/resolvers/reality-metrics.js +73 -70
  202. package/dist/resolvers/reality-metrics.js.map +1 -1
  203. package/dist/resolvers/reality-projections.d.ts +7 -1
  204. package/dist/resolvers/reality-projections.d.ts.map +1 -1
  205. package/dist/resolvers/reality-projections.js +35 -32
  206. package/dist/resolvers/reality-projections.js.map +1 -1
  207. package/dist/resolvers/reality-tree-snapshot.d.ts +10 -1
  208. package/dist/resolvers/reality-tree-snapshot.d.ts.map +1 -1
  209. package/dist/resolvers/reality-tree-snapshot.js +34 -31
  210. package/dist/resolvers/reality-tree-snapshot.js.map +1 -1
  211. package/dist/resolvers/retired-injections.d.ts +10 -1
  212. package/dist/resolvers/retired-injections.d.ts.map +1 -1
  213. package/dist/resolvers/retired-injections.js +68 -65
  214. package/dist/resolvers/retired-injections.js.map +1 -1
  215. package/dist/resolvers/review-outcomes.d.ts +11 -1
  216. package/dist/resolvers/review-outcomes.d.ts.map +1 -1
  217. package/dist/resolvers/review-outcomes.js +27 -24
  218. package/dist/resolvers/review-outcomes.js.map +1 -1
  219. package/dist/resolvers/security-advisory.d.ts +2 -1
  220. package/dist/resolvers/security-advisory.d.ts.map +1 -1
  221. package/dist/resolvers/security-advisory.js +47 -44
  222. package/dist/resolvers/security-advisory.js.map +1 -1
  223. package/dist/resolvers/wiki-search.d.ts +8 -1
  224. package/dist/resolvers/wiki-search.d.ts.map +1 -1
  225. package/dist/resolvers/wiki-search.js +17 -14
  226. package/dist/resolvers/wiki-search.js.map +1 -1
  227. package/dist/resolvers/wiki-topic.d.ts +9 -1
  228. package/dist/resolvers/wiki-topic.d.ts.map +1 -1
  229. package/dist/resolvers/wiki-topic.js +21 -18
  230. package/dist/resolvers/wiki-topic.js.map +1 -1
  231. package/dist/resolvers/workflow-stages.d.ts +7 -1
  232. package/dist/resolvers/workflow-stages.d.ts.map +1 -1
  233. package/dist/resolvers/workflow-stages.js +20 -17
  234. package/dist/resolvers/workflow-stages.js.map +1 -1
  235. package/dist/self-update.d.ts +6 -0
  236. package/dist/self-update.d.ts.map +1 -1
  237. package/dist/self-update.js +6 -1
  238. package/dist/self-update.js.map +1 -1
  239. package/dist/spawner/index.d.ts +2 -1
  240. package/dist/spawner/index.d.ts.map +1 -1
  241. package/dist/spawner/index.js +2 -1
  242. package/dist/spawner/index.js.map +1 -1
  243. package/dist/spawner/spawn-team.d.ts +14 -52
  244. package/dist/spawner/spawn-team.d.ts.map +1 -1
  245. package/dist/spawner/spawn-team.js +14 -469
  246. package/dist/spawner/spawn-team.js.map +1 -1
  247. package/dist/staleness.d.ts +124 -0
  248. package/dist/staleness.d.ts.map +1 -0
  249. package/dist/staleness.js +215 -0
  250. package/dist/staleness.js.map +1 -0
  251. package/dist/state-cascade.d.ts +3 -2
  252. package/dist/state-cascade.d.ts.map +1 -1
  253. package/dist/state-cascade.js +7 -3
  254. package/dist/state-cascade.js.map +1 -1
  255. package/dist/types/focus.d.ts +5 -4
  256. package/dist/types/focus.d.ts.map +1 -1
  257. package/dist/types/session.d.ts +2 -3
  258. package/dist/types/session.d.ts.map +1 -1
  259. package/dist/unified-shell.d.ts.map +1 -1
  260. package/dist/unified-shell.js +21 -12
  261. package/dist/unified-shell.js.map +1 -1
  262. package/package.json +2 -2
  263. package/dist/session-lifecycle.d.ts +0 -78
  264. package/dist/session-lifecycle.d.ts.map +0 -1
  265. package/dist/session-lifecycle.js +0 -382
  266. package/dist/session-lifecycle.js.map +0 -1
@@ -1,401 +1,44 @@
1
1
  /**
2
- * Focus team completion handling.
2
+ * Team completion -- explicit phase sequencer.
3
3
  *
4
- * Manages the post-execution lifecycle: advancing delivery statuses
5
- * based on issue completion, merging the focus branch, cleaning up
6
- * worktrees, and updating session records.
4
+ * "What happens when a team exits" reads from handleTeamCompletion below:
5
+ * an ordered sequence of named phases. The phase implementations live in
6
+ * focused modules, re-exported here so existing importers are unaffected:
7
+ *
8
+ * - exit-classification.ts classify exit (success / controlled stop / crash)
9
+ * - merge-phase.ts merge gate + focus->integration merge +
10
+ * merge-failure escalation + CI integration->main
11
+ * - status-advance-phase.ts delivery status advancement from issue completion
12
+ * - review-exit-phase.ts verify-delivery routing on review exit
13
+ *
14
+ * Phase order on team exit:
15
+ * classify -> report git state -> read-only guard -> WIP preserve ->
16
+ * status advance -> merge -> CI merge-to-main -> session finalize ->
17
+ * review exit (escalate/handoff) -> terminate (cleanup + re-poll)
7
18
  */
8
- import { filterWorkIssues, filterContextGroups, ACTIVE_WORK_STATUSES, TERMINAL_ISSUE_STATUSES, planDeliveryCloseSweep } from '../constants.js';
9
- import { updateSession, reportGitState, getDeliveryIssues, getDeliverySessionCount, updateIssueStatus, fetchEffectiveWorkflow, updateDeliveryStatus, markDeliveryAutoApproved, getActiveFocuses, } from '../supabase.js';
10
- import { hasPendingSpawnDirective } from '../directive-executor.js';
11
- import { advanceDeliveryStage, ensureWorktreeCommittedBeforeAdvance } from '../delivery-lifecycle.js';
12
- import { withRetry, ESCALATION_REASONS, DELIVERY_STATUS } from '@telora/daemon-core';
13
- import { mergeFocusBranch, escalateMergeConflict } from '../focus-merge.js';
14
- import { mergeIntegrationToMain } from '../git-merge.js';
15
- import { resolveCiDecision } from '../pipeline-config.js';
16
- import { fileCdPushEscalation, shouldFileCdPushEscalation } from '../ci-escalation.js';
19
+ import { updateSession, reportGitState } from '../supabase.js';
20
+ import { withRetry, DELIVERY_STATUS } from '@telora/daemon-core';
17
21
  import { recordSessionCompleted } from '../agent-state.js';
18
22
  import { triggerCheck } from '../listener.js';
19
- import { emitLoopTrigger } from '../loop-event-bus.js';
20
23
  import { getActiveTeams } from '../focus-team-state.js';
21
24
  import { commitWipChanges, worktreeHasUncommittedChanges, branchHasUnmergedCommits, runGitSync } from '../git.js';
22
25
  import { computeGitDiffStats } from '../git-utils.js';
23
- import { getFocusDeliveries, getFocusIssues, clearReviewRequestedAt, getFocusReviewReportForSession, } from '../queries/focuses.js';
24
- import { createEscalation } from '../queries/issues.js';
25
- import { reviewFiledIssueExists } from '../review-defect-detector.js';
26
- // ── Stage classification (re-exported for consumers) ─────────────────
27
- import { isStatusAgentActionable } from '../stage-classifier.js';
26
+ import { getFocusDeliveries } from '../queries/focuses.js';
27
+ import { classifyTeamExit, deriveExitCategory } from './exit-classification.js';
28
+ import { selectWorkedDeliveryIds, attemptFocusMerge, escalateFailedMergeDeliveries, runCiMergeToMain, } from './merge-phase.js';
29
+ import { advanceDeliveryStatuses } from './status-advance-phase.js';
30
+ import { runReviewExitHandler } from './review-exit-phase.js';
31
+ // ── Re-exported public surface (callers unchanged) ───────────────────
28
32
  export { isStatusTerminal, isStatusAgentActionable } from '../stage-classifier.js';
29
- // ── Merge gate ───────────────────────────────────────────────────────
30
- /**
31
- * Decide whether to attempt mergeFocusBranch on team exit.
32
- *
33
- * The gate asks "does the branch have clean committed work?" rather than
34
- * "did the team session exit cleanly?" -- so a team that exits via review-
35
- * cycle SIGTERM, role-clear, daemon crash, or manual termination still gets
36
- * its committed work propagated to integration.
37
- *
38
- * Skip merge when:
39
- * - The team is still in its planning phase (cancellation mid-planning --
40
- * nothing scoped, nothing to merge), OR
41
- * - The team is a read-only audit session (read-only violation; commits, if
42
- * any, are surfaced separately by the existing readOnly guard), OR
43
- * - The branch has no committed work ahead of integration (clean exit with
44
- * no commits, e.g. audit team or planning-only team).
45
- *
46
- * Exported for unit testing the gate logic in isolation.
47
- */
48
- export function shouldAttemptMerge(teamState, branchHasCommits) {
49
- if (teamState.planningPhase)
50
- return false;
51
- if (teamState.readOnly)
52
- return false;
53
- return branchHasCommits;
54
- }
55
- /**
56
- * Statuses for deliveries whose git state should be reported on team
57
- * completion. Includes 'done' so a focus whose deliveries all reached done
58
- * before the branch was merged (review SIGTERM, role-clear, deferred merge,
59
- * etc.) still attempts the merge instead of being short-circuited as
60
- * "no worked deliveries". Excludes 'queued'/'planning'/'paused'/'cancelled'
61
- * — those represent work that never started or was abandoned.
62
- *
63
- * Exported for unit testing.
64
- */
65
- export const REPORTABLE_DELIVERY_STATUSES = new Set([
66
- 'coding',
67
- 'verify',
68
- 'done',
69
- ]);
70
- /**
71
- * Filter delivery list to those whose git state should be reported on team
72
- * completion. Excludes already-merged deliveries so we don't re-report them.
73
- *
74
- * Exported for unit testing the filter logic without invoking handleTeamCompletion.
75
- */
76
- export function selectWorkedDeliveryIds(deliveries, alreadyMerged) {
77
- return deliveries
78
- .filter(d => REPORTABLE_DELIVERY_STATUSES.has(d.executionStatus ?? '') && !alreadyMerged.has(d.id))
79
- .map(d => d.id);
80
- }
81
- /**
82
- * Decide whether to merge the focus branch and, if so, run the merge.
83
- *
84
- * This is the integration of three separate concerns that the merge gate
85
- * conflated before:
86
- * 1. Branch state: does the focus branch actually have committed work?
87
- * 2. Team gate: planningPhase + readOnly safety nets in shouldAttemptMerge.
88
- * 3. Per-delivery telemetry: workedDeliveryIds is passed through for
89
- * git-state reporting inside mergeFocusBranch.
90
- *
91
- * Returns attempted=false when the gate skips the merge (clean exit with
92
- * nothing to merge, planning-phase, or read-only). Returns attempted=true
93
- * with the merge result otherwise.
94
- *
95
- * Exported so tests can verify the threading from done-only deliveries +
96
- * branch-with-commits to a real mergeFocusBranch invocation, without having
97
- * to mock all of handleTeamCompletion's other side effects.
98
- */
99
- export async function attemptFocusMerge(config, teamState, sessionId, workedDeliveryIds, deps = {
100
- branchHasUnmergedCommits,
101
- mergeFocusBranch,
102
- }) {
103
- const branchHasCommits = deps.branchHasUnmergedCommits(teamState.branchName, config.integrationBranch, config.repoPath);
104
- if (!shouldAttemptMerge(teamState, branchHasCommits)) {
105
- return { attempted: false, mergeSucceeded: false, exitReason: null };
106
- }
107
- const mergeResult = await deps.mergeFocusBranch(config, teamState, sessionId, teamState.branchName, workedDeliveryIds);
108
- return {
109
- attempted: true,
110
- mergeSucceeded: mergeResult.mergeSucceeded,
111
- exitReason: mergeResult.exitReason ?? null,
112
- };
113
- }
114
- const defaultReviewExitDeps = {
115
- getActiveFocuses,
116
- getFocusDeliveries,
117
- getFocusIssues,
118
- getDeliveryIssues,
119
- fetchEffectiveWorkflow,
120
- updateDeliveryStatus,
121
- markDeliveryAutoApproved,
122
- clearReviewRequestedAt,
123
- hasPendingSpawnDirective,
124
- emitLoopTrigger,
125
- getFocusReviewReportForSession,
126
- createEscalation,
127
- };
128
- /**
129
- * Handle a team exit when the focus has review_requested_at set.
130
- *
131
- * Routing is asymmetric in the downward direction (toward intent) -- open
132
- * work on a verify delivery means the work isn't done, so it routes to
133
- * verify_failed unconditionally. Closing a verify delivery to `done` is
134
- * the other direction; it now happens via two paths: an explicit
135
- * focus_reviews approval, OR a clean review session exit (auto-approve,
136
- * reversible via the later unapprove affordance).
137
- *
138
- * Routing rules:
139
- * 1. No review_requested_at -> nothing to do.
140
- * 2. A spawn directive is pending -> defer to the incoming team.
141
- * 3. For each verify delivery:
142
- * - open work issues -> verify_failed (always, no evidence gate)
143
- * - no open work + reportExists -> done (explicit approval)
144
- * - no open work + no report + review session succeeded ->
145
- * done (auto-approve; tagged with auto_approved_at /
146
- * auto_approved_by_session for surfacing + unapprove)
147
- * - no open work + non-review session (or failed review) ->
148
- * leave in verify (next auto-review poll re-triggers)
149
- * 4. If anything routed -> clear review_requested_at + emit
150
- * review_completed event so phase re-derives on the next poll.
151
- * 5. If routed via issue evidence (no report present), file a soft
152
- * review_missing_report notice -- the loop continues regardless.
153
- * 6. If nothing routed AND no evidence at all -> warn-and-leave. The
154
- * strict gate no longer escalates: an idle focus with no work and no
155
- * report is ambiguous, not failed; humans clear it via the MCP
156
- * clear-review-request path.
157
- *
158
- * The independent "evidence" signals (focus_reviews row + review-filed
159
- * issues) still feed into the issue-evidence notice and the warn-and-leave
160
- * messaging, but they no longer gate routing -- open work on a delivery
161
- * is its own evidence that the work isn't done.
162
- *
163
- * Exported with injectable deps for unit testing.
164
- */
165
- export async function runReviewExitHandler(params, deps = defaultReviewExitDeps) {
166
- const { focusId, focusName, organizationId, productId, sessionId, sessionType, succeeded } = params;
167
- const focuses = await deps.getActiveFocuses(organizationId, productId);
168
- const current = focuses.find(s => s.focus_id === focusId);
169
- if (!current?.review_requested_at) {
170
- // Nothing review-related to do.
171
- return;
172
- }
173
- const reviewRequestedAt = current.review_requested_at;
174
- // NOTE: a pending spawn directive no longer short-circuits the handler.
175
- // Delivery finalization (auto-approve verify->done, done-via-report, and
176
- // verify_failed on open work) plus clearing review_requested_at and emitting
177
- // review_completed must run unconditionally on a session exit -- otherwise a
178
- // transiently-pending spawn strands the verify delivery and feeds the
179
- // per-poll re-fire spin. The pending-spawn signal is consulted ONLY in the
180
- // non-routed branch below, where leaving the flag set for an incoming team is
181
- // the legitimate behavior.
182
- // ── Gather evidence ──────────────────────────────────────────────
183
- let reportExists = false;
184
- try {
185
- const report = await deps.getFocusReviewReportForSession(focusId, sessionId);
186
- reportExists = report.exists;
187
- }
188
- catch (err) {
189
- console.warn(`[focus-executor] getFocusReviewReportForSession failed for "${focusName}" session ${sessionId}: ${err.message}`);
190
- // Treat read failure the same as missing.
191
- }
192
- let hasIssueEvidence = false;
193
- try {
194
- const focusIssues = await deps.getFocusIssues(focusId);
195
- hasIssueEvidence = reviewFiledIssueExists(focusIssues, reviewRequestedAt);
196
- }
197
- catch (err) {
198
- console.warn(`[focus-executor] getFocusIssues failed for "${focusName}": ${err.message}`);
199
- // Treat read failure as no evidence -- only affects the warn-and-leave
200
- // message and the issue-evidence notice; routing runs regardless.
201
- }
202
- // ── Route verify deliveries ──────────────────────────────────────
203
- // Runs unconditionally: open work routes to verify_failed even when no
204
- // review evidence is present. This is the self-healing path -- a coding
205
- // team that left pre-existing open work behind no longer needs the review
206
- // agent to file fresh defects before the loop can correct itself.
207
- const reviewDeliveries = await deps.getFocusDeliveries(focusId);
208
- let routedAny = false;
209
- for (const d of reviewDeliveries) {
210
- if (d.executionStatus !== DELIVERY_STATUS.VERIFY)
211
- continue;
212
- try {
213
- const [issues, dWorkflow] = await Promise.all([
214
- deps.getDeliveryIssues(d.id),
215
- deps.fetchEffectiveWorkflow(d.id),
216
- ]);
217
- const workIssues = filterWorkIssues(issues);
218
- const openWork = workIssues.filter(i => ACTIVE_WORK_STATUSES.has(i.status));
219
- if (openWork.length > 0) {
220
- const awaitingVerifyStage = dWorkflow.stages.find(s => s.name === 'verify_failed');
221
- await deps.updateDeliveryStatus(d.id, 'verify_failed', awaitingVerifyStage?.id ?? null, undefined, undefined, { organizationId, fromStatus: 'verify' });
222
- console.log(`[focus-executor] Delivery "${d.name}" moved from verify to verify_failed: ${openWork.length} open issue(s)`);
223
- routedAny = true;
224
- }
225
- else if (reportExists) {
226
- const doneStage = dWorkflow.stages.find(s => s.name === 'done');
227
- await deps.updateDeliveryStatus(d.id, 'done', doneStage?.id ?? null, undefined, undefined, { organizationId, fromStatus: 'verify' });
228
- console.log(`[focus-executor] Delivery "${d.name}" moved from verify to done (review passed)`);
229
- routedAny = true;
230
- }
231
- else if (sessionType === 'review' && succeeded) {
232
- // Clean review session: review agent exited without filing defects
233
- // and without writing a focus_reviews row. Per the auto-approve-
234
- // with-visibility direction (memory/feedback_auto_approve_with_visibility),
235
- // promote verify -> done and tag the delivery with auto-approval
236
- // metadata so the (later) dashboard/MCP unapprove path can surface
237
- // it for human reversal. Reversible by construction.
238
- const doneStage = dWorkflow.stages.find(s => s.name === 'done');
239
- await deps.markDeliveryAutoApproved(d.id, sessionId, doneStage?.id ?? null);
240
- console.log(`[focus-executor] Delivery "${d.name}" auto-approved verify -> done ` +
241
- `(clean review session ${sessionId.slice(0, 8)}, no defects filed)`);
242
- routedAny = true;
243
- }
244
- else {
245
- // Non-review session exit, or review session that didn't succeed --
246
- // no signal to auto-approve. Leave in verify; the next state-cascade
247
- // poll will re-trigger auto-review via checkAutoReview.
248
- const reason = sessionType !== 'review'
249
- ? `${sessionType} session exited (not eligible for auto-approve)`
250
- : 'review session did not succeed';
251
- console.log(`[focus-executor] Delivery "${d.name}" left in verify: ${reason} ` +
252
- `(verify-cycling; next auto-review poll will re-trigger).`);
253
- }
254
- }
255
- catch (err) {
256
- console.warn(`[focus-executor] Failed to route delivery "${d.name}" after review: ${err.message}`);
257
- }
258
- }
259
- if (routedAny) {
260
- await deps.clearReviewRequestedAt(focusId);
261
- console.log(`[focus-executor] Review complete for "${focusName}" -- review_requested_at cleared ` +
262
- `(reportExists=${reportExists}, issueEvidence=${hasIssueEvidence})`);
263
- const routeVia = reportExists
264
- ? 'report'
265
- : hasIssueEvidence
266
- ? 'issue-evidence'
267
- : sessionType === 'review' && succeeded
268
- ? 'auto-approve'
269
- : 'open-work';
270
- deps.emitLoopTrigger({
271
- type: 'review_completed',
272
- focusId,
273
- detail: `review complete -- "${focusName}" routed via ${routeVia}`,
274
- });
275
- }
276
- else if (deps.hasPendingSpawnDirective(focusId)) {
277
- // Nothing routed AND a spawn directive is pending: the team was torn down
278
- // to make room for an incoming team (e.g., the directive executor spawning
279
- // a review team). Leave review_requested_at set so the incoming team's exit
280
- // performs the routing. This is the ONLY case the pending-spawn signal
281
- // gates -- it never blocks the finalization above.
282
- console.log(`[focus-executor] "${focusName}" review exit routed nothing and a spawn directive ` +
283
- `is pending -- leaving review_requested_at set for the incoming team.`);
284
- }
285
- else if (!reportExists && !hasIssueEvidence) {
286
- // Strict gate: no routing happened AND no evidence. Warn-and-leave --
287
- // the focus may simply be idle (no verify deliveries to route, no
288
- // review report) or awaiting human handling. We deliberately no longer
289
- // escalate here: an idle focus with no report is not a missing report,
290
- // it's an idle focus. Recovery is via MCP clear-review-request.
291
- const reason = sessionType !== 'review'
292
- ? `${sessionType} team exited (not a review team)`
293
- : !succeeded
294
- ? 'review team did not succeed'
295
- : 'no verify deliveries to route (clean review sessions auto-approve verify deliveries; this focus had none)';
296
- console.warn(`[focus-executor] "${focusName}" has review_requested_at set but ${reason}; ` +
297
- `phase remains 'reviewing' until a review team runs, work is queued, ` +
298
- `or review_requested_at is cleared via MCP.`);
299
- }
300
- // Surface that the agent skipped review_complete even though the loop healed.
301
- // Only fire when we actually routed something via issue evidence; otherwise
302
- // the notice misleads (it claims the loop continued when nothing moved).
303
- if (routedAny && !reportExists && hasIssueEvidence) {
304
- try {
305
- await deps.createEscalation({
306
- organizationId,
307
- sessionId,
308
- issueId: null,
309
- reasonType: ESCALATION_REASONS.BLOCKED_BY_EXTERNAL,
310
- description: 'Review session filed issues but did not call focus_review_complete. ' +
311
- 'Deliveries routed via issue evidence; review_requested_at cleared. ' +
312
- 'Tighten the review directive so the agent always stamps an outcome.',
313
- escalationKind: 'review_missing_report',
314
- metadata: { focus_id: focusId, session_id: sessionId, routed_via: 'issue_evidence' },
315
- });
316
- }
317
- catch (err) {
318
- console.warn(`[focus-executor] createEscalation (issue-evidence notice) failed for "${focusName}": ${err.message}`);
319
- }
320
- }
321
- }
322
- /**
323
- * Classify how a team lead process exited.
324
- *
325
- * Pure and side-effect free so the "lifecycle SIGTERM is not a review failure"
326
- * invariant is unit-testable in isolation. The crux: when the daemon proactively
327
- * terminates a team because its work is complete, it sets phase='shutting_down'
328
- * and shutdownReason='work_complete' BEFORE sending SIGTERM. We detect that
329
- * teardown and classify it as succeeded -- otherwise a clean review team
330
- * (which commits nothing of its own, then gets SIGTERMed once all deliveries
331
- * are terminal) would look like a failed exit and its verify delivery would be
332
- * stranded in `verify` instead of auto-approved to `done`.
333
- */
334
- export function classifyTeamExit(params) {
335
- const exitCode = params.code ?? 1;
336
- const wasSigterm = params.signal === 'SIGTERM' || exitCode === 143;
337
- const proactivelyTerminated = params.teamPhase === 'shutting_down' &&
338
- wasSigterm &&
339
- params.shutdownReason === 'work_complete';
340
- const succeeded = exitCode === 0 || proactivelyTerminated;
341
- // A controlled stop is any SIGTERM the daemon sent while the team was already
342
- // shutting down -- regardless of reason. It is the signal that distinguishes
343
- // a deliberate daemon teardown from a crash, without touching `succeeded`.
344
- const controlledStop = params.teamPhase === 'shutting_down' && wasSigterm;
345
- const disposition = succeeded ? 'completed' : controlledStop ? 'stopped' : 'failed';
346
- return { exitCode, wasSigterm, proactivelyTerminated, succeeded, controlledStop, disposition };
347
- }
348
- /**
349
- * Derive the `exit_category` to record alongside the session status, given the
350
- * classifier disposition and the team's shutdown reason.
351
- *
352
- * - 'completed' -> 'work_complete' when the daemon stopped the team because its
353
- * work was done; otherwise null (a plain clean self-exit has no category).
354
- * - 'stopped' -> the specific controlled-stop reason; any unknown/null reason
355
- * falls back to 'handoff' (a benign daemon-initiated respawn).
356
- * - 'failed' -> 'error' (genuine crash / unexpected exit).
357
- */
358
- export function deriveExitCategory(disposition, shutdownReason) {
359
- switch (disposition) {
360
- case 'completed':
361
- return shutdownReason === 'work_complete' ? 'work_complete' : null;
362
- case 'stopped':
363
- switch (shutdownReason) {
364
- case 'deactivated': return 'deactivated';
365
- case 'user_stopped': return 'user_stopped';
366
- case 'handoff': return 'handoff';
367
- case 'teardown': return 'teardown';
368
- default: return 'handoff';
369
- }
370
- case 'failed':
371
- return 'error';
372
- }
373
- }
374
- /**
375
- * Handle the team lead process exiting.
376
- *
377
- * Responsibilities:
378
- * - Report git state for all known deliveries
379
- * - Advance delivery statuses based on issue completion
380
- * - Merge focus branch to integration (if successful)
381
- * - Clean up worktree
382
- * - Update session record
383
- * - Record heartbeat counters
384
- * - Trigger re-poll for next work
385
- */
386
- export async function handleTeamCompletion(params) {
387
- const { config, teamState, sessionId, code, signal } = params;
388
- const { focusId, focusName, organizationId, branchName } = teamState;
389
- const worktreePath = teamState.worktreePath;
390
- // Classify the exit. A lifecycle teardown (shutdownReason='work_complete')
391
- // SIGTERM is treated as a success, NOT a review failure -- see classifyTeamExit.
392
- const { exitCode, proactivelyTerminated, succeeded, controlledStop, disposition } = classifyTeamExit({
393
- code,
394
- signal,
395
- teamPhase: teamState.phase,
396
- shutdownReason: teamState.shutdownReason,
397
- });
398
- let exitReason = exitCode === 0
33
+ export { classifyTeamExit, deriveExitCategory, } from './exit-classification.js';
34
+ export { shouldAttemptMerge, REPORTABLE_DELIVERY_STATUSES, selectWorkedDeliveryIds, attemptFocusMerge, escalateFailedMergeDeliveries, runCiMergeToMain, } from './merge-phase.js';
35
+ export { advanceDeliveryStatuses, } from './status-advance-phase.js';
36
+ export { runReviewExitHandler, } from './review-exit-phase.js';
37
+ // ── Phase helpers ─────────────────────────────────────────────────────
38
+ /** Build the initial exit reason from the exit classification. */
39
+ function buildInitialExitReason(teamState, classification, signal) {
40
+ const { exitCode, proactivelyTerminated, succeeded, controlledStop } = classification;
41
+ return exitCode === 0
399
42
  ? 'Focus team completed.'
400
43
  : proactivelyTerminated
401
44
  ? teamState.mergedDeliveryIds.size > 0
@@ -408,16 +51,20 @@ export async function handleTeamCompletion(params) {
408
51
  : signal
409
52
  ? `Team lead terminated by signal: ${signal}`
410
53
  : `Team lead exited with code: ${exitCode}`;
411
- // Fetch fresh delivery list to include mid-flight additions
412
- let allDeliveries;
54
+ }
55
+ /**
56
+ * Fetch the fresh delivery list (to include mid-flight additions), falling
57
+ * back to a synthesized list from knownDeliveryIds on read failure.
58
+ */
59
+ async function fetchDeliveriesForCompletion(teamState) {
413
60
  try {
414
- allDeliveries = await getFocusDeliveries(focusId);
61
+ return await getFocusDeliveries(teamState.focusId);
415
62
  }
416
63
  catch (err) {
417
64
  console.warn(`[focus-executor] Failed to fetch deliveries for gitState reporting, falling back to knownDeliveryIds:`, err.message);
418
65
  // Empty knownDeliveryIds (planning teams that exited without scoping) yields
419
66
  // an empty fallback array -- subsequent loops are intentional no-ops.
420
- allDeliveries = [...teamState.knownDeliveryIds].map(id => ({
67
+ return [...teamState.knownDeliveryIds].map(id => ({
421
68
  id, name: '', description: null, priorityRank: 0,
422
69
  executionStatus: DELIVERY_STATUS.CODING, acceptanceCriteria: null,
423
70
  techContext: null, currentWorkflowStageId: null,
@@ -427,6 +74,85 @@ export async function handleTeamCompletion(params) {
427
74
  updatedAt: null,
428
75
  }));
429
76
  }
77
+ }
78
+ /**
79
+ * Read-only audit safety net: revert any uncommitted modifications, and if
80
+ * the agent somehow committed despite the pre-commit hook, mark the session
81
+ * failed and tear the team down WITHOUT merging.
82
+ *
83
+ * Returns true when the completion flow must abort (violation handled).
84
+ */
85
+ async function runReadOnlyGuardPhase(config, teamState, sessionId) {
86
+ if (!teamState.readOnly)
87
+ return false;
88
+ const { focusId, focusName, branchName } = teamState;
89
+ const worktreePath = teamState.worktreePath;
90
+ // Revert any uncommitted changes
91
+ const hasChanges = worktreeHasUncommittedChanges(worktreePath);
92
+ if (hasChanges) {
93
+ console.warn(`[focus-executor] Read-only audit "${focusName}" left uncommitted changes. Reverting.`);
94
+ runGitSync(['checkout', '.'], worktreePath);
95
+ runGitSync(['clean', '-fd'], worktreePath);
96
+ }
97
+ // Check if the agent somehow committed (bypassed hook)
98
+ if (branchHasUnmergedCommits(branchName, config.integrationBranch, config.repoPath)) {
99
+ console.error(`[focus-executor] READ-ONLY VIOLATION: Audit "${focusName}" has commits on ${branchName}. ` +
100
+ `These will NOT be merged. Manual review required.`);
101
+ const exitReason = `Read-only audit violation: commits detected on ${branchName}. Merge skipped.`;
102
+ // Update session as failed due to read-only violation
103
+ try {
104
+ await withRetry(() => updateSession(sessionId, {
105
+ status: 'failed',
106
+ exit_reason: exitReason,
107
+ exit_category: 'error',
108
+ ended_at: new Date().toISOString(),
109
+ }), { maxAttempts: 3, baseDelayMs: 1000, label: 'session-update-readonly-violation' });
110
+ }
111
+ catch (err) {
112
+ console.warn(`[focus-executor] Failed to update session after retries:`, err.message);
113
+ }
114
+ // Record heartbeat and clean up
115
+ recordSessionCompleted(0, 0);
116
+ const activeTeams = getActiveTeams();
117
+ teamState.phase = 'terminated';
118
+ activeTeams.delete(focusId);
119
+ return true;
120
+ }
121
+ return false;
122
+ }
123
+ // ── The phase sequencer ───────────────────────────────────────────────
124
+ /**
125
+ * Handle the team lead process exiting.
126
+ *
127
+ * Responsibilities (in phase order):
128
+ * - Classify the exit (lifecycle teardown vs crash -- see classifyTeamExit)
129
+ * - Report git state for all known deliveries
130
+ * - Read-only audit guard (revert / block merge on violation)
131
+ * - Preserve WIP commits on unclean exits
132
+ * - Advance delivery statuses based on issue completion
133
+ * - Merge focus branch to integration + escalate per-delivery merge failures
134
+ * - CI: merge integration -> main (CD optionally pushes)
135
+ * - Finalize the session record (status, exit reason, diff stats)
136
+ * - Review exit routing (escalate / handoff -- see runReviewExitHandler)
137
+ * - Terminate: heartbeat, team-state cleanup, re-poll trigger
138
+ */
139
+ export async function handleTeamCompletion(params) {
140
+ const { config, teamState, sessionId, code, signal } = params;
141
+ const { focusId, focusName, branchName } = teamState;
142
+ const worktreePath = teamState.worktreePath;
143
+ // ── Phase: classify exit ────────────────────────────────────────────
144
+ // A lifecycle teardown (shutdownReason='work_complete') SIGTERM is treated
145
+ // as a success, NOT a review failure -- see classifyTeamExit.
146
+ const classification = classifyTeamExit({
147
+ code,
148
+ signal,
149
+ teamPhase: teamState.phase,
150
+ shutdownReason: teamState.shutdownReason,
151
+ });
152
+ const { succeeded, disposition } = classification;
153
+ let exitReason = buildInitialExitReason(teamState, classification, signal);
154
+ // ── Phase: report git state ─────────────────────────────────────────
155
+ const allDeliveries = await fetchDeliveriesForCompletion(teamState);
430
156
  // Report git state for deliveries the team plausibly worked on or finished.
431
157
  // See REPORTABLE_DELIVERY_STATUSES — including 'done' ensures focuses whose
432
158
  // deliveries all reached done before the team exit still attempt the merge.
@@ -435,42 +161,11 @@ export async function handleTeamCompletion(params) {
435
161
  for (const deliveryId of workedDeliveryIds) {
436
162
  reportGitState(deliveryId, 'worktree_complete').catch(err => console.warn(`[focus-executor] reportGitState worktree_complete failed for ${deliveryId}:`, err.message));
437
163
  }
438
- // ── Read-only audit safety net ──────────────────────────────────
439
- // If this was a read-only session, revert any file modifications and
440
- // block merging if the agent somehow committed despite the pre-commit hook.
441
- if (teamState.readOnly) {
442
- // Revert any uncommitted changes
443
- const hasChanges = worktreeHasUncommittedChanges(worktreePath);
444
- if (hasChanges) {
445
- console.warn(`[focus-executor] Read-only audit "${focusName}" left uncommitted changes. Reverting.`);
446
- runGitSync(['checkout', '.'], worktreePath);
447
- runGitSync(['clean', '-fd'], worktreePath);
448
- }
449
- // Check if the agent somehow committed (bypassed hook)
450
- if (branchHasUnmergedCommits(branchName, config.integrationBranch, config.repoPath)) {
451
- console.error(`[focus-executor] READ-ONLY VIOLATION: Audit "${focusName}" has commits on ${branchName}. ` +
452
- `These will NOT be merged. Manual review required.`);
453
- exitReason = `Read-only audit violation: commits detected on ${branchName}. Merge skipped.`;
454
- // Update session as failed due to read-only violation
455
- try {
456
- await withRetry(() => updateSession(sessionId, {
457
- status: 'failed',
458
- exit_reason: exitReason,
459
- exit_category: 'error',
460
- ended_at: new Date().toISOString(),
461
- }), { maxAttempts: 3, baseDelayMs: 1000, label: 'session-update-readonly-violation' });
462
- }
463
- catch (err) {
464
- console.warn(`[focus-executor] Failed to update session after retries:`, err.message);
465
- }
466
- // Record heartbeat and clean up
467
- recordSessionCompleted(0, 0);
468
- const activeTeams = getActiveTeams();
469
- teamState.phase = 'terminated';
470
- activeTeams.delete(focusId);
471
- return;
472
- }
164
+ // ── Phase: read-only guard ──────────────────────────────────────────
165
+ if (await runReadOnlyGuardPhase(config, teamState, sessionId)) {
166
+ return;
473
167
  }
168
+ // ── Phase: WIP preserve ─────────────────────────────────────────────
474
169
  // Commit any uncommitted WIP changes left by the agent (crash, kill, timeout).
475
170
  // Only needed when the agent didn't exit cleanly — a clean exit means the
476
171
  // agent committed its own work.
@@ -482,10 +177,12 @@ export async function handleTeamCompletion(params) {
482
177
  console.warn(`[focus-executor] WIP commit failed for "${focusName}": ${err.message}`);
483
178
  }
484
179
  }
180
+ // ── Phase: status advance ───────────────────────────────────────────
485
181
  // Advance delivery statuses based on issue completion
486
182
  if (succeeded) {
487
183
  await advanceDeliveryStatuses(config, teamState, sessionId);
488
184
  }
185
+ // ── Phase: merge ────────────────────────────────────────────────────
489
186
  // Merge focus branch to integration if the branch has committed work.
490
187
  // Decoupled from team-exit success: a team that exits via review SIGTERM,
491
188
  // role-clear, daemon crash, or manual termination still gets its committed
@@ -509,93 +206,26 @@ export async function handleTeamCompletion(params) {
509
206
  }
510
207
  // Escalate merge failure for worked deliveries only
511
208
  if (mergeAttempt.attempted && !mergeSucceeded) {
512
- for (const deliveryId of workedDeliveryIds) {
513
- if (!teamState.mergedDeliveryIds.has(deliveryId)) {
514
- escalateMergeConflict({
515
- organizationId,
516
- sessionId,
517
- deliveryId,
518
- deliveryName: focusName,
519
- branchName,
520
- integrationBranch: config.integrationBranch,
521
- mergeError: mergeAttempt.exitReason ?? 'unknown',
522
- }).catch(err => console.warn(`[focus-executor] escalateMergeConflict failed for ${deliveryId}:`, err.message));
523
- }
524
- }
525
- }
526
- // ── CI: merge integration → main ──────────────────────────────
527
- // When CI is enabled (default for legacy focuses without the field),
528
- // always attempt the integration→main merge at team exit. This is the
529
- // unconditional backstop: the eager path in focus-team-lifecycle fires
530
- // when a focus drains mid-flight, but if it didn't (or fired and
531
- // integration has since moved), the exit path closes the gap.
532
- // Independent of mergeSucceeded — even when this focus had no new
533
- // commits, past focuses may have left integration ahead of main.
534
- // mergeIntegrationToMain no-ops cleanly when integration is already
535
- // at main. CD additionally pushes.
536
- const ciDecision = resolveCiDecision(teamState.pipelineConfig);
537
- if (!ciDecision.shouldMerge) {
538
- console.log(`[CI] Skipping integration→main merge for focus ${teamState.focusId} ("${focusName}"): ci.enabled=false`);
539
- exitReason += ' CI skipped (ci.enabled=false).';
540
- }
541
- else {
542
- const { pushToRemote, mergeStrategy } = ciDecision;
543
- const ciLabel = pushToRemote ? 'CI+CD' : 'CI';
544
- console.log(`[${ciLabel}] Merging integration to main for "${focusName}" (strategy=${mergeStrategy})${pushToRemote ? ' (will push to remote)' : ''}`);
545
- try {
546
- const ciResult = await mergeIntegrationToMain({
547
- config,
548
- focusId: teamState.focusId,
549
- focusName,
550
- pushToRemote,
551
- mergeStrategy,
552
- });
553
- if (ciResult.success) {
554
- if (ciResult.pushedTo) {
555
- console.log(`[${ciLabel}] Pushed main to ${ciResult.pushedTo.name} (${ciResult.pushedTo.url}) for focus ${teamState.focusId} ("${focusName}")`);
556
- }
557
- else {
558
- console.log(`[${ciLabel}] Successfully merged integration to main for focus ${teamState.focusId} ("${focusName}")`);
559
- }
560
- // Report merged_to_main / pushed_to_remote for every delivery on
561
- // integration. That's mid-flight-merged deliveries plus, if this
562
- // exit's focus→integration merge succeeded, the just-merged ones.
563
- // Failed focus merges leave workedDeliveryIds off integration —
564
- // exclude them so we don't lie about where they landed.
565
- const onIntegration = new Set(teamState.mergedDeliveryIds);
566
- if (mergeSucceeded) {
567
- for (const id of workedDeliveryIds)
568
- onIntegration.add(id);
569
- }
570
- const newState = ciResult.pushedTo ? 'pushed_to_remote' : 'merged_to_main';
571
- for (const deliveryId of onIntegration) {
572
- reportGitState(deliveryId, newState).catch(err => console.warn(`[${ciLabel}] reportGitState ${newState} failed for ${deliveryId}:`, err.message));
573
- }
574
- exitReason += ` ${ciLabel}: merged to main${ciResult.pushedTo ? ' and pushed to remote' : ''}.`;
575
- }
576
- else {
577
- console.error(`[${ciLabel}] Failed to merge integration to main for "${focusName}": ${ciResult.error}`);
578
- exitReason += ` ${ciLabel} failed: ${ciResult.error}`;
579
- if (shouldFileCdPushEscalation(pushToRemote, ciResult)) {
580
- await fileCdPushEscalation({
581
- organizationId: teamState.organizationId,
582
- sessionId,
583
- focusId: teamState.focusId,
584
- focusName,
585
- integrationBranch: config.integrationBranch,
586
- pushError: ciResult.error,
587
- });
588
- }
589
- }
590
- }
591
- catch (err) {
592
- console.error(`[${ciLabel}] Error merging integration to main for "${focusName}":`, err.message);
593
- exitReason += ` ${ciLabel} error: ${err.message}`;
594
- }
209
+ escalateFailedMergeDeliveries({
210
+ config,
211
+ teamState,
212
+ sessionId,
213
+ workedDeliveryIds,
214
+ mergeExitReason: mergeAttempt.exitReason,
215
+ });
595
216
  }
217
+ // ── Phase: CI merge-to-main ─────────────────────────────────────────
218
+ exitReason += await runCiMergeToMain({
219
+ config,
220
+ teamState,
221
+ sessionId,
222
+ mergeSucceeded,
223
+ workedDeliveryIds,
224
+ });
596
225
  // Worktree is focus-owned — do NOT remove on team completion.
597
226
  // It persists for the next team session or QA use.
598
227
  // Removal only happens via coordinated teardown when focus is deactivated.
228
+ // ── Phase: session finalize ─────────────────────────────────────────
599
229
  // Compute git diff stats from all commits on this branch since integration
600
230
  let diffStats = { linesAdded: 0, linesRemoved: 0 };
601
231
  try {
@@ -622,12 +252,13 @@ export async function handleTeamCompletion(params) {
622
252
  catch (err) {
623
253
  console.warn(`[focus-executor] Failed to update session after retries:`, err.message);
624
254
  }
625
- // Review exit handler: see runReviewExitHandler below for the rules.
255
+ // ── Phase: review exit (escalate / handoff) ─────────────────────────
256
+ // See runReviewExitHandler for the routing rules.
626
257
  try {
627
258
  await runReviewExitHandler({
628
259
  focusId,
629
260
  focusName,
630
- organizationId,
261
+ organizationId: teamState.organizationId,
631
262
  productId: teamState.productId,
632
263
  sessionId,
633
264
  sessionType: teamState.sessionType,
@@ -637,6 +268,7 @@ export async function handleTeamCompletion(params) {
637
268
  catch (err) {
638
269
  console.warn(`[focus-executor] Review exit handler failed for "${focusName}":`, err.message);
639
270
  }
271
+ // ── Phase: terminate ────────────────────────────────────────────────
640
272
  // Record heartbeat
641
273
  recordSessionCompleted(0, 0);
642
274
  // Clean up team state
@@ -649,167 +281,4 @@ export async function handleTeamCompletion(params) {
649
281
  }
650
282
  console.log(`[focus-executor] Team for "${focusName}" cleaned up (merge: ${mergeSucceeded ? 'yes' : 'no'})`);
651
283
  }
652
- const defaultDeps = {
653
- getFocusDeliveries,
654
- getDeliveryIssues,
655
- getDeliverySessionCount,
656
- updateIssueStatus,
657
- fetchEffectiveWorkflow,
658
- updateDeliveryStatus,
659
- advanceDeliveryStage,
660
- };
661
- /**
662
- * Advance delivery statuses after team completion.
663
- *
664
- * Re-queries all deliveries for the focus from the database rather
665
- * than relying on the spawn-time snapshot in teamState.knownDeliveryIds.
666
- * This ensures deliveries added mid-flight (after the team was spawned)
667
- * are discovered and advanced, and that stale deliveryStageIds don't
668
- * cause deliveries to be skipped.
669
- *
670
- * For each delivery in 'coding' status:
671
- * - All issues Done (or no issues): advance via workflow (coding -> verify -> done)
672
- * - Open issues remain: re-queue to 'queued' so the next team session picks it up
673
- */
674
- export async function advanceDeliveryStatuses(config, teamState, sessionId, deps = defaultDeps) {
675
- // Re-query deliveries fresh from the DB to discover mid-flight additions
676
- let deliveries;
677
- try {
678
- deliveries = await deps.getFocusDeliveries(teamState.focusId);
679
- }
680
- catch (err) {
681
- console.warn(`[focus-executor] Failed to fetch deliveries for focus ${teamState.focusId}:`, err.message);
682
- return;
683
- }
684
- for (const delivery of deliveries) {
685
- // Skip deliveries that were already merged mid-flight
686
- if (teamState.mergedDeliveryIds.has(delivery.id)) {
687
- continue;
688
- }
689
- // Only advance deliveries in agent-actionable status.
690
- if (!isStatusAgentActionable(delivery.executionStatus ?? '')) {
691
- continue;
692
- }
693
- try {
694
- const [issues, sessionCount, workflow] = await Promise.all([
695
- deps.getDeliveryIssues(delivery.id),
696
- deps.getDeliverySessionCount(delivery.id),
697
- deps.fetchEffectiveWorkflow(delivery.id),
698
- ]);
699
- // Context Groups are reference material, not actionable work — exclude from open count
700
- const workIssues = filterWorkIssues(issues);
701
- const contextGroups = filterContextGroups(issues);
702
- const openCount = workIssues.filter(i => !TERMINAL_ISSUE_STATUSES.has(i.status)).length;
703
- const totalWork = workIssues.length;
704
- // Use currentWorkflowStageId from the fresh query, falling back to
705
- // the teamState cache for deliveries known at spawn time
706
- const currentStageId = delivery.currentWorkflowStageId
707
- ?? teamState.deliveryStageIds.get(delivery.id)
708
- ?? null;
709
- if (!currentStageId) {
710
- console.warn(`[focus-executor] Delivery ${delivery.id}: no workflow stage ID, skipping advance`);
711
- continue;
712
- }
713
- // Re-queue deliveries with open work issues so the next team picks them up
714
- if (totalWork > 0 && openCount > 0) {
715
- // Already queued — leave it there
716
- if (delivery.executionStatus === DELIVERY_STATUS.QUEUED) {
717
- continue;
718
- }
719
- const queuedStage = workflow.stages.find(s => s.name === 'queued');
720
- if (queuedStage) {
721
- console.log(`[focus-executor] Delivery ${delivery.id}: ${openCount}/${totalWork} work issues still open, re-queuing`);
722
- await deps.updateDeliveryStatus(delivery.id, 'queued', queuedStage.id, undefined, undefined, { organizationId: teamState.organizationId, fromStatus: delivery.executionStatus });
723
- teamState.deliveryStageIds.set(delivery.id, queuedStage.id);
724
- }
725
- else {
726
- console.warn(`[focus-executor] Delivery ${delivery.id}: ${openCount}/${totalWork} work issues still open, no queued stage in workflow — leaving in ${delivery.executionStatus}`);
727
- }
728
- continue;
729
- }
730
- // Auto-close Context Groups when all work issues are done
731
- for (const cg of contextGroups) {
732
- if (cg.status !== 'Done') {
733
- try {
734
- await deps.updateIssueStatus(cg.id, 'Done');
735
- console.log(`[focus-executor] Auto-closed Context Group "${cg.title}" (all work issues done)`);
736
- }
737
- catch (err) {
738
- console.warn(`[focus-executor] Failed to auto-close Context Group "${cg.title}":`, err.message);
739
- }
740
- }
741
- }
742
- // Sweep Verified and In Review issues to Done before advancing.
743
- // 'Verified': review agent confirmed the fix but used Verified instead of Done.
744
- // 'In Review': dev signalled completion; no review agent ran before close.
745
- // Both collapse to Done for a clean terminal record.
746
- const sweepIds = planDeliveryCloseSweep(workIssues);
747
- for (const issueId of sweepIds) {
748
- try {
749
- await deps.updateIssueStatus(issueId, 'Done');
750
- console.log(`[focus-executor] Swept issue ${issueId} to Done before delivery advance`);
751
- }
752
- catch (err) {
753
- console.warn(`[focus-executor] Failed to sweep issue ${issueId} to Done:`, err.message);
754
- }
755
- }
756
- // If delivery is queued or verify_failed, step through coding first
757
- // (workflow doesn't allow queued/verify_failed -> verify directly).
758
- // Update the stage ID for the subsequent advance call.
759
- let effectiveStageId = currentStageId;
760
- // from_status fed to the subsequent advanceDeliveryStage call: defaults to
761
- // the delivery's current status, but becomes 'coding' once we step there.
762
- let effectiveFromStatus = delivery.executionStatus ?? null;
763
- if (delivery.executionStatus === DELIVERY_STATUS.QUEUED || delivery.executionStatus === DELIVERY_STATUS.VERIFY_FAILED) {
764
- const codingStage = workflow.stages.find(s => s.name === 'coding');
765
- if (codingStage) {
766
- console.log(`[focus-executor] Delivery ${delivery.id}: stepping ${delivery.executionStatus} -> coding before verify advance`);
767
- await deps.updateDeliveryStatus(delivery.id, 'coding', codingStage.id, undefined, undefined, { organizationId: teamState.organizationId, fromStatus: delivery.executionStatus });
768
- effectiveStageId = codingStage.id;
769
- effectiveFromStatus = 'coding';
770
- }
771
- }
772
- console.log(`[focus-executor] Delivery ${delivery.id}: ${totalWork === 0 ? 'no issues' : `all ${totalWork} work issues done`}, advancing via workflow`);
773
- const precheck = await ensureWorktreeCommittedBeforeAdvance({
774
- deliveryId: delivery.id,
775
- deliveryName: delivery.name,
776
- focusId: teamState.focusId,
777
- organizationId: teamState.organizationId,
778
- sessionId,
779
- });
780
- if (!precheck.ok) {
781
- console.log(`[focus-executor] Delivery ${delivery.id}: skip advance -- ${precheck.reason}`);
782
- continue;
783
- }
784
- const result = await deps.advanceDeliveryStage({
785
- deliveryId: delivery.id,
786
- deliveryName: delivery.name,
787
- organizationId: teamState.organizationId,
788
- workflow,
789
- currentStageId: effectiveStageId,
790
- fromStatus: effectiveFromStatus,
791
- exitCode: 0,
792
- openIssueCount: openCount,
793
- sessionCount,
794
- policyFailureMode: config.policyFailureMode,
795
- sessionId,
796
- focusId: teamState.focusId,
797
- config,
798
- });
799
- // Track the new stage ID for potential future advances
800
- teamState.deliveryStageIds.set(delivery.id, result.deliveryStageId ?? null);
801
- // Notify loop engine of delivery completion (verify or done)
802
- if (result.advanced && (result.deliveryStatus === 'verify' || result.deliveryStatus === 'done')) {
803
- emitLoopTrigger({
804
- type: 'delivery_completed',
805
- focusId: teamState.focusId,
806
- detail: `${delivery.name} -> ${result.deliveryStatus}`,
807
- });
808
- }
809
- }
810
- catch (err) {
811
- console.warn(`[focus-executor] Failed to check/advance delivery ${delivery.id}:`, err.message);
812
- }
813
- }
814
- }
815
284
  //# sourceMappingURL=team-completion.js.map