@runfusion/fusion 0.77.0-beta.7 → 0.77.0-beta.8

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 (136) hide show
  1. package/dist/bin.js +548 -86
  2. package/dist/child-process-worker.js +546 -86
  3. package/dist/client/.vite/manifest.json +249 -249
  4. package/dist/client/assets/{AgentDetailView-C1eh_vXv.js → AgentDetailView-DsMRc-aH.js} +1 -1
  5. package/dist/client/assets/{AgentPermissionPolicyEditor-CeKsO1LR.js → AgentPermissionPolicyEditor-BXJ52YpO.js} +1 -1
  6. package/dist/client/assets/{AgentsView-BNe2ABOc.js → AgentsView-DO-25nlB.js} +3 -3
  7. package/dist/client/assets/{CommandCenter-BuREM-G1.js → CommandCenter-BMXMg_I2.js} +1 -1
  8. package/dist/client/assets/{DevServerView-D0i1b-QO.js → DevServerView-DAeL5TYz.js} +1 -1
  9. package/dist/client/assets/{DirectoryPicker-VOx91hQt.js → DirectoryPicker-Cw-XRjAa.js} +1 -1
  10. package/dist/client/assets/{DocumentsView-CO5W_WVb.js → DocumentsView-CusClN9u.js} +1 -1
  11. package/dist/client/assets/{EvalsView-BqSINSrZ.js → EvalsView-gNdeUDww.js} +1 -1
  12. package/dist/client/assets/{ExperimentalAgentOnboardingModal-DEcFgONi.js → ExperimentalAgentOnboardingModal-Bmzbqzhs.js} +1 -1
  13. package/dist/client/assets/{GoalsView-DZ57wXmJ.js → GoalsView-J8JMsoLn.js} +1 -1
  14. package/dist/client/assets/{InsightsView-DSWfXG7Q.js → InsightsView-BVbv56K0.js} +1 -1
  15. package/dist/client/assets/{MemoryView-BEX10uhl.js → MemoryView-DNvJFoDu.js} +1 -1
  16. package/dist/client/assets/{PiExtensionsManager-DcAmXXoR.js → PiExtensionsManager-BPMexS34.js} +1 -1
  17. package/dist/client/assets/{PluginManager-BU0omQUv.js → PluginManager-DGEv-ptu.js} +1 -1
  18. package/dist/client/assets/{PullRequestView-B-JL_8Kk.js → PullRequestView-CF7fx-Uo.js} +1 -1
  19. package/dist/client/assets/ReportModal-B8S5BFgn.js +21 -0
  20. package/dist/client/assets/ReportModal-Dx3f2aj7.css +1 -0
  21. package/dist/client/assets/{ResearchView-CnwTXfQO.js → ResearchView-CBeXsevy.js} +1 -1
  22. package/dist/client/assets/{SecretsView-BDvzsLFz.js → SecretsView-Dmi9wZkv.js} +1 -1
  23. package/dist/client/assets/{SettingsModal-801eFQ9u.js → SettingsModal-D2kXP7Um.js} +3 -3
  24. package/dist/client/assets/{SettingsModal-BZHeQ3ED.js → SettingsModal-DSW3xnGk.js} +1 -1
  25. package/dist/client/assets/{SettingsTextareaRow-B0rTuelr.js → SettingsTextareaRow-CLab4Vcd.js} +1 -1
  26. package/dist/client/assets/{SetupWizardModal-CoExPwIn.js → SetupWizardModal-Ct6juX9x.js} +2 -2
  27. package/dist/client/assets/{SkillsView-WVP25LPj.js → SkillsView-Dn0bwT6i.js} +1 -1
  28. package/dist/client/assets/{WorkflowNodeEditor-pCMyKcnS.js → WorkflowNodeEditor-cvReMlG-.js} +1 -1
  29. package/dist/client/assets/{agent-import-generation-M_hKtjgN.js → agent-import-generation-C8_ewVzC.js} +1 -1
  30. package/dist/client/assets/{agentsOrgChartLayout-DjvIMhFD.js → agentsOrgChartLayout-p1i6XROQ.js} +1 -1
  31. package/dist/client/assets/{architectureDiagram-3BPJPVTR-CkzEm8i9.js → architectureDiagram-3BPJPVTR-CzCk1QdH.js} +1 -1
  32. package/dist/client/assets/{blockDiagram-GPEHLZMM-Dc-dUMjY.js → blockDiagram-GPEHLZMM-74Rj-5ov.js} +1 -1
  33. package/dist/client/assets/{c4Diagram-AAUBKEIU-DnYjsrR0.js → c4Diagram-AAUBKEIU-B570qYH-.js} +1 -1
  34. package/dist/client/assets/channel-DWAK5y-J.js +1 -0
  35. package/dist/client/assets/{chunk-2J33WTMH-0E1GEFEr.js → chunk-2J33WTMH-DejJkxt6.js} +1 -1
  36. package/dist/client/assets/{chunk-4BX2VUAB-Dj5oZd51.js → chunk-4BX2VUAB-DlgxkBuE.js} +1 -1
  37. package/dist/client/assets/{chunk-55IACEB6-DfNT0s8E.js → chunk-55IACEB6-XCfE8PwC.js} +1 -1
  38. package/dist/client/assets/{chunk-727SXJPM-BnFjhiIQ.js → chunk-727SXJPM-D-nDw0Bi.js} +1 -1
  39. package/dist/client/assets/{chunk-AQP2D5EJ-DR84wSqR.js → chunk-AQP2D5EJ-C4KySao0.js} +1 -1
  40. package/dist/client/assets/{chunk-FMBD7UC4-IjByNOrR.js → chunk-FMBD7UC4-CXKdLygA.js} +1 -1
  41. package/dist/client/assets/{chunk-ND2GUHAM-By0q_jZm.js → chunk-ND2GUHAM-bNYWWGVJ.js} +1 -1
  42. package/dist/client/assets/{chunk-QZHKN3VN-BELcl3N5.js → chunk-QZHKN3VN-cfBQJ-MX.js} +1 -1
  43. package/dist/client/assets/classDiagram-4FO5ZUOK-WTkXRkfm.js +1 -0
  44. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-WTkXRkfm.js +1 -0
  45. package/dist/client/assets/{cose-bilkent-S5V4N54A-D4cCw9-_.js → cose-bilkent-S5V4N54A-CiZrg3mQ.js} +1 -1
  46. package/dist/client/assets/{dagre-BM42HDAG-JJp5dVeY.js → dagre-BM42HDAG-Bckph2U4.js} +1 -1
  47. package/dist/client/assets/{dashboard-view-Bl9CPhxE.js → dashboard-view-CN_fkDM1.js} +1 -1
  48. package/dist/client/assets/{dashboard-view-C5Nt8cvY.js → dashboard-view-CVYHQZwx.js} +1 -1
  49. package/dist/client/assets/{dashboard-view-6zPJm9zC.js → dashboard-view-DDRKwfFs.js} +1 -1
  50. package/dist/client/assets/{dashboard-view-MAC_LHDX.js → dashboard-view-IuPjDVGa.js} +1 -1
  51. package/dist/client/assets/{diagram-2AECGRRQ-D4L7j_t_.js → diagram-2AECGRRQ-Bnaurex7.js} +1 -1
  52. package/dist/client/assets/{diagram-5GNKFQAL-VAGFaXqG.js → diagram-5GNKFQAL-BKz1omVN.js} +1 -1
  53. package/dist/client/assets/{diagram-KO2AKTUF-hqXSggfW.js → diagram-KO2AKTUF-C51lEZwo.js} +1 -1
  54. package/dist/client/assets/{diagram-LMA3HP47-B3eAIuy8.js → diagram-LMA3HP47-BYm5gql7.js} +1 -1
  55. package/dist/client/assets/{diagram-OG6HWLK6-BGufp0RK.js → diagram-OG6HWLK6-B9CFA4ha.js} +1 -1
  56. package/dist/client/assets/{erDiagram-TEJ5UH35-DplXdx8w.js → erDiagram-TEJ5UH35-BLyCSssl.js} +1 -1
  57. package/dist/client/assets/{flowDiagram-I6XJVG4X-C1Ms2bko.js → flowDiagram-I6XJVG4X-yDVmpkro.js} +1 -1
  58. package/dist/client/assets/{folder-open-nMraSDXu.js → folder-open-Cf2NA9nV.js} +1 -1
  59. package/dist/client/assets/{ganttDiagram-6RSMTGT7-DVgxMtXb.js → ganttDiagram-6RSMTGT7-mC9YPFcx.js} +1 -1
  60. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DH2_n0J8.js → gitGraphDiagram-PVQCEYII-DS0bEccv.js} +1 -1
  61. package/dist/client/assets/index-BjM7LxzY.js +2805 -0
  62. package/dist/client/assets/index-DcKIzuMk.css +1 -0
  63. package/dist/client/assets/{infoDiagram-5YYISTIA-DmZdWVcU.js → infoDiagram-5YYISTIA-CuuNZITP.js} +1 -1
  64. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-DsEIHSXr.js → ishikawaDiagram-YF4QCWOH-MBSaC94m.js} +1 -1
  65. package/dist/client/assets/{journeyDiagram-JHISSGLW-CsfIWmu1.js → journeyDiagram-JHISSGLW-DkaAow9E.js} +1 -1
  66. package/dist/client/assets/{kanban-definition-UN3LZRKU-CQKqeZvc.js → kanban-definition-UN3LZRKU-DSf5OLtq.js} +1 -1
  67. package/dist/client/assets/{mermaid.core-DkC_dr4-.js → mermaid.core-HmTn21MF.js} +4 -4
  68. package/dist/client/assets/{mindmap-definition-RKZ34NQL-RnmE-NUe.js → mindmap-definition-RKZ34NQL-sv0Dl8aI.js} +1 -1
  69. package/dist/client/assets/{pieDiagram-4H26LBE5-DPu6FPc4.js → pieDiagram-4H26LBE5-BDK2IQNq.js} +1 -1
  70. package/dist/client/assets/{puzzle-DwhHOf32.js → puzzle-D00mP67o.js} +1 -1
  71. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-CYbypO2p.js → quadrantDiagram-W4KKPZXB-CiWAhwf1.js} +1 -1
  72. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DFk1vK_N.js → requirementDiagram-4Y6WPE33-DwC99D35.js} +1 -1
  73. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-CkUY1Zzu.js → sankeyDiagram-5OEKKPKP-CQn8z721.js} +1 -1
  74. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-CFivaLOO.js → sequenceDiagram-3UESZ5HK-B0CT8-vw.js} +1 -1
  75. package/dist/client/assets/{standing-instructions-template-BbqKGAC1.js → standing-instructions-template-BIBO_Prr.js} +1 -1
  76. package/dist/client/assets/{stateDiagram-AJRCARHV-BPeD5PwG.js → stateDiagram-AJRCARHV-BYAB_dFF.js} +1 -1
  77. package/dist/client/assets/{stateDiagram-v2-BHNVJYJU-DduiCPMb.js → stateDiagram-v2-BHNVJYJU-Cd19uJBk.js} +1 -1
  78. package/dist/client/assets/{timeline-definition-PNZ67QCA-CGUNNVro.js → timeline-definition-PNZ67QCA-DQLh8XkY.js} +1 -1
  79. package/dist/client/assets/{upload-BS96H1o7.js → upload-D4pkNOz2.js} +1 -1
  80. package/dist/client/assets/{users--irrWoyU.js → users-COgviS_S.js} +1 -1
  81. package/dist/client/assets/{vennDiagram-CIIHVFJN-CybTaJJH.js → vennDiagram-CIIHVFJN-BmwG1Wud.js} +1 -1
  82. package/dist/client/assets/{wardley-L42UT6IY-DSxaSCGi.js → wardley-L42UT6IY-BiO7jkmj.js} +1 -1
  83. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-YFb0AxNt.js → wardleyDiagram-YWT4CUSO-Dq0IPg5H.js} +1 -1
  84. package/dist/client/assets/{xychartDiagram-2RQKCTM6-Cv6zlMJC.js → xychartDiagram-2RQKCTM6-BlMOlSBc.js} +1 -1
  85. package/dist/client/index.html +13 -6
  86. package/dist/client/theme-data.css +85 -0
  87. package/dist/client/version.json +1 -1
  88. package/dist/droid-cli/package.json +1 -1
  89. package/dist/extension.js +548 -86
  90. package/dist/pi-claude-cli/package.json +1 -1
  91. package/dist/plugin-sdk/index.d.ts +5 -1
  92. package/dist/plugin-sdk/index.js +43 -19
  93. package/dist/plugins/fusion-plugin-acp-runtime/bundled.js +43 -19
  94. package/dist/plugins/fusion-plugin-acp-runtime/package.json +1 -1
  95. package/dist/plugins/fusion-plugin-claude-runtime/bundled.js +43 -19
  96. package/dist/plugins/fusion-plugin-claude-runtime/package.json +1 -1
  97. package/dist/plugins/fusion-plugin-cli-printing-press/bundled.js +43 -19
  98. package/dist/plugins/fusion-plugin-cli-printing-press/package.json +1 -1
  99. package/dist/plugins/fusion-plugin-compound-engineering/bundled.js +43 -19
  100. package/dist/plugins/fusion-plugin-compound-engineering/package.json +1 -1
  101. package/dist/plugins/fusion-plugin-cursor-runtime/bundled.js +43 -19
  102. package/dist/plugins/fusion-plugin-cursor-runtime/package.json +1 -1
  103. package/dist/plugins/fusion-plugin-dependency-graph/bundled.js +43 -19
  104. package/dist/plugins/fusion-plugin-dependency-graph/package.json +1 -1
  105. package/dist/plugins/fusion-plugin-droid-runtime/bundled.js +43 -19
  106. package/dist/plugins/fusion-plugin-droid-runtime/package.json +1 -1
  107. package/dist/plugins/fusion-plugin-grok-runtime/bundled.js +43 -19
  108. package/dist/plugins/fusion-plugin-grok-runtime/package.json +1 -1
  109. package/dist/plugins/fusion-plugin-hermes-runtime/bundled.js +43 -19
  110. package/dist/plugins/fusion-plugin-hermes-runtime/package.json +1 -1
  111. package/dist/plugins/fusion-plugin-linear-import/bundled.js +43 -19
  112. package/dist/plugins/fusion-plugin-linear-import/package.json +1 -1
  113. package/dist/plugins/fusion-plugin-omp-runtime/bundled.js +43 -19
  114. package/dist/plugins/fusion-plugin-omp-runtime/package.json +1 -1
  115. package/dist/plugins/fusion-plugin-openclaw-runtime/bundled.js +43 -19
  116. package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +1 -1
  117. package/dist/plugins/fusion-plugin-paperclip-runtime/bundled.js +43 -19
  118. package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +1 -1
  119. package/dist/plugins/fusion-plugin-quality/bundled.js +43 -19
  120. package/dist/plugins/fusion-plugin-quality/package.json +1 -1
  121. package/dist/plugins/fusion-plugin-reports/bundled.js +43 -19
  122. package/dist/plugins/fusion-plugin-reports/package.json +1 -1
  123. package/dist/plugins/fusion-plugin-roadmap/bundled.js +43 -19
  124. package/dist/plugins/fusion-plugin-roadmap/package.json +1 -1
  125. package/dist/plugins/fusion-plugin-todos/bundled.js +43 -19
  126. package/dist/plugins/fusion-plugin-todos/package.json +1 -1
  127. package/dist/plugins/fusion-plugin-whatsapp-chat/bundled.js +43 -19
  128. package/dist/plugins/fusion-plugin-whatsapp-chat/package.json +1 -1
  129. package/package.json +1 -1
  130. package/dist/client/assets/ReportModal-DxggF_RH.js +0 -21
  131. package/dist/client/assets/ReportModal-lzN_xrnM.css +0 -1
  132. package/dist/client/assets/channel-CxQ40s_U.js +0 -1
  133. package/dist/client/assets/classDiagram-4FO5ZUOK-C2ARdiRL.js +0 -1
  134. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C2ARdiRL.js +0 -1
  135. package/dist/client/assets/index-BJc-A7eB.css +0 -1
  136. package/dist/client/assets/index-CBFPgFTp.js +0 -2787
