chainlesschain 0.162.186 → 0.162.187

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 (173) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/assets/{AIOps-DTXiO-TB.js → AIOps-CETaO3s8.js} +1 -1
  3. package/src/assets/web-panel/assets/{ActionButton-BbmDW-Pz.js → ActionButton-CAe7E9TW.js} +1 -1
  4. package/src/assets/web-panel/assets/{Analytics-CSfgXJuD.js → Analytics-BGr2mzwO.js} +3 -3
  5. package/src/assets/web-panel/assets/{AppLayout-BS0FFLb7.js → AppLayout-BEoY-NcT.js} +5 -5
  6. package/src/assets/web-panel/assets/{Artifacts-B19WFPK2.js → Artifacts-DUZivCgt.js} +1 -1
  7. package/src/assets/web-panel/assets/{Audit-CQhZBi7n.js → Audit-C3yBs68k.js} +1 -1
  8. package/src/assets/web-panel/assets/{BackgroundAgents-BOLZCpGH.js → BackgroundAgents-C9KwiY3O.js} +1 -1
  9. package/src/assets/web-panel/assets/{Backup-Dz3R_ePD.js → Backup-BoSWD_C8.js} +1 -1
  10. package/src/assets/web-panel/assets/{BaseInput-CYfgMpax.js → BaseInput-Dp09GkxL.js} +1 -1
  11. package/src/assets/web-panel/assets/{Chat-5CymMr1j.js → Chat-5t9d-Ja0.js} +4 -4
  12. package/src/assets/web-panel/assets/ChatBubbleRenderer-DmVku4GH.js +1 -0
  13. package/src/assets/web-panel/assets/{Checkbox-CDXHVYcu.js → Checkbox-8QJFlkqI.js} +1 -1
  14. package/src/assets/web-panel/assets/{Codegen-uq9Z9oiS.js → Codegen-BJVFQ3lL.js} +1 -1
  15. package/src/assets/web-panel/assets/{Col-BuW1K_65.js → Col-6hdLD0oB.js} +1 -1
  16. package/src/assets/web-panel/assets/{Community-Bbg-PvRY.js → Community-B8opo0uY.js} +1 -1
  17. package/src/assets/web-panel/assets/{Compact-co0pPPTw.js → Compact-Dv8HudiN.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compliance-B-pFAv7z.js → Compliance-DhxuCS5N.js} +1 -1
  19. package/src/assets/web-panel/assets/{Cowork-54ZQS4gC.js → Cowork-Dggy6VIL.js} +2 -2
  20. package/src/assets/web-panel/assets/{Cron-B1BF2Seq.js → Cron-DSmDqJTT.js} +2 -2
  21. package/src/assets/web-panel/assets/{Crosschain-Dey6qQTj.js → Crosschain-7bZq0j5f.js} +1 -1
  22. package/src/assets/web-panel/assets/{DID-ltERvfAj.js → DID-BWY4kh-H.js} +2 -2
  23. package/src/assets/web-panel/assets/{Dashboard-DuWxOcqN.js → Dashboard-bOzPZxlu.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dropdown-CrZ8ekTo.js → Dropdown-BTFJHqQu.js} +1 -1
  25. package/src/assets/web-panel/assets/{EmailListRenderer-fIyHXhUS.js → EmailListRenderer-BC3MOdtT.js} +1 -1
  26. package/src/assets/web-panel/assets/{FamilyGuardDashboard-BtRyrRPm.js → FamilyGuardDashboard-CFHAdTTY.js} +1 -1
  27. package/src/assets/web-panel/assets/{Federation-EUC6Wydj.js → Federation-DZ_4aF_n.js} +1 -1
  28. package/src/assets/web-panel/assets/{FormItemContext-BOpka2G-.js → FormItemContext-BZje6UTj.js} +1 -1
  29. package/src/assets/web-panel/assets/{GenericCardRenderer-BXtrrYHn.js → GenericCardRenderer-D7QfCvjN.js} +1 -1
  30. package/src/assets/web-panel/assets/{Git-8WMb7PiR.js → Git-DuG7qANG.js} +2 -2
  31. package/src/assets/web-panel/assets/{Governance-DNWOeDTV.js → Governance-BDmYAgTh.js} +1 -1
  32. package/src/assets/web-panel/assets/{Inference-Bne9b_Lb.js → Inference-DSXAfc1Q.js} +1 -1
  33. package/src/assets/web-panel/assets/{KnowledgeGraph-Cp-6Us9b.js → KnowledgeGraph-DusleniP.js} +1 -1
  34. package/src/assets/web-panel/assets/{Logs-Ck-uSPAx.js → Logs-CpImmA8p.js} +2 -2
  35. package/src/assets/web-panel/assets/{MarkdownRenderer-DxjsdAXw.js → MarkdownRenderer-BZ6LJBkk.js} +1 -1
  36. package/src/assets/web-panel/assets/{Marketplace-Vdq7brrJ.js → Marketplace-DXS8CXkE.js} +1 -1
  37. package/src/assets/web-panel/assets/{McpTools-ggLnw4hY.js → McpTools-D151UfUI.js} +5 -5
  38. package/src/assets/web-panel/assets/{Memory-BKIjNVCU.js → Memory-DKDxnoA1.js} +2 -2
  39. package/src/assets/web-panel/assets/{MobileBridge-BElCY25a.js → MobileBridge-BFZ95iKM.js} +1 -1
  40. package/src/assets/web-panel/assets/MobileProjects-CDAR-grF.js +1 -0
  41. package/src/assets/web-panel/assets/{Mtc-bNdrF_Zh.js → Mtc-OGJ95xJs.js} +4 -4
  42. package/src/assets/web-panel/assets/{MtcAudit-Dw8w1Cqk.js → MtcAudit-BmGTo1XC.js} +2 -2
  43. package/src/assets/web-panel/assets/{Multisig-CfGKvlqc.js → Multisig-XumJRqOa.js} +3 -3
  44. package/src/assets/web-panel/assets/{NLProgramming-D6OJf7YN.js → NLProgramming-DHhhRZed.js} +1 -1
  45. package/src/assets/web-panel/assets/{Notes-B3n_LzIS.js → Notes-B9rGxNNr.js} +3 -3
  46. package/src/assets/web-panel/assets/{NotificationSettings-Uu1V_ICM.js → NotificationSettings-DClLbMYV.js} +1 -1
  47. package/src/assets/web-panel/assets/{OrderTableRenderer-aGyWwWcp.js → OrderTableRenderer-I8OohT7N.js} +1 -1
  48. package/src/assets/web-panel/assets/{Organization-qjFFmuwK.js → Organization-M6WzaYVa.js} +4 -4
  49. package/src/assets/web-panel/assets/{Overflow-J4ZgnwZz.js → Overflow-Cb3B5KtM.js} +1 -1
  50. package/src/assets/web-panel/assets/{P2P-YEZF-uVH.js → P2P-BGk57kya.js} +2 -2
  51. package/src/assets/web-panel/assets/{PdhVaultBrowser-yqx7fwqk.js → PdhVaultBrowser-CXcKyPqK.js} +3 -3
  52. package/src/assets/web-panel/assets/{Permissions-DdEjlZ02.js → Permissions-hsfJ-Y0e.js} +4 -4
  53. package/src/assets/web-panel/assets/{PersonalDataHub-DbAPYvns.js → PersonalDataHub-BSBii4js.js} +4 -4
  54. package/src/assets/web-panel/assets/{Pipeline-Bxlk_ZJU.js → Pipeline-BNw52rey.js} +1 -1
  55. package/src/assets/web-panel/assets/{Privacy-CpE-05tz.js → Privacy-DUpfue4E.js} +1 -1
  56. package/src/assets/web-panel/assets/{ProjectInit-DWlhcGHT.js → ProjectInit-C68NsP3h.js} +2 -2
  57. package/src/assets/web-panel/assets/{ProjectSettings-BnH6BiTQ.js → ProjectSettings-BJ2It9v4.js} +2 -2
  58. package/src/assets/web-panel/assets/{Projects-CHw2fXDB.js → Projects-D2FHLUa-.js} +1 -1
  59. package/src/assets/web-panel/assets/{Providers-Bdz_sTyb.js → Providers-EnU4VWwS.js} +1 -1
  60. package/src/assets/web-panel/assets/{QrScannerModal-BHh2b3pH.js → QrScannerModal-BRt5-B_I.js} +1 -1
  61. package/src/assets/web-panel/assets/{QuickAsk-D84M09pe.js → QuickAsk-Jd5L9UL2.js} +1 -1
  62. package/src/assets/web-panel/assets/{Recommend-Czc30K6M.js → Recommend-BNDhoVIu.js} +1 -1
  63. package/src/assets/web-panel/assets/{RemoteSession-q_TU5qxt.js → RemoteSession-BII41Mel.js} +2 -2
  64. package/src/assets/web-panel/assets/{Reputation-CnTce4WM.js → Reputation-D1wQPUNv.js} +1 -1
  65. package/src/assets/web-panel/assets/{Row-C60UFLay.js → Row-DnjRegul.js} +1 -1
  66. package/src/assets/web-panel/assets/{RssFeed-BOUFEu9_.js → RssFeed-C_T4I-nF.js} +3 -3
  67. package/src/assets/web-panel/assets/{Search-D4__blE-.js → Search-C4lrxlLh.js} +1 -1
  68. package/src/assets/web-panel/assets/{Security-BoOm7_JK.js → Security-ByYT-VzP.js} +4 -4
  69. package/src/assets/web-panel/assets/{Services-AvzrW6HA.js → Services-BHpyEqB5.js} +2 -2
  70. package/src/assets/web-panel/assets/{Skeleton-BZlrHozi.js → Skeleton-C0I23vG-.js} +1 -1
  71. package/src/assets/web-panel/assets/{Skills-CKaMLuk0.js → Skills-DGZXz22K.js} +1 -1
  72. package/src/assets/web-panel/assets/{Sla-DYCNJF8h.js → Sla-DL_urP9A.js} +1 -1
  73. package/src/assets/web-panel/assets/SpeechSettings-6x-hUNQ_.js +1 -0
  74. package/src/assets/web-panel/assets/{SyncSettings-B6tW8dVO.js → SyncSettings-Crk5TCXq.js} +2 -2
  75. package/src/assets/web-panel/assets/{Tasks-oNC_2NVz.js → Tasks-rZGq5PZs.js} +1 -1
  76. package/src/assets/web-panel/assets/{Templates-DvLPvvjL.js → Templates-DT5el8Q8.js} +1 -1
  77. package/src/assets/web-panel/assets/{Tenant-BDt9UBn_.js → Tenant-mlgeyMK8.js} +1 -1
  78. package/src/assets/web-panel/assets/{Terminal-ddCUnH-X.js → Terminal-C8HEA-lM.js} +2 -2
  79. package/src/assets/web-panel/assets/{TimelineRenderer-CA4sDL47.js → TimelineRenderer-DvlqBayB.js} +1 -1
  80. package/src/assets/web-panel/assets/{Tokens-By03hnVs.js → Tokens-C7lDcNmT.js} +1 -1
  81. package/src/assets/web-panel/assets/{Trigger-Dz2mH-ZU.js → Trigger-CM8nCnIJ.js} +1 -1
  82. package/src/assets/web-panel/assets/{Trust-DShSWHeJ.js → Trust-BQDLUQ9x.js} +1 -1
  83. package/src/assets/web-panel/assets/{UkeySign-JN8lP3gt.js → UkeySign-CVqM0OFE.js} +1 -1
  84. package/src/assets/web-panel/assets/{VideoEditing-BgPy4gV3.js → VideoEditing-BvAcQpzT.js} +1 -1
  85. package/src/assets/web-panel/assets/{Wallet-CQGEsXw_.js → Wallet-zFVFaAZ2.js} +3 -3
  86. package/src/assets/web-panel/assets/{WebAuthn-D8xR-ds_.js → WebAuthn-Cc522ums.js} +4 -4
  87. package/src/assets/web-panel/assets/{WorkflowEditor-Df61_p1p.js → WorkflowEditor-Bg_wxpDo.js} +1 -1
  88. package/src/assets/web-panel/assets/{chat-fdPby427.js → chat-jg0QwC8K.js} +1 -1
  89. package/src/assets/web-panel/assets/{colors-Dimqhufo.js → colors-ALzYz1gO.js} +1 -1
  90. package/src/assets/web-panel/assets/{compact-item-DHayKcqZ.js → compact-item-DXHYUoqi.js} +1 -1
  91. package/src/assets/web-panel/assets/{createContext-hZUhypRH.js → createContext-BsGsBM5m.js} +1 -1
  92. package/src/assets/web-panel/assets/devWarning-Dy1GJA4S.js +1 -0
  93. package/src/assets/web-panel/assets/{hasIn-Dw7zC_dV.js → hasIn-BccMLzxA.js} +1 -1
  94. package/src/assets/web-panel/assets/{index-BIxxvaGN.js → index-08diKcTm.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-BVHdkz8F.js → index-2VstcImq.js} +17 -17
  96. package/src/assets/web-panel/assets/index-8zbMbbuf.js +1 -0
  97. package/src/assets/web-panel/assets/{index-DAjvkQ4K.js → index-BBbbr1YB.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-DX1DwDFI.js → index-BTTeAiOP.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-JE6Udz2r.js → index-BcJzphrP.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-BYdNwhkv.js → index-BdCmJ_bG.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-CTiVrnpR.js → index-BfF0OGKU.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-C1yMYKi-.js → index-BfV8RDlg.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-DxoPzPHy.js → index-BvsII10i.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-DyEhrvpm.js → index-C0AbXsDy.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-lPjBQJCy.js → index-C3Rf8wIM.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-CPZ3U5iH.js → index-C3k3ZwkQ.js} +1 -1
  107. package/src/assets/web-panel/assets/index-C97f71oi.js +1 -0
  108. package/src/assets/web-panel/assets/{index-DJP7TjWm.js → index-CBVIFX-x.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-DSlda4-6.js → index-CJg6Wsjc.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-DzJXkoZs.js → index-CLzPzN75.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-cEnkyHtX.js → index-CQ1ORdp-.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-Bhmpp3cm.js → index-CSFESurl.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-BwnUluxu.js → index-CWLWBgfe.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-CIOE8tt6.js → index-CahDve8M.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-CBQK-LEF.js → index-Cg9cOVe9.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-D7jQEwB6.js → index-Cmg_Npx5.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-BuMSdwno.js → index-CrZHUsOZ.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-DnwDYBnG.js → index-Cz_s25m3.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-DVM_KgzF.js → index-D9oV2iRR.js} +1 -1
  120. package/src/assets/web-panel/assets/{index--DVpUpz3.js → index-DgjQ-kdT.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-y5LHQ5Ck.js → index-Dmk7dD-T.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-Urt2R5aE.js → index-Dxqiu9-F.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-CvKHpaao.js → index-JS2LXdd0.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-CsCo_A0I.js → index-TeCof1s3.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-ulvBurtw.js → index-UQXedWQz.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-BM5BNqp9.js → index-koT8Q86Z.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-BHJUptSx.js → index-mIQUoUx1.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-C8XXPz2n.js → index-oWltafBn.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-DC2R4zvM.js → index-rIy96PeM.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-j0pxzOu7.js → index-rcwgNMc0.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-CuaVa8tX.js → index-srqW5_-X.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-CcpDboUd.js → index-uzykuL_R.js} +1 -1
  133. package/src/assets/web-panel/assets/{initDefaultProps-B4pR3px5.js → initDefaultProps-xafkqrW3.js} +1 -1
  134. package/src/assets/web-panel/assets/{motion-CE7dWPPs.js → motion-BrtyQXLk.js} +1 -1
  135. package/src/assets/web-panel/assets/{move-Hw8cUObc.js → move-BunUyg59.js} +1 -1
  136. package/src/assets/web-panel/assets/{mtc-parser-CvRjE4Fa.js → mtc-parser-BPwjBeuE.js} +1 -1
  137. package/src/assets/web-panel/assets/{omit-CkFeLVre.js → omit-BpyqZlNO.js} +1 -1
  138. package/src/assets/web-panel/assets/{pickAttrs-5ZMqmbbb.js → pickAttrs-DDrV0XjE.js} +1 -1
  139. package/src/assets/web-panel/assets/{placementArrow-BZ4oeZ0q.js → placementArrow-1A18Pcl2.js} +1 -1
  140. package/src/assets/web-panel/assets/{responsiveObserve-lwoiX_Us.js → responsiveObserve-B8Fnlmso.js} +1 -1
  141. package/src/assets/web-panel/assets/{slide-D_as37l8.js → slide-x-Fwb2Zm.js} +1 -1
  142. package/src/assets/web-panel/assets/{statusUtils-BsA-4Bs7.js → statusUtils-aAG5yJl_.js} +1 -1
  143. package/src/assets/web-panel/assets/{styleChecker-BVar4sLQ.js → styleChecker-BRQSTMRL.js} +1 -1
  144. package/src/assets/web-panel/assets/{useFlexGapSupport-j1IwVpGd.js → useFlexGapSupport-D5X_KozY.js} +1 -1
  145. package/src/assets/web-panel/assets/{useFs-7Q9bEnUp.js → useFs-DN_YIjP-.js} +1 -1
  146. package/src/assets/web-panel/assets/{usePersonalDataHub-BBwTIRMd.js → usePersonalDataHub-BizxAvjw.js} +1 -1
  147. package/src/assets/web-panel/assets/{vnode-C7Dk-M8w.js → vnode-B5oAxN93.js} +1 -1
  148. package/src/assets/web-panel/assets/{zoom-D3UHaTFB.js → zoom-DJhvyzKv.js} +1 -1
  149. package/src/assets/web-panel/index.html +1 -1
  150. package/src/commands/team.js +1805 -473
  151. package/src/data/changelog.json +13 -0
  152. package/src/harness/worktree-isolator.js +671 -79
  153. package/src/lib/agent-team/task-lease.js +378 -78
  154. package/src/lib/agent-team/team-agent-stream.js +488 -0
  155. package/src/lib/agent-team/team-budget.js +216 -20
  156. package/src/lib/agent-team/team-mailbox.js +389 -15
  157. package/src/lib/agent-team/team-run-state-lock.js +209 -0
  158. package/src/lib/agent-team/team-runner.js +547 -63
  159. package/src/lib/agent-team/team-scope-lock.js +409 -0
  160. package/src/lib/agent-team/team-task-contract.js +277 -0
  161. package/src/lib/agent-team/team-worktree.js +1171 -62
  162. package/src/lib/agent-worktree.js +19 -20
  163. package/src/lib/collaboration-run-store.js +983 -68
  164. package/src/lib/with-file-lock.js +190 -24
  165. package/src/lib/worktree-isolator.js +3 -0
  166. package/src/lib/worktree-sparse.js +23 -0
  167. package/src/runtime/headless-stream.js +6 -1
  168. package/src/assets/web-panel/assets/ChatBubbleRenderer-B2A_TJtl.js +0 -1
  169. package/src/assets/web-panel/assets/MobileProjects-DpLgovdH.js +0 -1
  170. package/src/assets/web-panel/assets/SpeechSettings-BvCSU6d2.js +0 -1
  171. package/src/assets/web-panel/assets/devWarning-ePxoBp92.js +0 -1
  172. package/src/assets/web-panel/assets/index-BTBDdCoo.js +0 -1
  173. package/src/assets/web-panel/assets/index-CfOGx8Ns.js +0 -1