@@ -1061,6 +1061,8 @@ var init_execution_and_ui = __esm({
1061
1061
  "midnight",
1062
1062
  // FNXC:DashboardTheming 2026-08-18-05:45: Velvet is persisted immediately after Midnight; keep this ID and order synchronized across core, selector metadata, and both first-paint validators so saved selections survive pre-hydration validation.
1063
1063
  "velvet",
1064
+ // FNXC:DashboardTheming 2026-08-23-01:51: Iceberg is persisted immediately after Velvet; keep this ID and order synchronized across core, selector metadata, both first-paint validators, token blocks, and swatch CSS so saved selections survive pre-hydration validation.
1065
+ "iceberg",
1064
1066
  // FNXC:DashboardTheming 2026-07-31-20:39: Factory Dark must stay order-synchronized across core, selector metadata, and both first-paint validators so saved preferences survive pre-hydration validation.
1065
1067
  "factory-dark",
1066
1068
  // FNXC:DashboardTheming 2026-07-31-23:51: Factory Light must stay order-synchronized across core, selector metadata, and both first-paint validators so saved preferences survive pre-hydration validation.
@@ -1539,6 +1541,13 @@ function getTaskHardMergeBlocker(task, options = {}) {
1539
1541
  requiredPreMergeStepIds: options.requiredPreMergeStepIds
1540
1542
  });
1541
1543
  }
1544
+ function getMergeConfirmedFinalizationBlocker(task, options = {}) {
1545
+ const hasDurableMergeRecord = task.mergeDetails?.mergeConfirmed === true && typeof task.mergeDetails.commitSha === "string" && task.mergeDetails.commitSha.length > 0;
1546
+ return getTaskHardMergeBlocker(hasDurableMergeRecord ? { ...task, steps: [] } : task, options);
1547
+ }
1548
+ function getUnfinishedStepTitles(task) {
1549
+ return (task.steps ?? []).filter((step) => NON_TERMINAL_STEP_STATUSES.has(step.status)).map((step, index2) => step.name?.trim() || `step ${index2 + 1}`);
1550
+ }
1542
1551
  function isTaskReadyForMerge(task, options = {}) {
1543
1552
  return getTaskMergeBlocker(task, options) === void 0;
1544
1553
  }
@@ -2525,12 +2534,16 @@ var init_settings_schema = __esm({
2525
2534
  executorEscalationProvider: void 0,
2526
2535
  executorEscalationModelId: void 0,
2527
2536
  executorEscalationNodeId: void 0,
2528
- reviewConvergenceEscalationEnabled: true,
2529
- reviewConvergenceEscalationProvider: void 0,
2530
- reviewConvergenceEscalationModelId: void 0,
2531
- reviewArbitrationEnabled: true,
2532
- reviewArbitrationProvider: void 0,
2533
- reviewArbitrationModelId: void 0,
2537
+ /*
2538
+ FNXC:ReviewConvergence 2026-08-23-21:05:
2539
+ FN-149's six review-convergence/arbitration keys are declared in BUILTIN_WORKFLOW_SETTINGS, which
2540
+ the U4 hard-move made the SINGLE source of truth for a moved key's default. Re-declaring them here
2541
+ put them back in the legacy object the hard-move emptied, so an undeclared key on a custom workflow
2542
+ could resolve to this literal instead of the declaration default — the exact drift
2543
+ `workflow-settings-fallback-alignment` guards. The declaration defaults are identical (both
2544
+ `*Enabled` default true, the provider/model lanes carry no default), and the reader uses
2545
+ `!== false`, so removing them here preserves behaviour and restores the invariant.
2546
+ */
2534
2547
  /**
2535
2548
  * FNXC:Merge 2026-06-26-00:00:
2536
2549
  * New and unconfigured projects default AI merge to sync a dirty checked-out integration branch, restoring the legacy stash → fast-forward → restore landing behavior. Explicit persisted merger.allowDirtyLocalCheckoutSync values still win, and no existing-project migration stamps this default into storage.
@@ -10691,6 +10704,35 @@ var init_workflow_ir_types = __esm({
10691
10704
  }
10692
10705
  });
10693
10706
 
10707
+ // ../core/src/workflows/workflow-merge-region.ts
10708
+ function isMergeRegionNode(node) {
10709
+ return MERGE_REGION_ENTRY_NODE_KINDS.has(node.kind) || node.config?.seam === "merge";
10710
+ }
10711
+ function classifyWorkflowNodeMergeRegion(ir2, nodeId) {
10712
+ if (ir2.version !== "v2" || !Array.isArray(ir2.nodes)) return "unknown";
10713
+ const node = ir2.nodes.find((candidate) => candidate.id === nodeId);
10714
+ if (!node) return "unknown";
10715
+ return isMergeRegionNode(node) ? "merge-region" : "outside-merge-region";
10716
+ }
10717
+ var MERGE_REGION_ENTRY_NODE_KINDS;
10718
+ var init_workflow_merge_region = __esm({
10719
+ "../core/src/workflows/workflow-merge-region.ts"() {
10720
+ "use strict";
10721
+ MERGE_REGION_ENTRY_NODE_KINDS = /* @__PURE__ */ new Set([
10722
+ "merge-gate",
10723
+ "merge-attempt",
10724
+ "manual-merge-hold",
10725
+ "retry-backoff",
10726
+ "recovery-router",
10727
+ "branch-group-member-integration",
10728
+ "branch-group-promotion",
10729
+ "pr-create",
10730
+ "pr-respond",
10731
+ "pr-merge"
10732
+ ]);
10733
+ }
10734
+ });
10735
+
10694
10736
  // ../core/src/workflows/workflow-extension-types.ts