@@ -15,8 +15,12 @@
15
15
  * integration test exercises the actual worktree/commit/merge path.
16
16
  */
17
17
 
18
+ import { createHash } from "node:crypto";
19
+ import { existsSync } from "node:fs";
18
20
  import {
19
21
  createWorktree as _createWorktree,
22
+ assertManagedWorktreePath as _assertManagedWorktreePath,
23
+ removeManagedDependencyLinks as _removeManagedDependencyLinks,
20
24
  removeWorktree as _removeWorktree,
21
25
  previewWorktreeMerge as _previewWorktreeMerge,
22
26
  mergeWorktree as _mergeWorktree,
@@ -24,12 +28,30 @@ import {
24
28
  import { isGitRepo as _isGitRepo } from "../../lib/git-integration.js";
25
29
  import { normalizeSparsePaths } from "../../lib/worktree-sparse.js";
26
30
  import executionBroker from "../../lib/process-execution-broker/index.js";
31
+ import { stopBackgroundAgentChildTree } from "../../lib/background-agent-supervisor.js";
32
+ import { redactSecrets } from "../../lib/secret-scan.js";
27
33
 
28
34
  export const _processDeps = {
29
35
  spawn: executionBroker.spawn.bind(executionBroker),
30
36
  execFileSync: executionBroker.execFileSync.bind(executionBroker),
37
+ killProcessTree: (child) => {
38
+ const pid = Number(child?.pid);
39
+ if (Number.isSafeInteger(pid) && pid > 0 && pid !== process.pid) {
40
+ try {
41
+ return stopBackgroundAgentChildTree(pid);
42
+ } catch {
43
+ /* fall through to the direct handle */
44
+ }
45
+ }
46
+ return child?.kill?.() ?? false;
47
+ },
31
48
  };
32
49
 
50
+ export const MAX_TEAM_WORKTREE_STDERR_BYTES = 64 * 1024;
51
+ const TEAM_WORKTREE_SNAPSHOT_VERSION = 3;
52
+ const MAX_RUN_ID_BYTES = 256;
53
+ const MAX_RECORD_VALUE_BYTES = 4096;
54
+
33
55
  /**
34
56
  * Resolve the sparse-checkout / dependency-symlink options for a task, letting a
35
57
  * per-task value (from the plan) override the coordinator-wide default. Returns
@@ -41,35 +63,142 @@ function resolveWorktreeOptions(task, defaults) {
41
63
  const sparse = normalizeSparsePaths(
42
64
  t.sparsePaths ?? t.metadata?.sparsePaths ?? defaults.sparsePaths ?? null,
43
65
  );
44
- const symlink =
45
- t.symlinkDirectories ??
46
- t.metadata?.symlinkDirectories ??
47
- defaults.symlinkDirectories ??
48
- null;
66
+ const requestedSymlink =
67
+ t.symlinkDirectories ?? t.metadata?.symlinkDirectories ?? null;
68
+ const approvedSymlink = defaults.symlinkDirectories ?? null;
69
+ let symlink = approvedSymlink;
70
+ if (requestedSymlink != null) {
71
+ const requested = Array.isArray(requestedSymlink)
72
+ ? requestedSymlink
73
+ : [requestedSymlink];
74
+ const approved = new Set(
75
+ (Array.isArray(approvedSymlink)
76
+ ? approvedSymlink
77
+ : approvedSymlink == null
78
+ ? []
79
+ : [approvedSymlink]
80
+ ).map(String),
81
+ );
82
+ if (requested.some((entry) => !approved.has(String(entry)))) {
83
+ throw new Error(
84
+ "task symlinkDirectories must be explicitly approved by --symlink-dirs",
85
+ );
86
+ }
87
+ symlink = requestedSymlink;
88
+ }
49
89
  const opts = {};
50
90
  if (sparse) opts.sparsePaths = sparse;
51
91
  if (symlink != null) opts.symlinkDirectories = symlink;
52
92
  return Object.keys(opts).length ? opts : undefined;
53
93
  }
54
94
 
55
- function defaultRunShell(command, cwd) {
95
+ function safeProcessError(value, fallback) {
96
+ const text = redactSecrets(String(value || "")).trim();
97
+ return (text || fallback).slice(0, 4096);
98
+ }
99
+
100
+ function requireAdjudication(error, code) {
101
+ const failure =
102
+ error instanceof Error ? error : new Error(String(error || code));
103
+ failure.code = failure.code || code;
104
+ failure.retryable = false;
105
+ return failure;
106
+ }
107
+
108
+ function defaultRunShell(command, cwd, { signal = null } = {}) {
56
109
  return new Promise((resolve, reject) => {
57
- const child = _processDeps.spawn(command, [], {
58
- cwd,
59
- shell: true,
60
- env: process.env,
61
- origin: "team-worktree:task-command",
62
- policy: "allow",
63
- scope: "team-worktree",
110
+ let child;
111
+ try {
112
+ child = _processDeps.spawn(command, [], {
113
+ cwd,
114
+ shell: true,
115
+ env: process.env,
116
+ origin: "team-worktree:task-command",
117
+ policy: "allow",
118
+ scope: "team-worktree",
119
+ detached: process.platform !== "win32",
120
+ });
121
+ } catch (error) {
122
+ reject(error instanceof Error ? error : new Error(String(error)));
123
+ return;
124
+ }
125
+
126
+ let settled = false;
127
+ let stderrBytes = 0;
128
+ const stderrChunks = [];
129
+ let terminationError = null;
130
+ let terminationTimer = null;
131
+ let abortListener = null;
132
+ const finish = (error, value) => {
133
+ if (settled) return;
134
+ settled = true;
135
+ if (terminationTimer) clearTimeout(terminationTimer);
136
+ if (abortListener) {
137
+ signal?.removeEventListener?.("abort", abortListener);
138
+ }
139
+ if (error) reject(error);
140
+ else resolve(value);
141
+ };
142
+ const terminate = () => {
143
+ if (settled || terminationError) return;
144
+ terminationError = new Error("Team worktree shell task was cancelled");
145
+ terminationError.code = "TEAM_WORKTREE_SHELL_ABORTED";
146
+ try {
147
+ _processDeps.killProcessTree(child);
148
+ } catch {
149
+ /* wait for close or the bounded grace timer */
150
+ }
151
+ terminationTimer = setTimeout(() => finish(terminationError), 5000);
152
+ };
153
+
154
+ // A piped stdout that nobody consumes can fill its OS buffer and deadlock a
155
+ // task before it exits. Worktree commands do not return stdout, so drain it.
156
+ if (typeof child.stdout?.resume === "function") {
157
+ child.stdout.resume();
158
+ } else {
159
+ child.stdout?.on?.("data", () => {});
160
+ }
161
+ child.stderr?.on("data", (data) => {
162
+ if (stderrBytes >= MAX_TEAM_WORKTREE_STDERR_BYTES) return;
163
+ const chunk = Buffer.isBuffer(data)
164
+ ? data
165
+ : Buffer.from(String(data), "utf8");
166
+ const remaining = MAX_TEAM_WORKTREE_STDERR_BYTES - stderrBytes;
167
+ const retained = chunk.subarray(0, remaining);
168
+ if (retained.length > 0) {
169
+ stderrChunks.push(retained);
170
+ stderrBytes += retained.length;
171
+ }
64
172
  });
65
- let err = "";
66
- child.stderr?.on("data", (d) => (err += d.toString("utf8")));
67
- child.on("error", (e) => reject(new Error(e.message)));
68
- child.on("close", (code) =>
69
- code === 0
70
- ? resolve({ code })
71
- : reject(new Error(err.trim() || `command exited ${code}`)),
72
- );
173
+ child.once("error", (error) => {
174
+ if (!terminationError) {
175
+ finish(
176
+ new Error(
177
+ safeProcessError(
178
+ error?.message || error,
179
+ "command failed to start",
180
+ ),
181
+ ),
182
+ );
183
+ }
184
+ });
185
+ child.once("close", (code) => {
186
+ if (terminationError) {
187
+ finish(terminationError);
188
+ return;
189
+ }
190
+ if (code === 0) {
191
+ finish(null, { code });
192
+ return;
193
+ }
194
+ const stderr = Buffer.concat(stderrChunks, stderrBytes).toString("utf8");
195
+ finish(new Error(safeProcessError(stderr, `command exited ${code}`)));
196
+ });
197
+ if (signal) {
198
+ abortListener = terminate;
199
+ if (signal.aborted) terminate();
200
+ else signal.addEventListener?.("abort", abortListener, { once: true });
201
+ }
73
202
  });
74
203
  }