10695
10737
  function workflowExtensionRegistryId(pluginId, extensionId) {
10696
10738
  return `plugin:${pluginId}:${extensionId}`;
@@ -12430,6 +12472,7 @@ var init_workflow_ir = __esm({
12430
12472
  "../core/src/workflows/workflow-ir.ts"() {
12431
12473
  "use strict";
12432
12474
  init_workflow_ir_types();
12475
+ init_workflow_merge_region();
12433
12476
  init_workflow_extension_registry();
12434
12477
  init_types();
12435
12478
  init_trait_registry();
@@ -12510,18 +12553,7 @@ var init_workflow_ir = __esm({
12510
12553
  "done",
12511
12554
  "archived"
12512
12555
  ];
12513
- INTERPRETER_ENTRY_NODE_KINDS = /* @__PURE__ */ new Set([
12514
- "merge-gate",
12515
- "merge-attempt",
12516
- "manual-merge-hold",
12517
- "retry-backoff",
12518
- "recovery-router",
12519
- "branch-group-member-integration",
12520
- "branch-group-promotion",
12521
- "pr-create",
12522
- "pr-respond",
12523
- "pr-merge"
12524
- ]);
12556
+ INTERPRETER_ENTRY_NODE_KINDS = MERGE_REGION_ENTRY_NODE_KINDS;
12525
12557
  MERGE_CLASS_NODE_KINDS = /* @__PURE__ */ new Set([
12526
12558
  "merge-gate",
12527
12559
  "merge-attempt",
@@ -13927,6 +13959,13 @@ Lint, tests, and typecheck are also hard quality gates:
13927
13959
  - Keep fixing failures caused by your change until lint, impacted tests, build, and typecheck pass.
13928
13960
  - If the repository exposes a typecheck command, run it and fix failures caused by your change.
13929
13961
  - When tests fail, classify whether the failure is caused by your change, a pre-existing defect, an unrelated flaky test, or an outdated test expectation.
13962
+
13963
+ **A behavior change owns every test that asserts the old behavior.** Targeted verification runs the tests for files you TOUCHED; the tests encoding the behavior you just changed usually live in files you did NOT touch, so a green targeted run is not evidence that none exist. Before finishing, search for them:
13964
+ - Added a guard, validation, or required field? Grep for the FIXTURES it will now refuse \u2014 they are outside your File Scope \u2014 and fix each to state its intent explicitly.
13965
+ - Removed a feature? Delete its tests. A test asserting a deliberately removed contract guards nothing, and making it pass later means re-adding removed behavior.
13966
+ - Changed an order, default, constant, or prompt string? Grep for the literal; topology lists, prompt-content assertions, and size budgets live far from the code they describe.
13967
+ - Added a public store/service method? Update the inventory/drift guards that require every public surface to be classified.
13968
+ Fix at the SHARED FIXTURE FACTORY, not per test \u2014 one helper usually explains dozens of failures. Never make a stale test pass by weakening it: update the fixture, record the new truth, or delete the test naming the change that removed its subject.
13930
13969
  - If broad workspace verification fails on unrelated or pre-existing failures after impacted checks pass, do NOT expand this task by fixing unrelated areas. Log the evidence, quarantine flakes per project policy, or create/link a follow-up task.
13931
13970
  - Do not repeatedly rerun a broad failing or hanging workspace command without a new hypothesis and a narrower confirming command.
13932
13971
 
@@ -14199,6 +14238,7 @@ files with assertions that run via a test runner. Typechecks and builds are NOT
14199
14238
  tests. Manual verification is NOT a test.
14200
14239
 
14201
14240
  - Each implementation step should include writing tests for the code being changed
14241
+ - **If the task CHANGES, GATES, or REMOVES existing behavior, include a step to find and update the tests that assert the OLD behavior.** Name the search in that step: the fixtures a new guard will now refuse, the tests of a removed feature, or the literal to grep for a changed order/default/constant/prompt string. Those tests live OUTSIDE the File Scope, so targeted verification will not surface them and the executor will not find them by accident. Measured 2026-08-24: five such changes left ~135 stale failing tests behind, every one having passed its own targeted verification; one shared fixture line accounted for 37 of them.
14202
14242
  - For bug fixes and UI-affordance add/remove tasks, the spec MUST include a \`## Surface Enumeration\` section. The workflow Plan Review gate validates this before execution when plan review is enabled; missing coverage is a blocking REVISE.
14203
14243
  - For bug fixes and UI-affordance add/remove tasks, populate \`## Surface Enumeration\` with this checklist from \`docs/testing.md\`: providers/bridges/execution paths; desktop + mobile breakpoints/platforms; empty/undefined/duplicate/populated data states; shared hooks/components/modules/helpers; every component that renders the affordance; leftover shells after removal.
14204
14244
  - For bug fixes and UI-affordance add/remove tasks, regression tests must assert the invariant across all known surfaces \u2014 enumerate every provider/bridge, desktop + mobile breakpoints, empty/undefined/populated data states, and for UI-affordance changes every component rendering the affordance plus leftover shells after removal \u2014 not just the reported repro (see FN-5787/FN-5789/FN-5803, FN-5751, and FN-6115/FN-6118/FN-6123)
@@ -24523,7 +24563,16 @@ var init_project = __esm({
24523
24563
  "chat_rooms",
24524
24564
  "chat_room_members",
24525
24565
  "chat_room_messages",
24526
- "chat_token_usage"
24566
+ "chat_token_usage",
24567
+ /*
24568
+ FNXC:WorkspaceCoordination 2026-08-23-20:05:
24569
+ FN-9059 (migration 0060) added these two tables to the schema without registering them here, so
24570
+ every registry consumer under-counted them: the PostgreSQL test harness never TRUNCATEd them
24571
+ between tests (a held `repo:<repo>` lease leaked from one case into the next and made the next
24572
+ land report the previous test's successor as the busy holder), and health compaction skipped them.
24573
+ */
24574
+ "workspace_coordination_leases",
24575
+ "workspace_land_intents"
24527
24576
  ];
24528
24577
  }
24529
24578
  });
@@ -28653,10 +28702,6 @@ function resolveRequiredPreMergeStepIds(ir2, enabledWorkflowSteps) {
28653
28702
  resolveWorkflowOptionalSteps(ir2).filter((step) => step.phase === "pre-merge").filter((step) => isWorkflowOptionalGroupEnabled(enabledWorkflowSteps, step.templateId, step.defaultOn)).map((step) => step.templateId)
28654
28703
  );
28655
28704
  }
28656
- function findUnrunRequiredPreMergeStepIds(ir2, task) {
28657
- const results = task.workflowStepResults ?? [];
28658
- return [...resolveRequiredPreMergeStepIds(ir2, task.enabledWorkflowSteps)].filter((workflowStepId) => !results.some((result) => result.workflowStepId === workflowStepId));
28659
- }
28660
28705
  var init_required_pre_merge_steps = __esm({
28661
28706
  "../core/src/merge/required-pre-merge-steps.ts"() {
28662
28707
  "use strict";
@@ -28664,6 +28709,42 @@ var init_required_pre_merge_steps = __esm({
28664
28709
  }
28665
28710
  });
28666
28711
 
28712
+ // ../core/src/merge/merge-sweep-admission.ts
28713
+ function classifyMergeSweepAdmission(input) {
28714
+ if (input.hasLiveSession) return { admit: false, reason: "live-session" };
28715
+ if (input.mergeConfirmed) return { admit: true, reason: "merge-confirmed-finalization" };
28716
+ if (!input.gatesSatisfied) return { admit: false, reason: "gates-unsatisfied" };
28717
+ if (!input.continuationsReadable) return { admit: false, reason: "continuations-unreadable" };
28718
+ if (input.irTrust === "unresolved-selection") {
28719
+ return quiescentStallDecision(input, "unresolved-workflow-ir");
28720
+ }
28721
+ if (input.continuationPositions.length > 0) {
28722
+ if (input.continuationPositions.includes("merge-region")) {
28723
+ return { admit: true, reason: "at-merge-region-node" };
28724
+ }
28725
+ if (input.continuationPositions.includes("unknown")) {
28726
+ return { admit: true, reason: "drifted-continuation-node" };
28727
+ }
28728
+ return { admit: false, reason: "not-at-merge-region-node" };
28729
+ }
28730
+ if (input.interruptedMergeAttempt) {
28731
+ return { admit: true, reason: "interrupted-merge-attempt" };
28732
+ }
28733
+ return quiescentStallDecision(input, "quiescent-stall-recovery");
28734
+ }
28735
+ function quiescentStallDecision(input, reason) {
28736
+ const floorMs = input.quiescenceFloorMs ?? DEFAULT_MERGE_SWEEP_QUIESCENCE_MS;
28737
+ if (!(input.quiescentMs >= floorMs)) return { admit: false, reason: "too-recent" };
28738
+ return { admit: true, reason };
28739
+ }
28740
+ var DEFAULT_MERGE_SWEEP_QUIESCENCE_MS;
28741
+ var init_merge_sweep_admission = __esm({
28742
+ "../core/src/merge/merge-sweep-admission.ts"() {
28743
+ "use strict";
28744
+ DEFAULT_MERGE_SWEEP_QUIESCENCE_MS = 2 * 6e4;
28745
+ }
28746
+ });
28747
+
28667
28748
  // ../core/src/config/moved-settings.ts
28668
28749
  function isMovedSettingsKey(key) {
28669
28750
  return MOVED_SETTINGS_KEY_SET.has(key);
@@ -71674,6 +71755,21 @@ async function listWorkflowWorkItemsForTaskImpl(store, taskId, opts = {}) {
71674
71755
  const rows = opts.kinds?.length ? await layer.db.select().from(project_exports.workflowWorkItems).where(and(scope, eq(project_exports.workflowWorkItems.taskId, taskId), inArray(project_exports.workflowWorkItems.kind, opts.kinds))) : await q2;
71675
71756
  return rows.map((row) => store.rowToWorkflowWorkItem(row));
71676
71757
  }
71758
+ async function listWorkflowWorkItemsForTasksImpl(store, taskIds, opts = {}) {
71759
+ const byTask = /* @__PURE__ */ new Map();
71760
+ for (const taskId of taskIds) byTask.set(taskId, []);
71761
+ if (taskIds.length === 0) return byTask;
71762
+ const layer = store.asyncLayer;
71763
+ const scope = projectScopeFor(project_exports.workflowWorkItems.projectId, layer.projectId);
71764
+ const conditions = [scope, inArray(project_exports.workflowWorkItems.taskId, [...taskIds])];
71765
+ if (opts.kinds?.length) conditions.push(inArray(project_exports.workflowWorkItems.kind, opts.kinds));
71766
+ const rows = await layer.db.select().from(project_exports.workflowWorkItems).where(and(...conditions));
71767
+ for (const row of rows) {
71768
+ const item = store.rowToWorkflowWorkItem(row);
71769
+ byTask.get(item.taskId)?.push(item);
71770
+ }
71771
+ return byTask;
71772
+ }
71677
71773
  async function listDueWorkflowWorkItemsImpl(store, filter = {}) {
71678
71774
  const layer = store.asyncLayer;
71679
71775
  return listDueWorkflowWorkItems(layer.db, filter);
@@ -90992,6 +91088,17 @@ async function getMergeRequestRecordAsyncImpl(store, taskId) {
90992
91088
  const row = rows[0];
90993
91089
  return row ? store.rowToMergeRequestRecord(row) : null;
90994
91090
  }
91091
+ async function getMergeRequestRecordsAsyncImpl(store, taskIds) {
91092
+ const byTask = /* @__PURE__ */ new Map();
91093
+ if (taskIds.length === 0) return byTask;
91094
+ const layer = store.asyncLayer;
91095
+ const rows = await layer.db.select().from(project_exports.mergeRequests).where(inArray(project_exports.mergeRequests.taskId, [...taskIds]));
91096
+ for (const row of rows) {
91097
+ const record2 = store.rowToMergeRequestRecord(row);
91098
+ byTask.set(record2.taskId, record2);
91099
+ }
91100
+ return byTask;
91101
+ }
90995
91102
  function getWorkflowWorkItemByIdentityImpl(store, runId, taskId, nodeId, kind) {
90996
91103
  const row = store.db.prepare("SELECT * FROM workflow_work_items WHERE runId = ? AND taskId = ? AND nodeId = ? AND kind = ?").get(runId, taskId, nodeId, kind);
90997
91104
  return row ? store.rowToWorkflowWorkItem(row) : null;
@@ -106951,6 +107058,10 @@ var init_store = __esm({
106951
107058
  async getMergeRequestRecordAsync(taskId) {
106952
107059
  return getMergeRequestRecordAsyncImpl(this, taskId);
106953
107060
  }
107061
+ /** Batched `getMergeRequestRecordAsync` — one query for many tasks (FNXC:MergeAuthority 2026-08-23-20:05). */
107062
+ async getMergeRequestRecordsAsync(taskIds) {
107063
+ return getMergeRequestRecordsAsyncImpl(this, taskIds);
107064
+ }
106954
107065
  async projectMergeRequestToWorkflowWorkItem(taskId, opts = {}) {
106955
107066
  return projectMergeRequestToWorkflowWorkItemImpl(this, taskId, opts);
106956
107067
  }
@@ -106993,6 +107104,10 @@ var init_store = __esm({
106993
107104
  async listWorkflowWorkItemsForTask(taskId, opts = {}) {
106994
107105
  return listWorkflowWorkItemsForTaskImpl(this, taskId, opts);
106995
107106
  }
107107
+ /** Batched `listWorkflowWorkItemsForTask` — one query for many tasks (FNXC:MergeAuthority 2026-08-23-20:05). */
107108
+ async listWorkflowWorkItemsForTasks(taskIds, opts = {}) {
107109
+ return listWorkflowWorkItemsForTasksImpl(this, taskIds, opts);
107110
+ }
106996
107111
  /**
106997
107112
  * FNXC:RuntimeWorkflowAsync 2026-06-24-17:12:
106998
107113
  */
@@ -136705,6 +136820,7 @@ __export(src_exports, {
136705
136820
  DEFAULT_MAX_WORKTREES: () => DEFAULT_MAX_WORKTREES,
136706
136821
  DEFAULT_MEMORY_BACKEND: () => DEFAULT_MEMORY_BACKEND,
136707
136822
  DEFAULT_MEMORY_DREAMS_SCHEDULE: () => DEFAULT_MEMORY_DREAMS_SCHEDULE,
136823
+ DEFAULT_MERGE_SWEEP_QUIESCENCE_MS: () => DEFAULT_MERGE_SWEEP_QUIESCENCE_MS,
136708
136824
  DEFAULT_MIN_INTERVAL_MS: () => DEFAULT_MIN_INTERVAL_MS,
136709
136825
  DEFAULT_MISSION_ASSERTION_TYPE: () => DEFAULT_MISSION_ASSERTION_TYPE,
136710
136826
  DEFAULT_MOBILE_NAV_PRIMARY_ITEMS: () => DEFAULT_MOBILE_NAV_PRIMARY_ITEMS,
@@ -136871,6 +136987,7 @@ __export(src_exports, {
136871
136987
  MERGE_AUDIT_AUTO_RECOVERY_MODES: () => MERGE_AUDIT_AUTO_RECOVERY_MODES,
136872
136988
  MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT: () => MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT,
136873
136989
  MERGE_CONFIRMED_TRANSIENT_STATUSES: () => MERGE_CONFIRMED_TRANSIENT_STATUSES,
136990
+ MERGE_REGION_ENTRY_NODE_KINDS: () => MERGE_REGION_ENTRY_NODE_KINDS,
136874
136991
  MESH_INTENT_STATES: () => MESH_INTENT_STATES,
136875
136992
  MESH_RECONCILIATION_OUTCOMES: () => MESH_RECONCILIATION_OUTCOMES,
136876
136993
  MESH_WRITE_CLASSES: () => MESH_WRITE_CLASSES,
@@ -137239,6 +137356,7 @@ __export(src_exports, {
137239
137356
  classifyFileScopeFromPrompt: () => classifyFileScopeFromPrompt,
137240
137357
  classifyGhError: () => classifyGhError,
137241
137358
  classifyInsightRunError: () => classifyInsightRunError,
137359
+ classifyMergeSweepAdmission: () => classifyMergeSweepAdmission,
137242
137360
  classifyPlannerActionSideEffect: () => classifyPlannerActionSideEffect,
137243
137361
  classifyProviderError: () => classifyProviderError,
137244
137362
  classifyReadStaleness: () => classifyReadStaleness,
@@ -137249,6 +137367,7 @@ __export(src_exports, {
137249
137367
  classifyTaskCommitAssociationConfidence: () => classifyTaskCommitAssociationConfidence,
137250
137368
  classifyTerminalFailureAutoRecovery: () => classifyTerminalFailureAutoRecovery,
137251
137369
  classifyWorkflowAgentNode: () => classifyWorkflowAgentNode,
137370
+ classifyWorkflowNodeMergeRegion: () => classifyWorkflowNodeMergeRegion,
137252
137371
  cleanupOldAiSessions: () => cleanupOldAiSessions,
137253
137372
  cleanupStaleAiSessions: () => cleanupStaleAiSessions,
137254
137373
  clearActiveEmbeddedRuntimeUrl: () => clearActiveEmbeddedRuntimeUrl,
@@ -137448,7 +137567,6 @@ __export(src_exports, {
137448
137567
  findOrphanedSettingValues: () => findOrphanedSettingValues,
137449
137568
  findPendingPreMergeStep: () => findPendingPreMergeStep,
137450
137569
  findSameAgentDuplicates: () => findSameAgentDuplicates,
137451
- findUnrunRequiredPreMergeStepIds: () => findUnrunRequiredPreMergeStepIds,
137452
137570
  findVitestProcessIds: () => findVitestProcessIds,
137453
137571
  findWorkflowColumn: () => findWorkflowColumn,
137454
137572
  findWorkflowEventShapeViolations: () => findWorkflowEventShapeViolations,
@@ -137520,6 +137638,7 @@ __export(src_exports, {
137520
137638
  getMcpDiscoverySources: () => getMcpDiscoverySources,
137521
137639
  getMemoryBackend: () => getMemoryBackend,
137522
137640
  getMemoryBackendCapabilities: () => getMemoryBackendCapabilities,
137641
+ getMergeConfirmedFinalizationBlocker: () => getMergeConfirmedFinalizationBlocker,
137523
137642
  getOpenIncidentByGroupingKeyAsync: () => getOpenIncidentByGroupingKeyAsync,
137524
137643
  getOverriddenKeys: () => getOverriddenKeys,
137525
137644
  getPiExtensionDiscoveryDirs: () => getPiExtensionDiscoveryDirs,
@@ -137558,6 +137677,7 @@ __export(src_exports, {
137558
137677
  getTotalAgentActiveMs: () => getTotalAgentActiveMs,
137559
137678
  getTrait: () => getTrait,
137560
137679
  getTraitRegistry: () => getTraitRegistry,
137680
+ getUnfinishedStepTitles: () => getUnfinishedStepTitles,
137561
137681
  getValidTransitions: () => getValidTransitions,
137562
137682
  getWorkflowEventBus: () => getWorkflowEventBus,
137563
137683
  getWorkflowExtensionRegistry: () => getWorkflowExtensionRegistry,
@@ -137641,6 +137761,7 @@ __export(src_exports, {
137641
137761
  isManualTrigger: () => isManualTrigger,
137642
137762
  isMcpSecretRef: () => isMcpSecretRef,
137643
137763
  isMechanicalRoutableContext: () => isMechanicalRoutableContext,
137764
+ isMergeRegionNode: () => isMergeRegionNode,
137644
137765
  isMergeRequestContractShadowEnabled: () => isMergeRequestContractShadowEnabled,
137645
137766
  isMeshIntentState: () => isMeshIntentState,
137646
137767
  isMeshWriteClass: () => isMeshWriteClass,
@@ -138344,6 +138465,8 @@ var init_src2 = __esm({
138344
138465
  init_workflow_merge_proof();
138345
138466
  init_workflow_optional_steps();
138346
138467
  init_required_pre_merge_steps();
138468
+ init_merge_sweep_admission();
138469
+ init_workflow_merge_region();
138347
138470
  init_workflow_prompt_overrides();
138348
138471
  init_builtin_stepwise_coding_workflow_ir();
138349
138472
  init_builtin_stepwise_final_review_coding_workflow_ir();
@@ -143609,39 +143732,42 @@ var init_worktree_names = __esm({
143609
143732
  });
143610
143733
 
143611
143734
  // ../engine/src/util/emit-bounded-run-audit.ts
143612
- async function emitBoundedRunAudit2(host, event, options = {}) {
143735
+ function emitBoundedRunAudit2(host, event, options = {}) {
143736
+ return emitBoundedRunAuditWithOutcome2(host, event, options).then(() => void 0);
143737
+ }
143738
+ async function emitBoundedRunAuditWithOutcome2(host, event, options = {}) {
143613
143739
  let defaultLog2;
143614
143740
  try {
143615
143741
  defaultLog2 = runAuditLog;
143616
143742
  } catch {
143617
143743
  defaultLog2 = void 0;
143618
143744
  }
143619
- const log42 = options.log ?? defaultLog2 ?? { warn: (message) => console.warn(message) };
143745
+ const log42 = options.log ?? defaultLog2 ?? { warn: () => void 0 };
143620
143746
  const sink = host?.recordRunAuditEvent;
143621
- if (typeof sink !== "function") return;
143747
+ if (typeof sink !== "function") return { outcome: "absent" };
143622
143748
  let sinkPromise;
143623
143749
  try {
143624
143750
  sinkPromise = Promise.resolve(sink.call(host, event));
143625
- } catch {
143751
+ } catch (error2) {
143626
143752
  log42.warn(`[run-audit] failed to record ${event.mutationType}`);
143627
- return;
143753
+ return { outcome: "failed", error: error2 };
143628
143754
  }
143629
143755
  void sinkPromise.catch(() => void 0);
143630
- await new Promise((resolve51) => {
143756
+ return await new Promise((resolve51) => {
143631
143757
  const timer2 = setTimeout(() => {
143632
143758
  log42.warn(`[run-audit] timed out recording ${event.mutationType}`);
143633
- resolve51();
143759
+ resolve51({ outcome: "timed-out" });
143634
143760
  }, options.timeoutMs ?? RUN_AUDIT_EMIT_TIMEOUT_MS);
143635
143761
  timer2.unref?.();
143636
143762
  void sinkPromise.then(
143637
143763
  () => {
143638
143764
  clearTimeout(timer2);
143639
- resolve51();
143765
+ resolve51({ outcome: "recorded" });
143640
143766
  },
143641
- () => {
143767
+ (error2) => {
143642
143768
  clearTimeout(timer2);
143643
143769
  log42.warn(`[run-audit] failed to record ${event.mutationType}`);
143644
- resolve51();
143770
+ resolve51({ outcome: "failed", error: error2 });
143645
143771
  }
143646
143772
  );
143647
143773
  });
@@ -143690,6 +143816,7 @@ function createRunAuditor(store, context) {
143690
143816
  },
143691
143817
  database: async () => {
143692
143818
  },
143819
+ databaseWithOutcome: async () => ({ outcome: "absent" }),
143693
143820
  filesystem: async () => {
143694
143821
  },
143695
143822
  sandbox: async () => {
@@ -143703,6 +143830,7 @@ function createRunAuditor(store, context) {
143703
143830
  },
143704
143831
  database: async () => {
143705
143832
  },
143833
+ databaseWithOutcome: async () => ({ outcome: "absent" }),
143706
143834
  filesystem: async () => {
143707
143835
  },
143708
143836
  sandbox: async () => {
@@ -143745,6 +143873,28 @@ function createRunAuditor(store, context) {
143745
143873
  };
143746
143874
  await emitBoundedRunAudit2(store, eventInput);
143747
143875
  },
143876
+ /*
143877
+ FNXC:RunAudit 2026-08-23-18:30:
143878
+ Identical write to `database`, returning whether the row landed for the emitters that gate their
143879
+ own state on a proven write (see the interface note). Still bounded and non-throwing.
143880
+ */
143881
+ databaseWithOutcome: async (input) => {
143882
+ const inferredTaskId = input.target.startsWith("FN-") || input.target.startsWith("KB-") ? input.target : context.taskId;
143883
+ return await emitBoundedRunAuditWithOutcome2(store, {
143884
+ taskId: inferredTaskId,
143885
+ agentId: context.agentId,
143886
+ runId: context.runId,
143887
+ domain: "database",
143888
+ mutationType: input.type,
143889
+ target: input.target,
143890
+ metadata: {
143891
+ phase: context.phase,
143892
+ ...context.source ? { source: context.source } : {},
143893
+ ...context.taskLineageId ? { taskLineageId: context.taskLineageId } : {},
143894
+ ...input.metadata
143895
+ }
143896
+ });
143897
+ },
143748
143898
  filesystem: async (input) => {
143749
143899
  const eventInput = {
143750
143900
  taskId: context.taskId,
@@ -146642,10 +146792,13 @@ var init_scheduler = __esm({
146642
146792
  * Validate that a task's filesystem state is intact.
146643
146793
  * Checks that the task directory exists and PROMPT.md is present and non-empty.
146644
146794
  *
146645
- * @param id - The task ID to validate
146795
+ * @param task - The task (id + title) to validate
146646
146796
  * @returns Object with `valid: true` if checks pass, or `valid: false` with a `reason` string if they fail
146647
146797
  */
146648
- async validateTaskFilesystem(id) {
146798
+ async validateTaskFilesystem(task) {
146799
+ const id = task.id;
146800
+ const titleRedirect = nonExecutableDuplicateRedirectReason(null, task.title);
146801
+ if (titleRedirect) return { valid: false, reason: titleRedirect };
146649
146802
  if (typeof this.store.getTasksDir !== "function") {
146650
146803
  return { valid: true };
146651
146804
  }
@@ -146662,7 +146815,7 @@ var init_scheduler = __esm({
146662
146815
  if (!content || content.trim().length === 0) {
146663
146816
  return { valid: false, reason: "missing or empty PROMPT.md" };
146664
146817
  }
146665
- const duplicateOnly = nonExecutableDuplicateRedirectReason(content);
146818
+ const duplicateOnly = nonExecutableDuplicateRedirectReason(content, task.title);
146666
146819
  if (duplicateOnly) {
146667
146820
  return { valid: false, reason: duplicateOnly };
146668
146821
  }
@@ -147340,7 +147493,7 @@ var init_scheduler = __esm({
147340
147493
  );
147341
147494
  }
147342
147495
  }
147343
- const validation = await this.validateTaskFilesystem(task.id);
147496
+ const validation = await this.validateTaskFilesystem(task);
147344
147497
  if (!validation.valid) {
147345
147498
  schedulerLog.warn(`Task ${task.id} filesystem validation failed: ${validation.reason}`);
147346
147499
  const replanColumn = await moveTaskToReplanColumn(this.store, task);
@@ -213747,7 +213900,7 @@ async function finalizeProvenAutoMergeTask({
213747
213900
  });
213748
213901
  return { outcome: "blocked", task: latest, previousColumn: latest.column, reason };
213749
213902
  }
213750
- const hardBlocker = getTaskHardMergeBlocker({
213903
+ const hardBlocker = getMergeConfirmedFinalizationBlocker({
213751
213904
  ...latest,
213752
213905
  /*
213753
213906
  FNXC:WorkflowMerge 2026-06-29-09:15:
@@ -213762,6 +213915,14 @@ async function finalizeProvenAutoMergeTask({
213762
213915
  status: clearMergeConfirmedTransientStatus(latest.status),
213763
213916
  error: void 0
213764
213917
  });
213918
+ const unfinishedSteps = getUnfinishedStepTitles(latest);
213919
+ if (unfinishedSteps.length > 0) {
213920
+ await store.logEntry(
213921
+ taskId,
213922
+ `Finalizing proven merge with ${unfinishedSteps.length} unfinished step(s) \u2014 the branch already landed, so these did not run: ${unfinishedSteps.slice(0, 8).join("; ")}`,
213923
+ "MergeConfirmedFinalizeUnfinishedSteps"
213924
+ ).catch(() => void 0);
213925
+ }
213765
213926
  if (hardBlocker) {
213766
213927
  fence?.assertOwned("finalization");
213767
213928
  await store.updateTask(taskId, {
@@ -220594,15 +220755,21 @@ var SelfHealingManager = class _SelfHealingManager extends SelfHealingGitEvidenc
220594
220755
  const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonical);
220595
220756
  if (!isNearDuplicateCanonicalInactive(canonical ?? void 0, canonicalFlags)) continue;
220596
220757
  const promptPath = join75(this.options.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
220597
- if (existsSync60(promptPath)) {
220758
+ const written = existsSync60(promptPath) ? readFileSync23(promptPath, "utf-8") : "";
220759
+ const duplicateResolution = resolveExplicitDuplicateMarker(written, task.title);
220760
+ const canonicalMarkerId = canonicalId.toUpperCase();
220761
+ if (duplicateResolution.conflict || duplicateResolution.marker && duplicateResolution.marker.canonicalId !== canonicalMarkerId) {
220762
+ continue;
220763
+ }
220764
+ if (duplicateResolution.source === "prompt") {
220598
220765
  try {
220599
- const written = readFileSync23(promptPath, "utf-8");
220600
- if (parseExplicitDuplicateMarker(written)) {
220601
- rmSync7(promptPath, { force: true });
220602
- }
220766
+ rmSync7(promptPath, { force: true });
220603
220767
  } catch {
220604
220768
  }
220605
220769
  }
220770
+ if (resolveExplicitDuplicateMarker(null, task.title).marker?.canonicalId === canonicalMarkerId) {
220771
+ await this.store.updateTask(task.id, { title: `Duplicate redirect cleared: ${canonicalMarkerId}` });
220772
+ }
220606
220773
  await this.store.updateTask(task.id, buildMarkerClearedReplanTaskPatch(canonicalId));
220607
220774
  if (typeof this.store.logEntry === "function") {
220608
220775
  await Promise.resolve(this.store.logEntry(
@@ -223988,7 +224155,7 @@ var SelfHealingManager = class _SelfHealingManager extends SelfHealingGitEvidenc
223988
224155
  mergeTargetBranch: mergeTarget.branch,
223989
224156
  mergeTargetSource: mergeTarget.source
223990
224157
  };
223991
- const hardBlocker = getTaskHardMergeBlocker({
224158
+ const hardBlocker = getMergeConfirmedFinalizationBlocker({
223992
224159
  ...task,
223993
224160
  steps: task.steps ?? [],
223994
224161
  workflowStepResults: task.workflowStepResults
@@ -224173,7 +224340,7 @@ var SelfHealingManager = class _SelfHealingManager extends SelfHealingGitEvidenc
224173
224340
  mergeTargetBranch: mergeTarget.branch,
224174
224341
  mergeTargetSource: mergeTarget.source
224175
224342
  };
224176
- const hardBlocker = getTaskHardMergeBlocker({
224343
+ const hardBlocker = getMergeConfirmedFinalizationBlocker({
224177
224344
  ...task,
224178
224345
  steps: task.steps ?? [],
224179
224346
  workflowStepResults: task.workflowStepResults
@@ -226868,14 +227035,12 @@ var SelfHealingManager = class _SelfHealingManager extends SelfHealingGitEvidenc
226868
227035
  for (const task of candidates) {
226869
227036
  try {
226870
227037
  const promptPath = join75(this.options.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
226871
- if (!existsSync60(promptPath)) {
226872
- continue;
226873
- }
226874
- const written = readFileSync23(promptPath, "utf-8");
226875
- const marker = parseExplicitDuplicateMarker(written);
226876
- if (!marker) {
227038
+ const written = existsSync60(promptPath) ? readFileSync23(promptPath, "utf-8") : "";
227039
+ const duplicateResolution = resolveExplicitDuplicateMarker(written, task.title);
227040
+ if (!duplicateResolution.marker || duplicateResolution.conflict) {
226877
227041
  continue;
226878
227042
  }
227043
+ const marker = duplicateResolution.marker;
226879
227044
  if (processedMarkers >= 50) {
226880
227045
  break;
226881
227046
  }
@@ -226888,7 +227053,10 @@ var SelfHealingManager = class _SelfHealingManager extends SelfHealingGitEvidenc
226888
227053
  const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonicalTask);
226889
227054
  if (!canonicalTask || isNearDuplicateCanonicalInactive(canonicalTask, canonicalFlags)) {
226890
227055
  if (canClearInactiveMarker) {
226891
- rmSync7(promptPath, { force: true });
227056
+ if (duplicateResolution.source !== "title") rmSync7(promptPath, { force: true });
227057
+ if (resolveExplicitDuplicateMarker(null, task.title).marker?.canonicalId === marker.canonicalId) {
227058
+ await this.store.updateTask(task.id, { title: `Duplicate redirect cleared: ${marker.canonicalId}` });
227059
+ }
226892
227060
  const priorClearCount = typeof task.sourceMetadata?.duplicateMarkerClearCount === "number" ? task.sourceMetadata.duplicateMarkerClearCount : 0;
226893
227061
  const patch = buildMarkerClearedReplanTaskPatch(marker.canonicalId, priorClearCount);
226894
227062
  await this.store.updateTask(task.id, patch);
@@ -226907,7 +227075,10 @@ var SelfHealingManager = class _SelfHealingManager extends SelfHealingGitEvidenc
226907
227075
  const resolution = settings.triageDuplicateResolution ?? "prompt";
226908
227076
  if (resolution === "prompt" && isTriageDuplicateKeepAcknowledged(task.sourceMetadata, canonicalTask.id)) {
226909
227077
  if (canClearInactiveMarker) {
226910
- rmSync7(promptPath, { force: true });
227078
+ if (duplicateResolution.source !== "title") rmSync7(promptPath, { force: true });
227079
+ if (resolveExplicitDuplicateMarker(null, task.title).marker?.canonicalId === marker.canonicalId) {
227080
+ await this.store.updateTask(task.id, { title: `Duplicate redirect cleared: ${marker.canonicalId}` });
227081
+ }
226911
227082
  const priorKeepClears = typeof task.sourceMetadata?.duplicateMarkerClearCount === "number" ? task.sourceMetadata.duplicateMarkerClearCount : 0;
226912
227083
  const keepExhausted = priorKeepClears >= 1;
226913
227084
  await this.store.updateTask(
@@ -226932,7 +227103,10 @@ var SelfHealingManager = class _SelfHealingManager extends SelfHealingGitEvidenc
226932
227103
  await flagTriageDuplicate(this.store, task.id, canonicalTask.id);
226933
227104
  await this.store.updateTask(task.id, { paused: true, pausedReason: "duplicate-decision-required", status: null });
226934
227105
  } else {
226935
- rmSync7(promptPath, { force: true });
227106
+ if (duplicateResolution.source !== "title") rmSync7(promptPath, { force: true });
227107
+ if (resolveExplicitDuplicateMarker(null, task.title).marker?.canonicalId === marker.canonicalId) {
227108
+ await this.store.updateTask(task.id, { title: `Duplicate redirect cleared: ${marker.canonicalId}` });
227109
+ }
226936
227110
  await this.store.updateTask(task.id, buildMarkerClearedReplanTaskPatch(canonicalTask.id));
226937
227111
  if (typeof this.store.logEntry === "function") {
226938
227112
  await Promise.resolve(this.store.logEntry(
@@ -235940,6 +236114,7 @@ function buildActionGateContext(deps, taskId, agent, projectDefaultPolicy) {
235940
236114
  executorLog.warn(`${taskId}: failed to suspend in-flight session while awaiting approval: ${error2 instanceof Error ? error2.message : String(error2)}`);
235941
236115
  });
235942
236116
  }
236117
+ void emitApprovalMail({ messageStore: deps.messageStore ?? void 0, approvalRequestId, toolName: decision.toolName, taskId, agentId: actorId, agentName: actorName });
235943
236118
  if (agent && deps.agentStore) {
235944
236119
  await deps.agentStore.updateAgentState(agent.id, "paused");
235945
236120
  await deps.agentStore.updateAgent(agent.id, { pauseReason: "awaiting-approval" });
@@ -236011,6 +236186,7 @@ function buildPermanentAgentGatingContext(deps, taskId, agent, projectDefaultPol
236011
236186
  deps.approvalSuspended.delete(taskId);
236012
236187
  throw error2;
236013
236188
  }
236189
+ void emitApprovalMail({ messageStore: deps.messageStore ?? void 0, approvalRequestId, toolName, taskId, agentId: actorId, agentName: actorName });
236014
236190
  }
236015
236191
  };
236016
236192
  }
@@ -245105,6 +245281,20 @@ var StepSessionExecutor = class {
245105
245281
  * Safe to call multiple times (idempotent). Call this in a `finally` block
245106
245282
  * after `executeAll()`.
245107
245283
  */
245284
+ /**
245285
+ * FNXC:StepParallelWorktrees 2026-08-23-21:40:
245286
+ * `parallelBranches` holds only branches THIS executor created in `createStepWorktree`
245287
+ * (`fusion/step-<idx>-<worktree-name>`), so their provenance is known here rather than inferred.
245288
+ * FN-9161's `isFusionDeletableBranch` answers by NAME and reads anything that is not
245289
+ * `fusion/<task-id>...` as operator-supplied, so applying it here silently skipped every
245290
+ * step branch and leaked one ref per parallel step. Protect what an operator can actually own —
245291
+ * the task's working branch and its explicit override — and dispose of the rest.
245292
+ */
245293
+ isDisposableStepBranch(branchName) {
245294
+ const task = this.options.taskDetail;
245295
+ const operatorOwned = [task.branch, task.branchContext?.branchOverride?.branch].filter((value) => typeof value === "string" && value.trim().length > 0).map((value) => value.trim());
245296
+ return !operatorOwned.includes(branchName.trim());
245297
+ }
245108
245298
  async cleanup() {
245109
245299
  if (this.activeSessions.size > 0) {
245110
245300
  await this.terminateAllSessions();
@@ -245131,7 +245321,7 @@ var StepSessionExecutor = class {
245131
245321
  }
245132
245322
  for (const [stepIdx, branchName] of this.parallelBranches) {
245133
245323
  try {
245134
- if (!isFusionDeletableBranch(this.options.taskDetail, branchName)) continue;
245324
+ if (!this.isDisposableStepBranch(branchName)) continue;
245135
245325
  await execAsync50(`git branch -D "${branchName}"`, {
245136
245326
  cwd: this.options.rootDir
245137
245327
  });
@@ -245686,7 +245876,7 @@ Follow instructions precisely and avoid unrelated changes.`,
245686
245876
  });
245687
245877
  }
245688
245878
  const branch = this.parallelBranches.get(stepIdx);
245689
- if (branch && isFusionDeletableBranch(this.options.taskDetail, branch)) {
245879
+ if (branch && this.isDisposableStepBranch(branch)) {
245690
245880
  await execAsync50(`git branch -D "${branch}"`, {
245691
245881
  cwd: this.options.rootDir
245692
245882
  });
@@ -251894,6 +252084,7 @@ function buildBuildActionGateContextDeps(host) {
251894
252084
  get approvalRequestStore() {
251895
252085
  return host.approvalRequestStore;
251896
252086
  },
252087
+ messageStore: host.options.messageStore,
251897
252088
  activeWorkflowAuthorities: host.activeWorkflowAuthorities,
251898
252089
  activeWorkflowGraphAbortControllers: host.activeWorkflowGraphAbortControllers
251899
252090
  };
@@ -252050,7 +252241,8 @@ function buildBuildPermanentAgentGatingContextDeps(host) {
252050
252241
  approvalSuspended: host.approvalSuspended,
252051
252242
  get approvalRequestStore() {
252052
252243
  return host.approvalRequestStore;
252053
- }
252244
+ },
252245
+ messageStore: host.options.messageStore
252054
252246
  };
252055
252247
  }
252056
252248
  function buildPersistTokenUsageDeps(host) {
@@ -263921,7 +264113,7 @@ var TriageProcessor = class _TriageProcessor {
263921
264113
  planLog.warn(`${task.id} planning recovery skipped \u2014 PROMPT.md failed deterministic validation (${deterministicSpecFailure})`);
263922
264114
  return false;
263923
264115
  }
263924
- const isExplicitDuplicateRedirect = Boolean(parseExplicitDuplicateMarker(written));
264116
+ const isExplicitDuplicateRedirect = Boolean(resolveExplicitDuplicateMarker(written, task.title).marker);
263925
264117
  const workflow = await resolveWorkflowIrForTask(this.store, task.id).catch(() => void 0);
263926
264118
  const requiresPromptImplementationSteps = workflow?.nodes.some(
263927
264119
  (node) => node.kind === "parse-steps" && (node.config?.artifact === void 0 || node.config.artifact === "PROMPT.md") && node.config?.parser === "step-headings" && node.config?.requireStepsUnlessNoCommits === true
@@ -264343,7 +264535,7 @@ var TriageProcessor = class _TriageProcessor {
264343
264535
  phase: "triage",
264344
264536
  source: "triage"
264345
264537
  });
264346
- void throttleAuditor.database({
264538
+ void (throttleAuditor.databaseWithOutcome ?? throttleAuditor.database)({
264347
264539
  type: "task:plan-admission-throttled",
264348
264540
  target: eligibleIds[0] ?? this.rootDir,
264349
264541
  metadata: {
@@ -264356,7 +264548,9 @@ var TriageProcessor = class _TriageProcessor {
264356
264548
  processingCount: this.processing.size,
264357
264549
  processingTaskIds: processingIds
264358
264550
  }
264359
- }).then(() => {
264551
+ }).then((result) => {
264552
+ const outcome = result?.outcome;
264553
+ if (outcome !== "recorded" && outcome !== "absent") return;
264360
264554
  this.lastPlanThrottleSignature = throttleSignature;
264361
264555
  void Promise.all(eligibleIds.map((taskId) => this.store.logEntry(taskId, capacityReason))).catch((logErr) => {
264362
264556
  planLog.warn(`Failed to write planning capacity reason: ${logErr instanceof Error ? logErr.message : String(logErr)}`);
@@ -264500,6 +264694,44 @@ var TriageProcessor = class _TriageProcessor {
264500
264694
  return false;
264501
264695
  }
264502
264696
  }
264697
+ /**
264698
+ * Resolves an exact prompt/title redirect before this task claims any planning capacity.
264699
+ *
264700
+ * FNXC:DuplicateIntake 2026-08-09-01:31:
264701
+ * FN-8840 requires title redirects to take the same duplicate-decision route as prompt
264702
+ * redirects before `specifyTask()` can start a planner session. Reading the prompt here is
264703
+ * required only to detect a conflicting exact marker; a missing or unreadable prompt leaves
264704
+ * a title-only redirect actionable and never lets it consume an implementation session.
264705
+ *
264706
+ * FNXC:DuplicateIntake 2026-08-23-18:30:
264707
+ * This whole title-aware path (plus the `source: "prompt" | "title"` plumbing in
264708
+ * `clearExplicitDuplicateMarker` and the conflict guard in `finalizeApprovedTaskBody`) was
264709
+ * removed by accident in 1cf86baa1c ("refactor: package code organization wave 18"), which
264710
+ * reverted FN-8840 in triage.ts only — every other FN-8840 surface (dashboard task routes,
264711
+ * `handle-graph-failure.ts`, core `resolveExplicitDuplicateMarker`) kept it. A title-only
264712
+ * `DUPLICATE: <id>` card therefore consumed a full planner session again instead of taking the
264713
+ * duplicate-decision route. Restored here; do not "simplify" back to prompt-only parsing.
264714
+ */
264715
+ async finalizeExplicitDuplicateBeforePlanning(task) {
264716
+ try {
264717
+ const liveTask = await this.store.getTask(task.id).catch(() => null);
264718
+ if (!liveTask || liveTask.paused === true || liveTask.userPaused === true) return false;
264719
+ const promptPath = join89(this.rootDir, ".fusion", "tasks", liveTask.id, "PROMPT.md");
264720
+ const written = await readFile49(promptPath, "utf-8").catch(() => "");
264721
+ const duplicateResolution = resolveExplicitDuplicateMarker(written, liveTask.title);
264722
+ if (!duplicateResolution.marker && !duplicateResolution.conflict) return false;
264723
+ const settings = await mergeEffectiveSettings(this.store, liveTask, await this.store.getSettings());
264724
+ if (duplicateResolution.conflict) {
264725
+ await this.finalizeApprovedTask(liveTask, written, settings);
264726
+ return true;
264727
+ }
264728
+ return await this.tryFinalizeExplicitDuplicateMarker(liveTask, written, settings);
264729
+ } catch (error2) {
264730
+ const message = error2 instanceof Error ? error2.message : String(error2);
264731
+ planLog.warn(`${task.id}: pre-planning duplicate resolution failed open: ${message}`);
264732
+ return false;
264733
+ }
264734
+ }
264503
264735
  async specifyTask(task) {
264504
264736
  if (this.resetFence.isResetHoldActive(task.id)) {
264505
264737
  if (dropPreHeldExecutorSlot(task.id)) this.options.semaphore?.release();
@@ -264512,6 +264744,11 @@ var TriageProcessor = class _TriageProcessor {
264512
264744
  this.coordinatorAdmittedTaskIds.delete(task.id);
264513
264745
  return;
264514
264746
  }
264747
+ if (await this.finalizeExplicitDuplicateBeforePlanning(task)) {
264748
+ if (dropPreHeldExecutorSlot(task.id)) this.options.semaphore?.release();
264749
+ this.coordinatorAdmittedTaskIds.delete(task.id);
264750
+ return;
264751
+ }
264515
264752
  if (await this.flagImportNearDuplicateBeforePlanning(task)) {
264516
264753
  if (dropPreHeldExecutorSlot(task.id)) this.options.semaphore?.release();
264517
264754
  return;
@@ -265045,12 +265282,28 @@ This is a multi-repository workspace task. Your final PROMPT.md MUST include a n
265045
265282
  content,
265046
265283
  false
265047
265284
  ),
265285
+ /*
265286
+ FNXC:PlanArtifactPersistence 2026-08-23-19:24:
265287
+ The project-database mirror is BEST-EFFORT — the comment above says so and validation below
265288
+ owns the verdict. FN-151 (c1818ea819) routed it through the reset-fenced writer, which does
265289
+ not swallow, so an infrastructure failure inside the lifecycle lock (e.g. a
265290
+ PlanningLifecycleLockTransportError) aborted a planning attempt whose authoritative PROMPT.md
265291
+ had already been written, and pre-empted both the unchanged-PROMPT verdict and FN-8911's
265292
+ marker-consuming path. Degrade the mirror the way `mirrorPlanToProjectDb` always did; the
265293
+ authoritative `writeAuthoritativePrompt` half deliberately still throws, and only a
265294
+ lifecycle-lock TRANSPORT rejection is degraded here — every other mirror failure keeps
265295
+ aborting the attempt exactly as before.
265296
+ */
265048
265297
  mirrorAuthoritativePlan: async (content) => await this.persistResetFencedPlanningArtifact(
265049
265298
  task,
265050
265299
  planningGeneration,
265051
265300
  content,
265052
265301
  true
265053
- )
265302
+ ).catch((error2) => {
265303
+ if (!isPlanningLifecycleLockTransportError(error2)) throw error2;
265304
+ planLog.warn(`${task.id}: lifecycle-lock transport failure while mirroring the authoritative plan into the project database: ${error2.message}`);
265305
+ return false;
265306
+ })
265054
265307
  });
265055
265308
  if (planPersistence.outcome === "recovery-fenced") return;
265056
265309
  if (planPersistence.outcome === "recovered") {
@@ -265714,11 +265967,11 @@ ${lines.join("\n")}` }],
265714
265967
  }
265715
265968
  async tryFinalizeExplicitDuplicateMarker(task, written, settings, options = {}, report2 = { outcome: "parked" }) {
265716
265969
  try {
265717
- const explicitDuplicateMarker = parseExplicitDuplicateMarker(written);
265718
- if (!explicitDuplicateMarker) {
265970
+ const duplicateResolution = resolveExplicitDuplicateMarker(written, task.title);
265971
+ if (!duplicateResolution.marker || duplicateResolution.conflict) {
265719
265972
  return false;
265720
265973
  }
265721
- const canonicalId = explicitDuplicateMarker.canonicalId;
265974
+ const canonicalId = duplicateResolution.marker.canonicalId;
265722
265975
  const canonicalTask = await this.store.getTask(canonicalId);
265723
265976
  if (canonicalTask?.id.toLowerCase() === task.id.toLowerCase()) {
265724
265977
  return false;
@@ -265833,7 +266086,12 @@ ${lines.join("\n")}` }],
265833
266086
  */
265834
266087
  async clearDuplicateMarkerForReplan(task, canonicalId, feedback, options) {
265835
266088
  if (!await this.runIfStillPlanningUnderTaskLock(task, async () => {
265836
- await rm16(join89(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true });
266089
+ if (options?.source !== "title") {
266090
+ await rm16(join89(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true });
266091
+ }
266092
+ if (resolveExplicitDuplicateMarker(null, task.title).marker?.canonicalId === canonicalId) {
266093
+ await this.store.updateTask(task.id, { title: `Duplicate redirect cleared: ${canonicalId}` });
266094
+ }
265837
266095
  })) return false;
265838
266096
  const priorClearCount = options?.priorClearCount ?? 0;
265839
266097
  if (options?.exhausted) {
@@ -265863,8 +266121,14 @@ ${lines.join("\n")}` }],
265863
266121
  }
265864
266122
  async finalizeApprovedTaskBody(task, writtenInput, settings, options = {}, report2 = { outcome: "parked" }) {
265865
266123
  let written = writtenInput;
265866
- if (await this.recoverMissingPromptBeforeRelease(task)) return;
265867
- const explicitDuplicateMarker = parseExplicitDuplicateMarker(written);
266124
+ const duplicateResolution = resolveExplicitDuplicateMarker(written, task.title);
266125
+ if (duplicateResolution.conflict) {
266126
+ await this.updatePlanningStateIfStillCurrent(task, { status: "needs-replan", error: null });
266127
+ await this.store.logEntry(task.id, "Duplicate redirect sources conflict", "PROMPT.md and task title name different canonical tasks; correct one exact redirect before planning.");
266128
+ return;
266129
+ }
266130
+ if (!duplicateResolution.marker && await this.recoverMissingPromptBeforeRelease(task)) return;
266131
+ const explicitDuplicateMarker = duplicateResolution.marker;
265868
266132
  if (explicitDuplicateMarker) {
265869
266133
  await this.updatePlanningStateIfStillCurrent(task, (live) => {
265870
266134
  const customFields = { ...live.customFields ?? {} };
@@ -265872,6 +266136,7 @@ ${lines.join("\n")}` }],
265872
266136
  return { customFields };
265873
266137
  });
265874
266138
  const canonicalId = explicitDuplicateMarker.canonicalId;
266139
+ const duplicateSource = duplicateResolution.source ?? "prompt";
265875
266140
  const canonicalTask = await this.store.getTask(canonicalId).catch(() => null);
265876
266141
  const canClearInactiveMarker = task.userPaused !== true && (task.paused !== true || task.pausedReason === "duplicate-decision-required") && (task.pausedReason == null || task.pausedReason === "duplicate-decision-required");
265877
266142
  const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonicalTask);
@@ -265883,7 +266148,7 @@ ${lines.join("\n")}` }],
265883
266148
  task,
265884
266149
  canonicalId,
265885
266150
  buildInactiveDuplicateClearFeedback(canonicalId),
265886
- { exhausted: false, priorClearCount }
266151
+ { exhausted: false, priorClearCount, source: duplicateSource }
265887
266152
  );
265888
266153
  }
265889
266154
  return;
@@ -265896,7 +266161,7 @@ ${lines.join("\n")}` }],
265896
266161
  task,
265897
266162
  canonicalId,
265898
266163
  buildKeepDuplicateClearFeedback(canonicalId),
265899
- { exhausted: priorClearCount >= 1, priorClearCount }
266164
+ { exhausted: priorClearCount >= 1, priorClearCount, source: duplicateSource }
265900
266165
  );
265901
266166
  }
265902
266167
  return;
@@ -265934,7 +266199,8 @@ ${lines.join("\n")}` }],
265934
266199
  await this.clearDuplicateMarkerForReplan(
265935
266200
  task,
265936
266201
  canonicalId,
265937
- buildKeepDuplicateClearFeedback(canonicalId)
266202
+ buildKeepDuplicateClearFeedback(canonicalId),
266203
+ { source: duplicateSource }
265938
266204
  );
265939
266205
  return;
265940
266206
  }
@@ -270270,6 +270536,7 @@ var OverseerAdvisorService = class {
270270
270536
 
270271
270537
  // ../engine/src/project-engine.ts
270272
270538
  init_agent_session_helpers();
270539
+ init_active_session_registry();
270273
270540
 
270274
270541
  // ../engine/src/merge/pr-reconcile.ts
270275
270542
  init_src2();
@@ -271451,6 +271718,10 @@ async function enforceAiMergeSquashGates(params) {
271451
271718
  if (!(error2 instanceof FileScopeViolationError)) throw error2;
271452
271719
  await execFileAsync27("git", ["reset", "--hard", params.tipSha], { cwd: params.mergeRoot });
271453
271720
  await execFileAsync27("git", ["clean", "-fd"], { cwd: params.mergeRoot });
271721
+ try {
271722
+ await params.store.updateTask(params.taskId, { aiMergeReviewReconciliation: null });
271723
+ } catch {
271724
+ }
271454
271725
  throw error2;
271455
271726
  }
271456
271727
  }
@@ -271993,11 +272264,12 @@ async function cleanupAiMergeWorktree(input) {
271993
272264
  }
271994
272265
  const removalTargets = canonicalRoot === mergeRoot ? [mergeRoot] : [canonicalRoot, mergeRoot];
271995
272266
  const cleanupMetadata = { taskId, mergeRoot: canonicalRoot, requestedMergeRoot: mergeRoot };
271996
- let alreadyAbsent = false;
272267
+ let alreadyAbsent = removalTargets.every((target) => !existsSync86(target));
271997
272268
  if (worktreeAdded) {
271998
272269
  try {
271999
272270
  await gitRunner(["worktree", "remove", "--force", canonicalRoot], projectRootDir);
272000
- await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true } });
272271
+ if (alreadyAbsent) await log42(`AI merge cleanup: worktree ${canonicalRoot} was already absent before git removal; treating cleanup as idempotent`);
272272
+ await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, ...alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {} } });
272001
272273
  } catch (err) {
272002
272274
  const error2 = describeCleanupError(err);
272003
272275
  const code = getErrorStringProperty(err, "code");
@@ -273593,7 +273865,7 @@ async function landWorkspaceTask(store, task, workspaceRootDir, options = {}, de
273593
273865
  const requiresRepositoryReviewEvidence = approvedReviewEvidence !== void 0 || (mergeBoundaryTask.enabledWorkflowSteps ?? []).some((step) => /review/i.test(step));
273594
273866
  const approvalMissingRepositories = Object.entries(mergeBoundaryFingerprints).filter(([repoRel]) => requiresRepositoryReviewEvidence && !approvedReviewEvidence?.[repoRel]).map(([repoRel]) => repoRel).sort();
273595
273867
  const contentChangedRepositories = Object.entries(mergeBoundaryFingerprints).filter(([repoRel, fingerprint]) => requiresRepositoryReviewEvidence && Boolean(approvedReviewEvidence?.[repoRel]) && approvedReviewEvidence?.[repoRel]?.fingerprint !== fingerprint).map(([repoRel]) => repoRel).sort();
273596
- const changedFiles = normalizedMergeBoundaryFiles.filter((file) => !persistedReviewFiles.includes(file)).sort();
273868
+ const changedFiles = requiresRepositoryReviewEvidence ? normalizedMergeBoundaryFiles.filter((file) => !persistedReviewFiles.includes(file)).sort() : [];
273597
273869
  if (approvalMissingRepositories.length > 0) {
273598
273870
  throw new WorkspaceReviewRequiredError(taskId, {
273599
273871
  kind: "approval-missing",
@@ -275528,6 +275800,9 @@ function emitMergeRequestShadowDequeueParityAudit(store, legacyTaskId, shadowTas
275528
275800
  }
275529
275801
  var execFileAsync33 = promisify87(execFile39);
275530
275802
  var MERGE_HANDOFF_GRACE_MS = 300;
275803
+ function isActiveMergeRequestState(state) {
275804
+ return state === "running" || state === "retrying";
275805
+ }
275531
275806
  var PR_MERGE_RETRY_BACKOFF_BASE_MS = 5e3;
275532
275807
  function getPrMergeRetryNotBefore(task) {
275533
275808
  const retries = task.mergeRetries ?? 0;
@@ -275752,6 +276027,12 @@ var ProjectEngine = class _ProjectEngine {
275752
276027
  };
275753
276028
  // ── Auto-merge state ──
275754
276029
  mergeQueue = [];
276030
+ /* FNXC:MergeAuthority 2026-08-23-18:05: last logged sweep-hold reason per task, so a held card is
276031
+ reported once per reason change instead of every 15s poll. In-memory by design — it is a log
276032
+ de-duplicator, not state anything reads back. Pruned each sweep to the current candidate set
276033
+ (review finding #11), so a card that leaves review by ANY route drops out; admission and
276034
+ soft-delete also clear their entry directly. */
276035
+ mergeSweepHoldReasons = /* @__PURE__ */ new Map();
275755
276036
  mergeActive = /* @__PURE__ */ new Set();
275756
276037
  /** Capacity-deferred ids stay out of the runnable queue until their retry timer fires. */
275757
276038
  capacityDeferredMergeTaskIds = /* @__PURE__ */ new Set();
@@ -277933,25 +278214,174 @@ ${detail}`);
277933
278214
  );