75
204
 
@@ -84,41 +213,298 @@ function runTeamGit(args, cwd) {
84
213
  });
85
214
  }
86
215
 
216
+ function resolveGitRef(cwd, ref = "HEAD") {
217
+ const value = _processDeps.execFileSync(
218
+ "git",
219
+ ["rev-parse", "--verify", ref],
220
+ {
221
+ cwd,
222
+ encoding: "utf8",
223
+ stdio: ["ignore", "pipe", "pipe"],
224
+ origin: "team-worktree:recovery",
225
+ policy: "allow",
226
+ scope: "team-worktree",
227
+ shell: false,
228
+ },
229
+ );
230
+ const oid = String(value || "").trim();
231
+ if (!/^[a-f0-9]{40,64}$/i.test(oid)) {
232
+ throw new Error(`invalid Git object id for ${ref}`);
233
+ }
234
+ return oid.toLowerCase();
235
+ }
236
+
237
+ function resolveGitBranch(cwd) {
238
+ try {
239
+ const value = _processDeps.execFileSync(
240
+ "git",
241
+ ["symbolic-ref", "--quiet", "--short", "HEAD"],
242
+ {
243
+ cwd,
244
+ encoding: "utf8",
245
+ stdio: ["ignore", "pipe", "pipe"],
246
+ origin: "team-worktree:recovery",
247
+ policy: "allow",
248
+ scope: "team-worktree",
249
+ shell: false,
250
+ },
251
+ );
252
+ const branch = String(value || "").trim();
253
+ if (!isGitSafeBranch(branch)) {
254
+ throw new Error("invalid current Git branch");
255
+ }
256
+ return branch;
257
+ } catch (error) {
258
+ if (error?.status === 1 || error?.code === 1) return null;
259
+ throw error;
260
+ }
261
+ }
262
+
263
+ function isGitAncestor(cwd, ancestor, descendant = "HEAD") {
264
+ try {
265
+ _processDeps.execFileSync(
266
+ "git",
267
+ ["merge-base", "--is-ancestor", ancestor, descendant],
268
+ {
269
+ cwd,
270
+ stdio: "ignore",
271
+ origin: "team-worktree:recovery",
272
+ policy: "allow",
273
+ scope: "team-worktree",
274
+ shell: false,
275
+ },
276
+ );
277
+ return true;
278
+ } catch (error) {
279
+ if (error?.status === 1 || error?.code === 1) return false;
280
+ throw error;
281
+ }
282
+ }
283
+
284
+ function isWorktreeClean(cwd, { includeIgnored = false } = {}) {
285
+ const args = [
286
+ "status",
287
+ "--porcelain=v1",
288
+ "--untracked-files=all",
289
+ "--ignore-submodules=none",
290
+ ];
291
+ if (includeIgnored) args.push("--ignored=matching");
292
+ const value = _processDeps.execFileSync("git", args, {
293
+ cwd,
294
+ encoding: "utf8",
295
+ stdio: ["ignore", "pipe", "pipe"],
296
+ origin: "team-worktree:cleanup",
297
+ policy: "allow",
298
+ scope: "team-worktree",
299
+ shell: false,
300
+ });
301
+ return String(value || "").trim() === "";
302
+ }
303
+
87
304
  /** Stage + commit everything in a worktree. Returns true if a commit was made. */
88
305
  function defaultCommit(worktreePath, message) {
89
306
  runTeamGit(["add", "-A"], worktreePath);
307
+
90
308
  try {
91
- runTeamGit(
92
- [
93
- "-c",
94
- "user.email=team@chainlesschain.local",
95
- "-c",
96
- "user.name=cc team",
97
- "commit",
98
- "-m",
99
- message,
100
- ],
101
- worktreePath,
102
- );
103
- return true;
104
- } catch {
105
- return false; // nothing to commit (no changes)
309
+ runTeamGit(["diff", "--cached", "--quiet", "--exit-code"], worktreePath);
310
+ return false;
311
+ } catch (error) {
312
+ // `git diff --quiet` uses status 1 exclusively to report a non-empty diff.
313
+ // Any other status is an actual Git failure and must not be hidden.
314
+ if (error?.status !== 1 && error?.code !== 1) throw error;
106
315
  }
316
+
317
+ // The staged diff was known to be non-empty. A commit failure here may be a
318
+ // hook, lock, identity, or repository problem, so always surface it.
319
+ runTeamGit(
320
+ [
321
+ "-c",
322
+ "user.email=team@chainlesschain.local",
323
+ "-c",
324
+ "user.name=cc team",
325
+ "commit",
326
+ "-m",
327
+ message,
328
+ ],
329
+ worktreePath,
330
+ );
331
+ return true;
107
332
  }
108
333
 
109
334
  export const _deps = {
110
335
  createWorktree: _createWorktree,
336
+ assertManagedWorktreePath: _assertManagedWorktreePath,
337
+ removeManagedDependencyLinks: _removeManagedDependencyLinks,
111
338
  removeWorktree: _removeWorktree,
112
339
  previewWorktreeMerge: _previewWorktreeMerge,
113
340
  mergeWorktree: _mergeWorktree,
114
341
  isGitRepo: _isGitRepo,
115
342
  runShell: defaultRunShell,
116
343
  commit: defaultCommit,
344
+ resolveGitRef,
345
+ resolveGitBranch,
346
+ isGitAncestor,
347
+ isWorktreeClean,
348
+ pathExists: existsSync,
117
349
  };
118
350
 
351
+ function normalizeRunId(value) {
352
+ if (value == null) return null;
353
+ if (typeof value !== "string" || value.trim() === "") {
354
+ throw new TypeError("team worktree runId must be a non-empty string");
355
+ }
356
+ if (Buffer.byteLength(value, "utf8") > MAX_RUN_ID_BYTES) {
357
+ throw new RangeError(
358
+ `team worktree runId exceeds ${MAX_RUN_ID_BYTES} bytes`,
359
+ );
360
+ }
361
+ return value;
362
+ }
363
+
364
+ function safeGitSegment(value, fallback) {
365
+ const raw = String(value);
366
+ const digest = createHash("sha256")
367
+ .update(raw, "utf8")
368
+ .digest("hex")
369
+ .slice(0, 16);
370
+ let slug = raw
371
+ .normalize("NFKC")
372
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
373
+ .replace(/\.{2,}/g, "-")
374
+ .replace(/^[.-]+|[.-]+$/g, "")
375
+ .slice(0, 48)
376
+ .replace(/[.-]+$/g, "");
377
+ if (!slug) slug = fallback;
378
+ return `${slug}-${digest}`;
379
+ }
380
+
381
+ function isGitSafeBranch(branch) {
382
+ if (
383
+ typeof branch !== "string" ||
384
+ branch.length === 0 ||
385
+ Buffer.byteLength(branch, "utf8") > MAX_RECORD_VALUE_BYTES ||
386
+ branch === "@" ||
387
+ branch.startsWith("/") ||
388
+ branch.endsWith("/") ||
389
+ branch.endsWith(".") ||
390
+ branch.includes("..") ||
391
+ branch.includes("@{") ||
392
+ branch.includes("//") ||
393
+ Array.from(branch).some((char) => {
394
+ const code = char.codePointAt(0);
395
+ return code <= 0x20 || code === 0x7f;
396
+ }) ||
397
+ /[~^:?*[\]\\]/u.test(branch)
398
+ ) {
399
+ return false;
400
+ }
401
+ return branch
402
+ .split("/")
403
+ .every(
404
+ (part) =>
405
+ part.length > 0 && !part.startsWith(".") && !part.endsWith(".lock"),
406
+ );
407
+ }
408
+
409
+ function requireRecordString(value, field) {
410
+ if (
411
+ typeof value !== "string" ||
412
+ value.length === 0 ||
413
+ Buffer.byteLength(value, "utf8") > MAX_RECORD_VALUE_BYTES ||
414
+ value.includes("\0")
415
+ ) {
416
+ throw new TypeError(`invalid team worktree record ${field}`);
417
+ }
418
+ return value;
419
+ }
420
+
421
+ function requireGitOid(value, field) {
422
+ if (typeof value !== "string" || !/^[a-f0-9]{40,64}$/i.test(value)) {
423
+ throw new TypeError(`invalid team worktree record ${field}`);
424
+ }
425
+ return value.toLowerCase();
426
+ }
427
+
428
+ function emptyIntegrationState() {
429
+ return {
430
+ previewed: false,
431
+ clean: null,
432
+ merged: false,
433
+ baseCommit: null,
434
+ mergeCommit: null,
435
+ };
436
+ }
437
+
438
+ function normalizeIntegrationState(value, key) {
439
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
440
+ throw new TypeError(`invalid team worktree integration state for "${key}"`);
441
+ }
442
+ if (
443
+ typeof value.previewed !== "boolean" ||
444
+ (value.clean !== null && typeof value.clean !== "boolean") ||
445
+ typeof value.merged !== "boolean" ||
446
+ (value.baseCommit !== null &&
447
+ !/^[a-f0-9]{40,64}$/i.test(value.baseCommit)) ||
448
+ (value.mergeCommit !== null &&
449
+ !/^[a-f0-9]{40,64}$/i.test(value.mergeCommit)) ||
450
+ (!value.previewed &&
451
+ (value.clean !== null ||
452
+ value.merged ||
453
+ value.baseCommit !== null ||
454
+ value.mergeCommit !== null)) ||
455
+ (value.previewed &&
456
+ (typeof value.clean !== "boolean" || value.baseCommit == null)) ||
457
+ (!value.merged && value.mergeCommit !== null) ||
458
+ (value.merged && (value.clean !== true || value.mergeCommit == null))
459
+ ) {
460
+ throw new TypeError(`invalid team worktree integration state for "${key}"`);
461
+ }
462
+ return {
463
+ previewed: value.previewed,
464
+ clean: value.clean,
465
+ merged: value.merged,
466
+ baseCommit: value.baseCommit?.toLowerCase() || null,
467
+ mergeCommit: value.mergeCommit?.toLowerCase() || null,
468
+ };
469
+ }
470
+
471
+ function normalizeBaseTarget(value) {
472
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
473
+ throw new TypeError("invalid team worktree base target");
474
+ }
475
+ const branch = value.branch;
476
+ if (branch !== null && !isGitSafeBranch(branch)) {
477
+ throw new TypeError("invalid team worktree base branch");
478
+ }
479
+ return {
480
+ branch,
481
+ commitOid: requireGitOid(value.commitOid, "baseTarget.commitOid"),
482
+ };
483
+ }
484
+
485
+ function normalizeManagedLinks(value, key) {
486
+ if (value == null) return [];
487
+ if (!Array.isArray(value)) {
488
+ throw new TypeError(`invalid team worktree managed links for "${key}"`);
489
+ }
490
+ const seen = new Set();
491
+ const links = [];
492
+ for (const item of value) {
493
+ const link = requireRecordString(item, "managedLinks");
494
+ if (seen.has(link)) {
495
+ throw new TypeError(`duplicate team worktree managed link for "${key}"`);
496
+ }
497
+ seen.add(link);
498
+ links.push(link);
499
+ }
500
+ return links;
501
+ }
502
+
119
503
  export class TeamWorktreeCoordinator {
120
504
  constructor(repoDir, options = {}) {
121
505
  this.repoDir = repoDir;
506
+ this.runId = normalizeRunId(options.runId);
507
+ this._baseTarget = null;
122
508
  this._created = new Map(); // key → { branch, path, committed }
123
509
  // Coordinator-wide sparse-checkout / dependency-symlink defaults; a per-task
124
510
  // value (task.sparsePaths / task.metadata.sparsePaths) overrides these.
@@ -128,14 +514,247 @@ export class TeamWorktreeCoordinator {
128
514
  };
129
515
  this._onWorktree =
130
516
  typeof options.onWorktree === "function" ? options.onWorktree : null;
517
+ const recovered =
518
+ options.snapshot ??
519
+ options.recover ??
520
+ options.seedRecords ??
521
+ options.seed ??
522
+ null;
523
+ if (recovered) {
524
+ if (Array.isArray(recovered)) this.seed(recovered);
525
+ else this.restore(recovered);
526
+ }
527
+ if (
528
+ this._baseTarget == null &&
529
+ (this._created.size > 0 || _deps.isGitRepo(this.repoDir))
530
+ ) {
531
+ this._captureBaseTarget();
532
+ }
131
533
  }
132
534
 
133
535
  isGitRepo() {
134
536
  return _deps.isGitRepo(this.repoDir);
135
537
  }
136
538
 
539
+ _captureBaseTarget() {
540
+ this._baseTarget = {
541
+ branch: _deps.resolveGitBranch(this.repoDir),
542
+ commitOid: requireGitOid(
543
+ _deps.resolveGitRef(this.repoDir, "HEAD"),
544
+ "baseTarget.commitOid",
545
+ ),
546
+ };
547
+ return { ...this._baseTarget };
548
+ }
549
+
550
+ _assertBaseTarget({ merge = false } = {}) {
551
+ if (this._baseTarget == null) this._captureBaseTarget();
552
+ const currentBranch = _deps.resolveGitBranch(this.repoDir);
553
+ const currentHead = requireGitOid(
554
+ _deps.resolveGitRef(this.repoDir, "HEAD"),
555
+ "baseTarget.currentHead",
556
+ );
557
+ if (currentBranch !== this._baseTarget.branch) {
558
+ throw requireAdjudication(
559
+ new Error(
560
+ `team worktree base target changed from ${
561
+ this._baseTarget.branch || "detached HEAD"
562
+ } to ${currentBranch || "detached HEAD"}`,
563
+ ),
564
+ "TEAM_WORKTREE_BASE_ADJUDICATION_REQUIRED",
565
+ );
566
+ }
567
+ if (currentBranch == null) {
568
+ if (merge) {
569
+ throw requireAdjudication(
570
+ new Error(
571
+ "refusing to merge team worktrees into a detached HEAD; check out the persisted base branch first",
572
+ ),
573
+ "TEAM_WORKTREE_BASE_ADJUDICATION_REQUIRED",
574
+ );
575
+ }
576
+ if (currentHead !== this._baseTarget.commitOid) {
577
+ throw requireAdjudication(
578
+ new Error(
579
+ "detached team worktree base HEAD moved after run creation",
580
+ ),
581
+ "TEAM_WORKTREE_BASE_ADJUDICATION_REQUIRED",
582
+ );
583
+ }
584
+ } else if (
585
+ currentHead !== this._baseTarget.commitOid &&
586
+ !_deps.isGitAncestor(
587
+ this.repoDir,
588
+ this._baseTarget.commitOid,
589
+ currentHead,
590
+ )
591
+ ) {
592
+ throw requireAdjudication(
593
+ new Error(
594
+ `team worktree base branch "${currentBranch}" no longer descends from its persisted start commit`,
595
+ ),
596
+ "TEAM_WORKTREE_BASE_ADJUDICATION_REQUIRED",
597
+ );
598
+ }
599
+ return { branch: currentBranch, currentHead };
600
+ }
601
+
137
602
  branchFor(key) {
138
- return `team/${key}`;
603
+ if (this.runId == null) return `team/${key}`;
604
+ requireRecordString(key, "key");
605
+ return `team/${safeGitSegment(this.runId, "run")}/${safeGitSegment(
606
+ key,
607
+ "task",
608
+ )}`;
609
+ }
610
+
611
+ /** Seed validated worktree records after recovering team state. */
612
+ seed(records) {
613
+ if (!Array.isArray(records)) {
614
+ throw new TypeError("team worktree seed must be an array");
615
+ }
616
+ const normalized = [];
617
+ const keys = new Set(this._created.keys());
618
+ for (const record of records) {
619
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
620
+ throw new TypeError("invalid team worktree record");
621
+ }
622
+ const key = requireRecordString(record.key, "key");
623
+ const branch = requireRecordString(record.branch, "branch");
624
+ const worktreePath = _deps.assertManagedWorktreePath(
625
+ this.repoDir,
626
+ requireRecordString(record.path, "path"),
627
+ { branchName: branch },
628
+ );
629
+ if (!isGitSafeBranch(branch)) {
630
+ throw new TypeError(`invalid team worktree record branch for "${key}"`);
631
+ }
632
+ if (branch !== this.branchFor(key)) {
633
+ throw new Error(`team worktree branch does not match key "${key}"`);
634
+ }
635
+ if (keys.has(key)) {
636
+ throw new Error(`duplicate team worktree record "${key}"`);
637
+ }
638
+ const committed = record.committed;
639
+ const completed = record.completed ?? true;
640
+ const cleanupPrepared = record.cleanupPrepared;
641
+ const cleaned = record.cleaned;
642
+ if (
643
+ typeof committed !== "boolean" ||
644
+ typeof completed !== "boolean" ||
645
+ typeof cleanupPrepared !== "boolean" ||
646
+ typeof cleaned !== "boolean"
647
+ ) {
648
+ throw new TypeError(
649
+ `invalid team worktree completion state for "${key}"`,
650
+ );
651
+ }
652
+ const commitOid = completed
653
+ ? requireGitOid(record.commitOid, "commitOid")
654
+ : record.commitOid == null
655
+ ? null
656
+ : requireGitOid(record.commitOid, "commitOid");
657
+ const integration = normalizeIntegrationState(record.integration, key);
658
+ const managedLinks = normalizeManagedLinks(record.managedLinks, key);
659
+ if (
660
+ !completed &&
661
+ (committed ||
662
+ commitOid !== null ||
663
+ integration.previewed ||
664
+ cleanupPrepared ||
665
+ cleaned)
666
+ ) {
667
+ throw new TypeError(
668
+ `invalid unfinished team worktree recovery state for "${key}"`,
669
+ );
670
+ }
671
+ if (
672
+ cleanupPrepared &&
673
+ (!integration.previewed || integration.clean !== true)
674
+ ) {
675
+ throw new TypeError(
676
+ `prepared team worktree "${key}" has no clean integration record`,
677
+ );
678
+ }
679
+ if (cleaned && !cleanupPrepared) {
680
+ throw new TypeError(
681
+ `cleaned team worktree "${key}" has no cleanup authorization`,
682
+ );
683
+ }
684
+ keys.add(key);
685
+ normalized.push({
686
+ key,
687
+ info: {
688
+ branch,
689
+ path: worktreePath,
690
+ committed,
691
+ completed,
692
+ commitOid,
693
+ integration,
694
+ managedLinks,
695
+ cleanupPrepared,
696
+ cleaned,
697
+ },
698
+ });
699
+ }
700
+ for (const { key, info } of normalized) this._created.set(key, info);
701
+ return this;
702
+ }
703
+
704
+ /** Return a detached, JSON-safe recovery snapshot. */
705
+ snapshot() {
706
+ return {
707
+ version: TEAM_WORKTREE_SNAPSHOT_VERSION,
708
+ runId: this.runId,
709
+ baseTarget: this._baseTarget ? { ...this._baseTarget } : null,
710
+ records: Array.from(this._created, ([key, info]) => ({
711
+ key,
712
+ branch: info.branch,
713
+ path: info.path,
714
+ committed: info.committed === true,
715
+ completed: info.completed === true,
716
+ commitOid: info.commitOid || null,
717
+ integration: { ...info.integration },
718
+ managedLinks: [...(info.managedLinks || [])],
719
+ cleanupPrepared: info.cleanupPrepared === true,
720
+ cleaned: info.cleaned === true,
721
+ })),
722
+ };
723
+ }
724
+
725
+ /** Restore a snapshot into an empty coordinator. */
726
+ restore(snapshot) {
727
+ if (this._created.size > 0) {
728
+ throw new Error(
729
+ "cannot restore team worktrees into a non-empty coordinator",
730
+ );
731
+ }
732
+ if (
733
+ !snapshot ||
734
+ typeof snapshot !== "object" ||
735
+ Array.isArray(snapshot) ||
736
+ snapshot.version !== TEAM_WORKTREE_SNAPSHOT_VERSION ||
737
+ !snapshot.baseTarget ||
738
+ !Array.isArray(snapshot.records)
739
+ ) {
740
+ throw new TypeError("invalid team worktree snapshot");
741
+ }
742
+ const snapshotRunId = normalizeRunId(snapshot.runId);
743
+ const previousRunId = this.runId;
744
+ const previousBaseTarget = this._baseTarget;
745
+ if (this.runId == null) {
746
+ this.runId = snapshotRunId;
747
+ } else if (snapshotRunId !== this.runId) {
748
+ throw new Error("team worktree snapshot runId mismatch");
749
+ }
750
+ this._baseTarget = normalizeBaseTarget(snapshot.baseTarget);
751
+ try {
752
+ return this.seed(snapshot.records);
753
+ } catch (error) {
754
+ this.runId = previousRunId;
755
+ this._baseTarget = previousBaseTarget;
756
+ throw error;
757
+ }
139
758
  }