277934
278215
  });
277935
278216
  const allowFlags = await Promise.all(candidates.map((t) => this.allowInReviewMergeProcessing(t, settings, this.runtime.getTaskStore())));
277936
- const unrunGateFlags = await Promise.all(
277937
- candidates.map(async (t) => {
277938
- try {
277939
- const ir2 = await resolveWorkflowIrForTask(this.runtime.getTaskStore(), t.id, reviewLaneIrCache);
277940
- if (!ir2) return false;
277941
- return findUnrunRequiredPreMergeStepIds(ir2, t).length > 0;
277942
- } catch {
278217
+ const sweepBatch = await this.loadMergeSweepBatch(candidates);
278218
+ const admissions = await Promise.all(
278219
+ candidates.map((t) => this.classifyMergeSweepCandidate(t, reviewLaneIrCache, sweepBatch))
278220
+ );
278221
+ const candidateIds = new Set(candidates.map((t) => t.id));
278222
+ for (const heldTaskId of [...this.mergeSweepHoldReasons.keys()]) {
278223
+ if (!candidateIds.has(heldTaskId)) this.mergeSweepHoldReasons.delete(heldTaskId);
278224
+ }
278225
+ const eligible = sortTasksByPriorityThenAgeAndId(
278226
+ candidates.filter((t, i) => {
278227
+ if (!allowFlags[i]) return false;
278228
+ const admission = admissions[i];
278229
+ if (!admission.admit) {
278230
+ const previous = this.mergeSweepHoldReasons.get(t.id);
278231
+ if (previous !== admission.reason) {
278232
+ this.mergeSweepHoldReasons.set(t.id, admission.reason);
278233
+ runtimeLog.log(`Auto-merge sweep holding ${t.id}: ${admission.reason} (graph owns the merge decision)`);
278234
+ }
277943
278235
  return false;
277944
278236
  }
278237
+ this.mergeSweepHoldReasons.delete(t.id);
278238
+ return true;
277945
278239
  })
277946
278240
  );
277947
- const eligible = sortTasksByPriorityThenAgeAndId(
277948
- candidates.filter((_2, i) => allowFlags[i] && !unrunGateFlags[i])
277949
- );
277950
278241
  for (const t of eligible) {
277951
278242
  this.internalEnqueueMerge(t.id);
277952
278243
  }
277953
278244
  return eligible.length;
277954
278245
  }
278246
+ /*
278247
+ FNXC:MergeAuthority 2026-08-23-18:05 (FN-9191 + FN-9193 wedges):
278248
+ Gather the sweep-admission inputs for one candidate. Probes are best-effort, but they do NOT all
278249
+ degrade in the same direction — the direction is chosen per probe by what a wrong answer costs:
278250
+
278251
+ - liveness, continuations: a throw must not wedge the whole lane, and the classifier's other
278252
+ fences still hold, so they degrade toward "not live" / "unreadable" (the latter refuses
278253
+ initiation on its own — see `continuationsReadable`).
278254
+ - `gatesSatisfied`: the ONE deliberate fail-CLOSED probe (review finding #8). If we cannot prove
278255
+ the pre-merge gates are satisfied we must not start a merge; withholding costs a delay, while
278256
+ admitting costs an unreviewed branch on the target. The real door re-checks authoritatively.
278257
+
278258
+ MULTI-REPO / WORKSPACE (and shared-branch groups) resolve through the SAME rules, deliberately:
278259
+ - `branch-group-member-integration` and `branch-group-promotion` are merge-region kinds, so a
278260
+ shared-branch member parked at either is admitted like any other merge-region card.
278261
+ - A workspace sub-repo land registers `workspace-repo-land` / `workspace-repo-acquire` paths in
278262
+ `activeSessionRegistry`, so a task mid-land reads as live and is held — this is what stops a
278263
+ second dispatch racing a partial land.
278264
+ - A workspace partial land re-enqueues through `internalEnqueueMerge` directly (the error path
278265
+ below), which never consults this gate; the merging status it leaves also satisfies
278266
+ `interruptedMergeAttempt` so a restarted engine re-drives the same land.
278267
+ - `isMergePending` covers the cross-node case: another engine holding a durable merge-dispatch
278268
+ lease reads as live here, so two nodes on one central DB cannot both dispatch the same land.
278269
+ NOTE it answers `true` from its OWN catch on an unreadable lease probe (see its FNXC note), so
278270
+ a persistently broken lease read holds every card in this lane rather than racing another
278271
+ node. That is the intended conservative direction; it is not a silent no-op.
278272
+
278273
+ `batch` carries the sweep-wide batched reads (review finding #10) so this runs at O(1) queries per
278274
+ poll instead of O(cards). It is optional: single-card callers (the column-entry handoff, the
278275
+ unpause re-enqueue, the pre-dispatch re-check) pass nothing and fall back to per-task reads.
278276
+ */
278277
+ async classifyMergeSweepCandidate(task, irCache, batch, opts = {}) {
278278
+ const store = this.runtime.getTaskStore();
278279
+ let irTrust = "unresolved-selection";
278280
+ let ir2 = null;
278281
+ try {
278282
+ const resolved = await resolveWorkflowIrForTaskWithProvenance(store, task.id, irCache);
278283
+ ir2 = resolved.ir;
278284
+ irTrust = resolved.source === "selection" ? "cards-own" : resolved.selectionAbsent === true ? "effective-default" : "unresolved-selection";
278285
+ } catch {
278286
+ ir2 = null;
278287
+ irTrust = "unresolved-selection";
278288
+ }
278289
+ let hasLiveSession = false;
278290
+ try {
278291
+ hasLiveSession = executingTaskLock.has(task.id) || activeSessionRegistry.pathsForTask(task.id).length > 0 || !opts.ignoreOwnMergePipeline && (this.activeMergeTaskId === task.id || await this.isMergePending(task.id));
278292
+ } catch {
278293
+ hasLiveSession = false;
278294
+ }
278295
+ let continuationPositions = [];
278296
+ let continuationsReadable = true;
278297
+ try {
278298
+ const readContinuations = store.listWorkflowWorkItemsForTask;
278299
+ const items = batch?.continuations ? batch.continuations.get(task.id) ?? [] : typeof readContinuations === "function" ? await readContinuations.call(store, task.id, { kinds: ["task"] }) : [];
278300
+ continuationPositions = items.filter((item) => ACTIVE_WORKFLOW_WORK_ITEM_STATES.includes(item.state)).map((item) => ir2 ? classifyWorkflowNodeMergeRegion(ir2, item.nodeId) : "unknown");
278301
+ } catch {
278302
+ continuationsReadable = false;
278303
+ continuationPositions = [];
278304
+ }
278305
+ if (batch && !batch.continuationsReadable) continuationsReadable = false;
278306
+ let mergeRequestActive = false;
278307
+ if (!isActiveMergeStatus(task.status)) {
278308
+ try {
278309
+ mergeRequestActive = batch?.mergeRequests ? isActiveMergeRequestState(batch.mergeRequests.get(task.id)?.state) : isActiveMergeRequestState((await store.getMergeRequestRecordAsync?.(task.id))?.state);
278310
+ } catch {
278311
+ mergeRequestActive = false;
278312
+ }
278313
+ }
278314
+ let gatesSatisfied = true;
278315
+ try {
278316
+ const reviewColumns = /* @__PURE__ */ new Set([task.column]);
278317
+ gatesSatisfied = !getTaskMergeBlocker({ ...task, steps: task.steps ?? [] }, {
278318
+ reviewColumns,
278319
+ requiredPreMergeStepIds: ir2 ? resolveRequiredPreMergeStepIds(ir2, task.enabledWorkflowSteps) : void 0
278320
+ });
278321
+ } catch {
278322
+ gatesSatisfied = false;
278323
+ }
278324
+ const updatedAtMs = Date.parse(task.updatedAt ?? "");
278325
+ const quiescentMs = Number.isFinite(updatedAtMs) ? Math.max(0, Date.now() - updatedAtMs) : Number.POSITIVE_INFINITY;
278326
+ return classifyMergeSweepAdmission({
278327
+ irTrust,
278328
+ continuationPositions,
278329
+ continuationsReadable,
278330
+ mergeConfirmed: task.mergeDetails?.mergeConfirmed === true,
278331
+ hasLiveSession,
278332
+ interruptedMergeAttempt: isActiveMergeStatus(task.status) || mergeRequestActive,
278333
+ quiescentMs,
278334
+ gatesSatisfied
278335
+ });
278336
+ }
278337
+ /*
278338
+ FNXC:MergeAuthority 2026-08-23-20:05 (review finding #10):
278339
+ One batched read per sweep instead of one per card. The sweep asks the same two questions of every
278340
+ review-lane card on every 15s poll; per-task reads made that 2N queries. A failed batch read is
278341
+ reported as unreadable rather than as empty, so it refuses initiation exactly like the per-task
278342
+ failure path does.
278343
+ */
278344
+ /*
278345
+ FNXC:MergeAuthority 2026-08-23-20:05 (review finding #2):
278346
+ Position-only dispatch guard. Answers ONE question — "do this card's active continuations still
278347
+ place it inside its workflow's merge region?" — and fails OPEN on every uncertainty (unreadable
278348
+ continuations, an unresolvable workflow, no continuation at all), because the merge door behind it
278349
+ is the authority on everything else. A card with NO active continuation is authorized here by
278350
+ design: that is the shape of the interrupted-merge and quiescent-stall recoveries.
278351
+ */
278352
+ async isDispatchStillGraphAuthorized(task) {
278353
+ const store = this.runtime.getTaskStore();
278354
+ try {
278355
+ const readContinuations = store.listWorkflowWorkItemsForTask;
278356
+ if (typeof readContinuations !== "function") return true;
278357
+ const items = await readContinuations.call(this.runtime.getTaskStore(), task.id, { kinds: ["task"] });
278358
+ const active2 = items.filter((item) => ACTIVE_WORKFLOW_WORK_ITEM_STATES.includes(item.state));
278359
+ if (active2.length === 0) return true;
278360
+ const resolved = await resolveWorkflowIrForTaskWithProvenance(this.runtime.getTaskStore(), task.id);
278361
+ if (resolved.source !== "selection" && resolved.selectionAbsent !== true) return true;
278362
+ return active2.some((item) => classifyWorkflowNodeMergeRegion(resolved.ir, item.nodeId) !== "outside-merge-region");
278363
+ } catch {
278364
+ return true;
278365
+ }
278366
+ }
278367
+ async loadMergeSweepBatch(candidates) {
278368
+ const store = this.runtime.getTaskStore();
278369
+ const ids = candidates.map((t) => t.id);
278370
+ const batch = { continuationsReadable: true };
278371
+ if (ids.length === 0) return batch;
278372
+ try {
278373
+ batch.continuations = typeof store.listWorkflowWorkItemsForTasks === "function" ? await store.listWorkflowWorkItemsForTasks.call(this.runtime.getTaskStore(), ids, { kinds: ["task"] }) : void 0;
278374
+ } catch {
278375
+ batch.continuations = void 0;
278376
+ batch.continuationsReadable = false;
278377
+ }
278378
+ try {
278379
+ batch.mergeRequests = typeof store.getMergeRequestRecordsAsync === "function" ? await store.getMergeRequestRecordsAsync.call(this.runtime.getTaskStore(), ids) : void 0;
278380
+ } catch {
278381
+ batch.mergeRequests = void 0;
278382
+ }
278383
+ return batch;
278384
+ }
277955
278385
  reconcileStaleMergeActive() {
277956
278386
  let cleared = 0;
277957
278387
  for (const taskId of [...this.mergeActive]) {
@@ -278270,6 +278700,11 @@ ${detail}`);
278270
278700
  }
278271
278701
  continue;
278272
278702
  }
278703
+ if (!task.mergeDetails?.mergeConfirmed && !await this.isDispatchStillGraphAuthorized(task)) {
278704
+ runtimeLog.log(`Auto-merge dispatch skipped for ${taskId}: graph moved the card out of its merge region while it was queued`);
278705
+ this.clearMergeActive(taskId);
278706
+ continue;
278707
+ }
278273
278708
  if (task.mergeDetails?.mergeConfirmed) {
278274
278709
  const branchGroupForFastPathCandidate = isSharedBranchGroupMemberIntegration(task) ? store.getBranchGroup?.(task.branchContext?.groupId) : null;
278275
278710
  const fastPathDefaultBranch = await resolveIntegrationBranch(this.config.workingDirectory, settings2);
@@ -278389,7 +278824,7 @@ ${detail}`);
278389
278824
  continue;
278390
278825
  }
278391
278826
  }
278392
- const blockerReason = getTaskHardMergeBlocker({
278827
+ const blockerReason = getMergeConfirmedFinalizationBlocker({
278393
278828
  ...task,
278394
278829
  /*
278395
278830
  FNXC:WorkflowResolvedColumns 2026-07-30-18:05 (this parked ALREADY-MERGED work as failed):
@@ -278403,7 +278838,13 @@ ${detail}`);
278403
278838
  and for the reason recorded there: `"in-review"` is the review-eligible SENTINEL for this
278404
278839
  helper, not a lifecycle column, so a merge-confirmed card evaluates the same blocker set
278405
278840
  on a custom workflow as on the builtin one. The column identity of an already-landed card
278406
- is not what this check is for — paused / error / incomplete steps still apply.
278841
+ is not what this check is for.
278842
+
278843
+ FNXC:MergeConfirmedFinalization 2026-08-23-21:40 (FN-9193): incomplete steps NO LONGER
278844
+ apply here. The fast path above already proved this merge landed, and holding a landed
278845
+ card out of `done` for unfinished steps is what left FN-9193 permanently unfinalizable —
278846
+ a restart then replanned fresh pending steps and re-created the very block it was meant
278847
+ to clear. Paused/error/pre-merge-step blockers still apply.
278407
278848
  */
278408
278849
  column: REVIEW_ELIGIBLE_SENTINEL_COLUMN,
278409
278850
  // Merge-confirmed tasks have already landed. Treat stale merge
@@ -278413,6 +278854,14 @@ ${detail}`);
278413
278854
  status: clearMergeConfirmedTransientStatus(task.status),
278414
278855
  error: void 0
278415
278856
  });
278857
+ const unfinishedFastPathSteps = getUnfinishedStepTitles(task);
278858
+ if (unfinishedFastPathSteps.length > 0) {
278859
+ await store.logEntry(
278860
+ taskId,
278861
+ `Finalizing proven merge with ${unfinishedFastPathSteps.length} unfinished step(s) \u2014 the branch already landed, so these did not run: ${unfinishedFastPathSteps.slice(0, 8).join("; ")}`,
278862
+ "MergeConfirmedFinalizeUnfinishedSteps"
278863
+ ).catch(() => void 0);
278864
+ }
278416
278865
  if (blockerReason) {
278417
278866
  await store.updateTask(taskId, {
278418
278867
  status: "failed",
@@ -279564,6 +280013,11 @@ ${detail}`);
279564
280013
  runtimeLog.warn(`Auto-merge handoff (${task.id}): clearing stale mergeActive before enqueue`);
279565
280014
  this.clearMergeActive(task.id);
279566
280015
  }
280016
+ const handoffAdmission = await this.classifyMergeSweepCandidate(latestTask, /* @__PURE__ */ new Map(), void 0, { ignoreOwnMergePipeline: true });
280017
+ if (!handoffAdmission.admit) {
280018
+ runtimeLog.log(`Auto-merge handoff (${task.id}) skipped: ${handoffAdmission.reason} (graph owns the merge decision)`);
280019
+ return;
280020
+ }
279567
280021
  this.internalEnqueueMerge(task.id);
279568
280022
  } catch (err) {
279569
280023
  runtimeLog.warn(
@@ -279651,6 +280105,11 @@ ${detail}`);
279651
280105
  if (this.options.getTaskMergeBlocker?.(task)) {
279652
280106
  return;
279653
280107
  }
280108
+ const unpauseAdmission = await this.classifyMergeSweepCandidate(task, /* @__PURE__ */ new Map(), void 0, { ignoreOwnMergePipeline: true });
280109
+ if (!unpauseAdmission.admit) {
280110
+ runtimeLog.log(`In-review unpause: ${task.id} not re-enqueued: ${unpauseAdmission.reason} (graph owns the merge decision)`);
280111
+ return;
280112
+ }
279654
280113
  runtimeLog.log(`Unpaused in-review task re-enqueued for auto-merge: ${task.id}`);
279655
280114
  this.internalEnqueueMerge(task.id);
279656
280115
  } catch (err) {
@@ -279661,6 +280120,7 @@ ${detail}`);
279661
280120
  };
279662
280121
  this.taskDeletedHandler = (task) => {
279663
280122
  this.pausedReviewTaskIds.delete(task.id);
280123
+ this.mergeSweepHoldReasons.delete(task.id);
279664
280124
  const queueLengthBefore = this.mergeQueue.length;
279665
280125
  this.mergeQueue = this.mergeQueue.filter((queuedTaskId) => queuedTaskId !== task.id);
279666
280126
  const removedFromQueue = this.mergeQueue.length !== queueLengthBefore;