140
759
 
141
760
  /**
@@ -147,7 +766,9 @@ export class TeamWorktreeCoordinator {
147
766
  * non-zero command / agent exit so retry/cancel still work.
148
767
  */
149
768
  makeRunTask({ runInWorktree = null } = {}) {
150
- return async ({ key, task, holder }) => {
769
+ return async (context) => {
770
+ const { key, task, holder } = context;
771
+ this._assertBaseTarget();
151
772
  const branch = this.branchFor(key);
152
773
  // A retry (the prior attempt failed and TaskLeaseRegistry re-queued the
153
774
  // task) would collide with its own leftover worktree: createWorktree
@@ -158,38 +779,192 @@ export class TeamWorktreeCoordinator {
158
779
  // so a retry starts clean.
159
780
  const prior = this._created.get(key);
160
781
  if (prior) {
782
+ if (prior.completed) {
783
+ throw requireAdjudication(
784
+ new Error(`task "${key}" already has completed worktree output`),
785
+ "TEAM_WORKTREE_RETRY_ADJUDICATION_REQUIRED",
786
+ );
787
+ }
788
+ const priorPath = _deps.assertManagedWorktreePath(
789
+ this.repoDir,
790
+ prior.path,
791
+ { branchName: prior.branch },
792
+ );
793
+ const priorBranchHead = _deps.resolveGitRef(this.repoDir, prior.branch);
794
+ const baseHead = _deps.resolveGitRef(this.repoDir, "HEAD");
795
+ if (priorBranchHead !== baseHead) {
796
+ throw requireAdjudication(
797
+ new Error(`task "${key}" has an unsettled worktree commit`),
798
+ "TEAM_WORKTREE_RETRY_ADJUDICATION_REQUIRED",
799
+ );
800
+ }
161
801
  try {
162
- _deps.removeWorktree(this.repoDir, prior.path, {
802
+ if (_deps.pathExists(priorPath)) {
803
+ if (_deps.resolveGitRef(priorPath, "HEAD") !== priorBranchHead) {
804
+ throw new Error("worktree HEAD moved");
805
+ }
806
+ _deps.removeManagedDependencyLinks(
807
+ this.repoDir,
808
+ priorPath,
809
+ prior.managedLinks || [],
810
+ );
811
+ if (!_deps.isWorktreeClean(priorPath, { includeIgnored: true })) {
812
+ throw new Error("worktree has unsettled or ignored files");
813
+ }
814
+ }
815
+ _deps.removeWorktree(this.repoDir, priorPath, {
163
816
  deleteBranch: true,
817
+ branchName: prior.branch,
818
+ expectedBranchOid: priorBranchHead,
819
+ force: false,
164
820
  });
165
- } catch {
166
- /* best-effort — a real problem will resurface in createWorktree */
821
+ } catch (error) {
822
+ throw requireAdjudication(
823
+ new Error(
824
+ `task "${key}" retry would discard unsettled worktree changes: ${
825
+ error instanceof Error ? error.message : String(error)
826
+ }`,
827
+ ),
828
+ "TEAM_WORKTREE_RETRY_ADJUDICATION_REQUIRED",
829
+ );
167
830
  }
168
831
  this._created.delete(key);
169
832
  }
170
833
  const wtOptions = resolveWorktreeOptions(task, this._worktreeDefaults);
171
- const { path: worktreePath } = _deps.createWorktree(
834
+ const created = _deps.createWorktree(
172
835
  this.repoDir,
173
836
  branch,
174
837
  null,
175
838
  wtOptions,
176
839
  );
177
- this._created.set(key, { branch, path: worktreePath, committed: false });
840
+ const createdPath = created.path;
841
+ const worktreePath = _deps.assertManagedWorktreePath(
842
+ this.repoDir,
843
+ createdPath,
844
+ { branchName: branch },
845
+ );
846
+ this._created.set(key, {
847
+ branch,
848
+ path: worktreePath,
849
+ committed: false,
850
+ completed: false,
851
+ commitOid: null,
852
+ integration: emptyIntegrationState(),
853
+ managedLinks: normalizeManagedLinks(
854
+ created.symlinkedDirectories || [],
855
+ key,
856
+ ),
857
+ cleanupPrepared: false,
858
+ cleaned: false,
859
+ });
178
860
  this._onWorktree?.({ key, branch, path: worktreePath, holder });
179
- if (typeof runInWorktree === "function") {
180
- await runInWorktree({ key, task, holder, cwd: worktreePath });
181
- } else {
182
- const command = task.metadata?.command || task?.command;
183
- if (!command) {
184
- throw new Error(
185
- `task "${key}" has no \`command\` to run in a worktree`,
861
+ if (typeof context.renew === "function") {
862
+ const fence = await context.renew();
863
+ if (!fence?.ok) {
864
+ throw requireAdjudication(
865
+ new Error(
866
+ `task "${key}" lost its lease while creating its worktree`,
867
+ ),
868
+ "TEAM_WORKTREE_CREATE_ADJUDICATION_REQUIRED",
186
869
  );
187
870
  }
188
- await _deps.runShell(command, worktreePath);
189
871
  }
190
- const committed = _deps.commit(worktreePath, `team task ${key}`);
191
- this._created.get(key).committed = committed;
192
- return { branch, committed };
872
+ if (
873
+ context.signal?.aborted ||
874
+ context.budget?.reason?.() === "max-wall-ms"
875
+ ) {
876
+ throw requireAdjudication(
877
+ new Error(`task "${key}" was cancelled before execution`),
878
+ "TEAM_WORKTREE_EXECUTION_CANCELLED",
879
+ );
880
+ }
881
+ let executionResult = null;
882
+ try {
883
+ if (typeof runInWorktree === "function") {
884
+ executionResult = await runInWorktree({
885
+ ...context,
886
+ cwd: worktreePath,
887
+ });
888
+ } else {
889
+ const command = task.metadata?.command || task?.command;
890
+ if (!command) {
891
+ throw new Error(
892
+ `task "${key}" has no \`command\` to run in a worktree`,
893
+ );
894
+ }
895
+ await _deps.runShell(command, worktreePath, {
896
+ signal: context.signal || null,
897
+ });
898
+ }
899
+ } catch (error) {
900
+ if (task.metadata?.retrySafe === true) throw error;
901
+ throw requireAdjudication(
902
+ error,
903
+ "TEAM_WORKTREE_EXECUTION_ADJUDICATION_REQUIRED",
904
+ );
905
+ }
906
+ if (context.signal?.aborted) {
907
+ throw requireAdjudication(
908
+ new Error(`task "${key}" was cancelled before commit`),
909
+ "TEAM_WORKTREE_COMMIT_CANCELLED",
910
+ );
911
+ }
912
+ let committed;
913
+ let commitOid;
914
+ try {
915
+ committed = _deps.commit(worktreePath, `team task ${key}`);
916
+ commitOid = _deps.resolveGitRef(worktreePath, "HEAD");
917
+ } catch (error) {
918
+ throw requireAdjudication(
919
+ error,
920
+ "TEAM_WORKTREE_COMMIT_ADJUDICATION_REQUIRED",
921
+ );
922
+ }
923
+ // Git commit/ref resolution are synchronous today. Yield once so a wall
924
+ // timer, fatal peer failure, or cancellation that became ready while Git
925
+ // held the event loop can fence this output before it is authorized.
926
+ await new Promise((resolveYield) => setImmediate(resolveYield));
927
+ if (context.signal?.aborted) {
928
+ throw requireAdjudication(
929
+ new Error(`task "${key}" was cancelled after commit`),
930
+ "TEAM_WORKTREE_POST_COMMIT_ADJUDICATION_REQUIRED",
931
+ );
932
+ }
933
+ if (typeof context.renew === "function") {
934
+ const fence = await context.renew();
935
+ if (!fence?.ok) {
936
+ throw requireAdjudication(
937
+ new Error(
938
+ `task "${key}" lost its lease after committing worktree output`,
939
+ ),
940
+ "TEAM_WORKTREE_POST_COMMIT_ADJUDICATION_REQUIRED",
941
+ );
942
+ }
943
+ }
944
+ if (context.budget?.reason?.() === "max-wall-ms") {
945
+ throw requireAdjudication(
946
+ new Error(
947
+ `task "${key}" exceeded the team wall-clock budget during commit`,
948
+ ),
949
+ "TEAM_WORKTREE_POST_COMMIT_ADJUDICATION_REQUIRED",
950
+ );
951
+ }
952
+ const record = this._created.get(key);
953
+ record.committed = committed;
954
+ record.completed = true;
955
+ record.commitOid = commitOid;
956
+ return {
957
+ branch,
958
+ committed,
959
+ ...(executionResult?.usage ? { usage: executionResult.usage } : {}),
960
+ ...(executionResult?.provider
961
+ ? { provider: executionResult.provider }
962
+ : {}),
963
+ ...(executionResult?.model ? { model: executionResult.model } : {}),
964
+ ...(executionResult?.usageRecords
965
+ ? { usageRecords: executionResult.usageRecords }
966
+ : {}),
967
+ };
193
968
  };
194
969
  }
195
970
 
@@ -202,9 +977,147 @@ export class TeamWorktreeCoordinator {
202
977
  * @returns {Array<{key,branch,committed,clean,merged,conflicts,error?}>}
203
978
  */
204
979
  integrate({ merge = false } = {}) {
980
+ this._assertBaseTarget({ merge });
205
981
  const results = [];
206
982
  for (const [key, info] of this._created) {
983
+ const target = this._assertBaseTarget({ merge });
984
+ if (info.completed === false) {
985
+ results.push({
986
+ key,
987
+ branch: info.branch,
988
+ committed: false,
989
+ clean: false,
990
+ merged: false,
991
+ conflicts: [],
992
+ note: "task did not complete",
993
+ });
994
+ continue;
995
+ }
996
+ if (info.integration.merged) {
997
+ try {
998
+ if (
999
+ !_deps.isGitAncestor(
1000
+ this.repoDir,
1001
+ info.commitOid,
1002
+ target.currentHead,
1003
+ )
1004
+ ) {
1005
+ throw new Error(
1006
+ `merged worktree commit for "${key}" is no longer in HEAD`,
1007
+ );
1008
+ }
1009
+ results.push({
1010
+ key,
1011
+ branch: info.branch,
1012
+ committed: info.committed,
1013
+ clean: true,
1014
+ merged: true,
1015
+ conflicts: [],
1016
+ recovered: true,
1017
+ });
1018
+ } catch (error) {
1019
+ results.push({
1020
+ key,
1021
+ branch: info.branch,
1022
+ committed: info.committed,
1023
+ clean: false,
1024
+ merged: false,
1025
+ conflicts: [],
1026
+ error: error instanceof Error ? error.message : String(error),
1027
+ });
1028
+ }
1029
+ continue;
1030
+ }
1031
+ const worktreeMissing = !_deps.pathExists(info.path);
1032
+ if (info.cleaned || (info.cleanupPrepared && worktreeMissing)) {
1033
+ try {
1034
+ const branchHead = _deps.resolveGitRef(this.repoDir, info.branch);
1035
+ if (branchHead !== info.commitOid) {
1036
+ throw new Error(
1037
+ `worktree branch "${info.branch}" moved after task settlement`,
1038
+ );
1039
+ }
1040
+ if (
1041
+ !info.integration.previewed ||
1042
+ info.integration.clean !== true ||
1043
+ (merge && info.committed) ||
1044
+ (info.committed &&
1045
+ info.integration.baseCommit !== target.currentHead)
1046
+ ) {
1047
+ throw new Error(
1048
+ `persisted merge preview for "${key}" is stale or incomplete`,
1049
+ );
1050
+ }
1051
+ results.push({
1052
+ key,
1053
+ branch: info.branch,
1054
+ committed: info.committed,
1055
+ clean: true,
1056
+ merged: false,
1057
+ conflicts: [],
1058
+ recovered: true,
1059
+ note: "reused persisted merge preview",
1060
+ });
1061
+ } catch (error) {
1062
+ results.push({
1063
+ key,
1064
+ branch: info.branch,
1065
+ committed: info.committed,
1066
+ clean: false,
1067
+ merged: false,
1068
+ conflicts: [],
1069
+ error: error instanceof Error ? error.message : String(error),
1070
+ });
1071
+ }
1072
+ continue;
1073
+ }
1074
+ let branchHead;
1075
+ let baseHead;
1076
+ try {
1077
+ branchHead = _deps.resolveGitRef(this.repoDir, info.branch);
1078
+ if (branchHead !== info.commitOid) {
1079
+ throw new Error(
1080
+ `worktree branch "${info.branch}" moved after task settlement`,
1081
+ );
1082
+ }
1083
+ if (worktreeMissing) {
1084
+ throw new Error(
1085
+ `worktree for "${key}" is missing before integration`,
1086
+ );
1087
+ }
1088
+ const worktreeHead = _deps.resolveGitRef(info.path, "HEAD");
1089
+ if (worktreeHead !== info.commitOid) {
1090
+ throw new Error(
1091
+ `worktree HEAD for "${key}" moved after task settlement`,
1092
+ );
1093
+ }
1094
+ if (!_deps.isWorktreeClean(info.path)) {
1095
+ throw new Error(
1096
+ `worktree "${key}" has unsettled tracked or untracked changes`,
1097
+ );
1098
+ }
1099
+ baseHead = target.currentHead;
1100
+ } catch (error) {
1101
+ results.push({
1102
+ key,
1103
+ branch: info.branch,
1104
+ committed: info.committed,
1105
+ clean: false,
1106
+ merged: false,
1107
+ conflicts: [],
1108
+ error: error instanceof Error ? error.message : String(error),
1109
+ });
1110
+ continue;
1111
+ }
207
1112
  if (!info.committed) {
1113
+ const recovered = info.integration.previewed === true;
1114
+ info.integration = {
1115
+ previewed: true,
1116
+ clean: true,
1117
+ merged: false,
1118
+ baseCommit: info.integration.baseCommit || baseHead,
1119
+ mergeCommit: null,
1120
+ };
208
1121
  results.push({
209
1122
  key,
210
1123
  branch: info.branch,
@@ -212,10 +1125,36 @@ export class TeamWorktreeCoordinator {
212
1125
  clean: true,
213
1126
  merged: false,
214
1127
  conflicts: [],
1128
+ recovered,
215
1129
  note: "no changes to merge",
216
1130
  });
217
1131
  continue;
218
1132
  }
1133
+ // A crash may happen after Git merged a branch but before the updated
1134
+ // integration record was persisted. Prove ancestry and settle it without
1135
+ // replaying the merge.
1136
+ if (
1137
+ merge &&
1138
+ _deps.isGitAncestor(this.repoDir, info.commitOid, target.currentHead)
1139
+ ) {
1140
+ info.integration = {
1141
+ previewed: true,
1142
+ clean: true,
1143
+ merged: true,
1144
+ baseCommit: info.integration.baseCommit || baseHead,
1145
+ mergeCommit: baseHead,
1146
+ };
1147
+ results.push({
1148
+ key,
1149
+ branch: info.branch,
1150
+ committed: info.committed,
1151
+ clean: true,
1152
+ merged: true,
1153
+ conflicts: [],
1154
+ recovered: true,
1155
+ });
1156
+ continue;
1157
+ }
219
1158
  let preview;
220
1159
  try {
221
1160
  preview = _deps.previewWorktreeMerge(this.repoDir, info.branch);
@@ -231,8 +1170,41 @@ export class TeamWorktreeCoordinator {
231
1170
  });
232
1171
  continue;
233
1172
  }
1173
+ try {
1174
+ const postPreviewTarget = this._assertBaseTarget({ merge });
1175
+ const postPreviewBranch = _deps.resolveGitRef(
1176
+ this.repoDir,
1177
+ info.branch,
1178
+ );
1179
+ if (
1180
+ postPreviewTarget.currentHead !== baseHead ||
1181
+ postPreviewBranch !== info.commitOid
1182
+ ) {
1183
+ throw new Error(
1184
+ `Git refs moved while previewing worktree "${key}"; retry integration`,
1185
+ );
1186
+ }
1187
+ } catch (error) {
1188
+ results.push({
1189
+ key,
1190
+ branch: info.branch,
1191
+ committed: true,
1192
+ clean: false,
1193
+ merged: false,
1194
+ conflicts: [],
1195
+ error: error instanceof Error ? error.message : String(error),
1196
+ });
1197
+ continue;
1198
+ }
234
1199
  const clean = preview?.success === true;
235
1200
  const conflicts = preview?.conflicts || [];
1201
+ info.integration = {
1202
+ previewed: true,
1203
+ clean,
1204
+ merged: false,
1205
+ baseCommit: baseHead,
1206
+ mergeCommit: null,
1207
+ };
236
1208
  let merged = false;
237
1209
  if (clean && merge) {
238
1210
  // mergeWorktree does NOT throw on a failed merge — it catches internally
@@ -244,15 +1216,46 @@ export class TeamWorktreeCoordinator {
244
1216
  // throw path — a malformed branch ref.)
245
1217
  let mergeResult;
246
1218
  try {
247
- mergeResult = _deps.mergeWorktree(this.repoDir, info.branch, {
1219
+ this._assertBaseTarget({ merge: true });
1220
+ mergeResult = _deps.mergeWorktree(this.repoDir, info.commitOid, {
248
1221
  message: `Merge team task ${key}`,
1222
+ deleteBranch: false,
249
1223
  });
250
1224
  } catch (err) {
251
1225
  mergeResult = { success: false, message: err.message };
252
1226
  }
253
1227
  if (mergeResult?.success === true) {
254
- merged = true;
1228
+ try {
1229
+ const mergedTarget = this._assertBaseTarget({ merge: true });
1230
+ const mergeCommit = mergedTarget.currentHead;
1231
+ if (
1232
+ !_deps.isGitAncestor(this.repoDir, info.commitOid, mergeCommit)
1233
+ ) {
1234
+ throw new Error(
1235
+ `merged worktree commit for "${key}" is not in HEAD`,
1236
+ );
1237
+ }
1238
+ merged = true;
1239
+ info.integration = {
1240
+ ...info.integration,
1241
+ merged: true,
1242
+ mergeCommit,
1243
+ };
1244
+ } catch (error) {
1245
+ info.integration.clean = false;
1246
+ results.push({
1247
+ key,
1248
+ branch: info.branch,
1249
+ committed: true,
1250
+ clean: false,
1251
+ merged: false,
1252
+ conflicts: [],
1253
+ error: error instanceof Error ? error.message : String(error),
1254
+ });
1255
+ continue;
1256
+ }
255
1257
  } else {
1258
+ info.integration.clean = false;
256
1259
  results.push({
257
1260
  key,
258
1261
  branch: info.branch,
@@ -279,18 +1282,124 @@ export class TeamWorktreeCoordinator {
279
1282
  return results;
280
1283
  }
281
1284
 
282
- /** Remove every worktree created for this run (branches left intact). */
1285
+ /**
1286
+ * Durable phase one of cleanup. The caller must persist the resulting
1287
+ * snapshot before deleting anything. A recovered `cleanupPrepared` record is
1288
+ * sufficient proof that a missing worktree may be finalized without rerunning
1289
+ * its task or replaying integration.
1290
+ */
1291
+ prepareCleanupAll({ requireMerged = false } = {}) {
1292
+ const results = [];
1293
+ for (const [key, info] of this._created) {
1294
+ if (info.cleaned || info.cleanupPrepared) {
1295
+ results.push({
1296
+ key,
1297
+ ok: true,
1298
+ alreadyPrepared: true,
1299
+ });
1300
+ continue;
1301
+ }
1302
+ if (!info.completed) {
1303
+ throw new Error(
1304
+ `refusing to prepare unfinished worktree "${key}" for cleanup`,
1305
+ );
1306
+ }
1307
+ if (!info.integration.previewed || info.integration.clean !== true) {
1308
+ throw new Error(
1309
+ `refusing to prepare worktree "${key}" before clean integration`,
1310
+ );
1311
+ }
1312
+ if (requireMerged && info.committed && !info.integration.merged) {
1313
+ throw new Error(
1314
+ `refusing to prepare unmerged worktree "${key}" for cleanup`,
1315
+ );
1316
+ }
1317
+ info.cleanupPrepared = true;
1318
+ results.push({ key, ok: true });
1319
+ }
1320
+ return results;
1321
+ }
1322
+
1323
+ /**
1324
+ * Remove every worktree created for this run (branches left intact).
1325
+ * Failed removals remain in the recovery snapshot and are returned to the
1326
+ * caller; they are never hidden by clearing the entire in-memory manifest.
1327
+ */
283
1328
  cleanupAll({ deleteBranch = false } = {}) {
284
- for (const [, info] of this._created) {
1329
+ const results = [];
1330
+ for (const [key, info] of Array.from(this._created)) {
1331
+ if (info.cleaned) {
1332
+ results.push({
1333
+ key,
1334
+ ok: true,
1335
+ path: info.path,
1336
+ alreadyCleaned: true,
1337
+ });
1338
+ continue;
1339
+ }
285
1340
  try {
286
- _deps.removeWorktree(this.repoDir, info.path, { deleteBranch });
287
- } catch {
288
- /* best-effort */
1341
+ if (!info.cleanupPrepared) {
1342
+ throw new Error(
1343
+ `refusing to clean worktree "${key}" before durable preparation`,
1344
+ );
1345
+ }
1346
+ if (deleteBranch && info.committed && !info.integration.merged) {
1347
+ throw new Error(
1348
+ `refusing to delete unmerged worktree branch "${key}"`,
1349
+ );
1350
+ }
1351
+ const branchHead = _deps.resolveGitRef(this.repoDir, info.branch);
1352
+ if (branchHead !== info.commitOid) {
1353
+ throw new Error(
1354
+ `refusing to clean worktree "${key}" after its branch moved`,
1355
+ );
1356
+ }
1357
+ const worktreePath = _deps.assertManagedWorktreePath(
1358
+ this.repoDir,
1359
+ info.path,
1360
+ { branchName: info.branch },
1361
+ );
1362
+ if (_deps.pathExists(worktreePath)) {
1363
+ const worktreeHead = _deps.resolveGitRef(worktreePath, "HEAD");
1364
+ if (worktreeHead !== info.commitOid) {
1365
+ throw new Error(
1366
+ `refusing to clean worktree "${key}" after its HEAD moved`,
1367
+ );
1368
+ }
1369
+ _deps.removeManagedDependencyLinks(
1370
+ this.repoDir,
1371
+ worktreePath,
1372
+ info.managedLinks || [],
1373
+ );
1374
+ if (!_deps.isWorktreeClean(worktreePath, { includeIgnored: true })) {
1375
+ throw new Error(
1376
+ `refusing to clean worktree "${key}" with unsettled or ignored files`,
1377
+ );
1378
+ }
1379
+ }
1380
+ _deps.removeWorktree(this.repoDir, worktreePath, {
1381
+ deleteBranch,
1382
+ branchName: info.branch,
1383
+ expectedBranchOid: info.commitOid,
1384
+ force: false,
1385
+ });
1386
+ info.cleaned = true;
1387
+ results.push({ key, ok: true, path: worktreePath });
1388
+ } catch (error) {
1389
+ results.push({
1390
+ key,
1391
+ ok: false,
1392
+ path: info.path,
1393
+ error: error instanceof Error ? error.message : String(error),
1394
+ });
289
1395
  }
290
1396
  }
1397
+ return results;
291
1398
  }
292
1399
 
293
1400
  branches() {
294
- return Array.from(this._created.values()).map((i) => i.branch);
1401
+ return Array.from(this._created.values())
1402
+ .filter((info) => !info.cleaned)
1403
+ .map((info) => info.branch);
295
1404
  }
296
1405
  }