taskplane 0.1.18 β†’ 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -281,6 +281,17 @@ export default function (pi: ExtensionAPI) {
281
281
  "info",
282
282
  );
283
283
  }
284
+ const hasStrictErrors = fatalErrors.some(
285
+ (e) => e.code === "TASK_ROUTING_STRICT",
286
+ );
287
+ if (hasStrictErrors) {
288
+ ctx.ui.notify(
289
+ "πŸ’‘ Strict routing is enabled (routing.strict: true). Every task must declare an explicit execution target.\n" +
290
+ " Add a `## Execution Target` section with `Repo: <id>` to each task's PROMPT.md.\n" +
291
+ " To disable strict routing, set `routing.strict: false` in workspace config.",
292
+ "info",
293
+ );
294
+ }
284
295
  return;
285
296
  }
286
297
 
@@ -8,6 +8,7 @@
8
8
  export * from "./types.ts";
9
9
  export * from "./config.ts";
10
10
  export * from "./git.ts";
11
+ export * from "./naming.ts";
11
12
  export * from "./worktree.ts";
12
13
  export * from "./discovery.ts";
13
14
  export * from "./waves.ts";
@@ -7,8 +7,10 @@ import { spawnSync } from "child_process";
7
7
  import { join } from "path";
8
8
 
9
9
  import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
10
+ import { resolveOperatorId } from "./naming.ts";
10
11
  import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MS, MergeError, VALID_MERGE_STATUSES } from "./types.ts";
11
- import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, WaveExecutionResult } from "./types.ts";
12
+ import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
13
+ import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
12
14
  import { sleepSync } from "./worktree.ts";
13
15
 
14
16
  // ── Merge Implementation ─────────────────────────────────────────────
@@ -493,6 +495,7 @@ export function mergeWave(
493
495
  ): MergeWaveResult {
494
496
  const startTime = Date.now();
495
497
  const tmuxPrefix = config.orchestrator.tmux_prefix;
498
+ const opId = resolveOperatorId(config);
496
499
  const targetBranch = baseBranch;
497
500
  const laneResults: MergeLaneResult[] = [];
498
501
 
@@ -543,8 +546,9 @@ export function mergeWave(
543
546
  // ── Create isolated merge worktree ──────────────────────────────
544
547
  // Merging in a dedicated worktree prevents dirty-worktree failures
545
548
  // caused by user edits or orchestrator-generated files in the main repo.
546
- const tempBranch = `_merge-temp-${batchId}`;
547
- const mergeWorkDir = join(repoRoot, ".worktrees", "merge-workspace");
549
+ // Include opId to prevent collisions between concurrent operators.
550
+ const tempBranch = `_merge-temp-${opId}-${batchId}`;
551
+ const mergeWorkDir = join(repoRoot, ".worktrees", `merge-workspace-${opId}`);
548
552
 
549
553
  // Clean up stale merge worktree/branch from prior failed attempt
550
554
  try {
@@ -592,10 +596,10 @@ export function mergeWave(
592
596
 
593
597
  for (const lane of orderedLanes) {
594
598
  const laneStart = Date.now();
595
- const sessionName = `${tmuxPrefix}-merge-${lane.laneNumber}`;
596
- const resultFileName = `merge-result-w${waveIndex}-lane${lane.laneNumber}-${batchId}.json`;
599
+ const sessionName = `${tmuxPrefix}-${opId}-merge-${lane.laneNumber}`;
600
+ const resultFileName = `merge-result-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.json`;
597
601
  const resultFilePath = join(repoRoot, ".pi", resultFileName);
598
- const requestFileName = `merge-request-w${waveIndex}-lane${lane.laneNumber}-${batchId}.txt`;
602
+ const requestFileName = `merge-request-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.txt`;
599
603
  const requestFilePath = join(repoRoot, ".pi", requestFileName);
600
604
 
601
605
  execLog("merge", sessionName, `starting merge for lane ${lane.laneNumber}`, {
@@ -647,6 +651,7 @@ export function mergeWave(
647
651
  result: mergeResult,
648
652
  error: null,
649
653
  durationMs: Date.now() - laneStart,
654
+ repoId: lane.repoId,
650
655
  });
651
656
 
652
657
  // Handle merge outcome
@@ -715,6 +720,7 @@ export function mergeWave(
715
720
  result: null,
716
721
  error: errMsg,
717
722
  durationMs: Date.now() - laneStart,
723
+ repoId: lane.repoId,
718
724
  });
719
725
 
720
726
  failedLane = lane.laneNumber;
@@ -795,3 +801,241 @@ export function mergeWave(
795
801
  };
796
802
  }
797
803
 
804
+
805
+ // ── Repo-Scoped Merge ────────────────────────────────────────────────
806
+
807
+ /**
808
+ * Group mergeable lanes by their `repoId`.
809
+ *
810
+ * Returns groups sorted deterministically by repoId (undefined/repo-mode
811
+ * group sorts first as empty string). Lanes within each group preserve
812
+ * the input order.
813
+ *
814
+ * @param lanes - Lanes to group (already filtered for mergeability)
815
+ * @returns Array of { repoId, lanes } groups in deterministic order
816
+ */
817
+ export function groupLanesByRepo(
818
+ lanes: AllocatedLane[],
819
+ ): Array<{ repoId: string | undefined; lanes: AllocatedLane[] }> {
820
+ const groupMap = new Map<string, AllocatedLane[]>();
821
+
822
+ for (const lane of lanes) {
823
+ const key = lane.repoId ?? "";
824
+ const existing = groupMap.get(key) || [];
825
+ existing.push(lane);
826
+ groupMap.set(key, existing);
827
+ }
828
+
829
+ const sortedKeys = [...groupMap.keys()].sort();
830
+ return sortedKeys.map(key => ({
831
+ repoId: key || undefined,
832
+ lanes: groupMap.get(key)!,
833
+ }));
834
+ }
835
+
836
+ /**
837
+ * Merge a wave's lanes partitioned by repository.
838
+ *
839
+ * In repo mode (all lanes have repoId=undefined), this produces a single
840
+ * repo group and delegates to `mergeWave()` exactly once β€” a no-op
841
+ * regression case that preserves existing behavior.
842
+ *
843
+ * In workspace mode, lanes are grouped by `repoId`. Each repo group gets:
844
+ * - Its own repo root (via `resolveRepoRoot()`)
845
+ * - Its own base branch (via `resolveBaseBranch()`)
846
+ * - An independent `mergeWave()` call with those repo-scoped parameters
847
+ *
848
+ * Repo groups are processed in deterministic order (sorted by repoId).
849
+ * Per-repo results are aggregated into a single `MergeWaveResult` for
850
+ * the existing wave-level failure policy handling in `engine.ts`.
851
+ *
852
+ * Failure semantics:
853
+ * - A failure in one repo does NOT stop merging in other repos.
854
+ * - The aggregate status is "succeeded" only if all repos succeeded.
855
+ * - If any repo failed and any succeeded, status is "partial".
856
+ * - `repoResults` field carries per-repo attribution for downstream
857
+ * reporting (Step 1 will use this for explicit partial-success summaries).
858
+ *
859
+ * @param completedLanes - Lanes that completed execution (from wave result)
860
+ * @param waveResult - The wave execution result (for lane status filtering)
861
+ * @param waveIndex - Wave number (1-indexed)
862
+ * @param config - Orchestrator configuration
863
+ * @param repoRoot - Default repository root (used in repo mode)
864
+ * @param batchId - Batch ID for session naming
865
+ * @param baseBranch - Default branch to merge into (captured at batch start)
866
+ * @param workspaceConfig - Workspace configuration (null in repo mode)
867
+ * @returns MergeWaveResult with per-lane and per-repo outcomes
868
+ */
869
+ export function mergeWaveByRepo(
870
+ completedLanes: AllocatedLane[],
871
+ waveResult: WaveExecutionResult,
872
+ waveIndex: number,
873
+ config: OrchestratorConfig,
874
+ repoRoot: string,
875
+ batchId: string,
876
+ baseBranch: string,
877
+ workspaceConfig?: WorkspaceConfig | null,
878
+ ): MergeWaveResult {
879
+ const startTime = Date.now();
880
+
881
+ // Build lane outcome lookup for merge eligibility (same logic as mergeWave).
882
+ const laneOutcomeByNumber = new Map<number, LaneExecutionResult>();
883
+ for (const laneOutcome of waveResult.laneResults) {
884
+ laneOutcomeByNumber.set(laneOutcome.laneNumber, laneOutcome);
885
+ }
886
+
887
+ // Filter to mergeable lanes (same criteria as mergeWave).
888
+ const mergeableLanes = completedLanes.filter(lane => {
889
+ const outcome = laneOutcomeByNumber.get(lane.laneNumber);
890
+ if (!outcome) return false;
891
+ const hasSucceeded = outcome.tasks.some(t => t.status === "succeeded");
892
+ const hasHardFailure = outcome.tasks.some(
893
+ t => t.status === "failed" || t.status === "stalled",
894
+ );
895
+ return hasSucceeded && !hasHardFailure;
896
+ });
897
+
898
+ if (mergeableLanes.length === 0) {
899
+ execLog("merge", `W${waveIndex}`, "no mergeable lanes (all failed or empty)");
900
+ return {
901
+ waveIndex,
902
+ status: "succeeded",
903
+ laneResults: [],
904
+ failedLane: null,
905
+ failureReason: null,
906
+ totalDurationMs: Date.now() - startTime,
907
+ repoResults: [],
908
+ };
909
+ }
910
+
911
+ // Group lanes by repo
912
+ const repoGroups = groupLanesByRepo(mergeableLanes);
913
+
914
+ execLog("merge", `W${waveIndex}`, `merging across ${repoGroups.length} repo group(s)`, {
915
+ repos: repoGroups.map(g => g.repoId ?? "(default)").join(", "),
916
+ totalLanes: mergeableLanes.length,
917
+ });
918
+
919
+ // In repo mode (single group with repoId=undefined), delegate directly
920
+ // to mergeWave() for zero-overhead backward compatibility.
921
+ if (repoGroups.length === 1 && repoGroups[0].repoId === undefined) {
922
+ const result = mergeWave(
923
+ completedLanes,
924
+ waveResult,
925
+ waveIndex,
926
+ config,
927
+ repoRoot,
928
+ batchId,
929
+ baseBranch,
930
+ );
931
+ // Attach empty repoResults for consistent shape
932
+ return { ...result, repoResults: [] };
933
+ }
934
+
935
+ // ── Workspace mode: per-repo merge loops ─────────────────────
936
+ const allLaneResults: MergeLaneResult[] = [];
937
+ const repoOutcomes: RepoMergeOutcome[] = [];
938
+ let firstFailedLane: number | null = null;
939
+ let firstFailureReason: string | null = null;
940
+ // Track repo-level failures independently of lane-level failures.
941
+ // mergeWave() can return status="failed" with failedLane=null for
942
+ // pre-lane setup errors (temp branch creation, worktree creation).
943
+ // We must detect these to avoid misclassifying the aggregate as "succeeded".
944
+ let anyRepoFailed = false;
945
+
946
+ for (const group of repoGroups) {
947
+ const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig);
948
+ const groupBaseBranch = resolveBaseBranch(group.repoId, groupRepoRoot, baseBranch, workspaceConfig);
949
+
950
+ execLog("merge", `W${waveIndex}`, `merging repo group: ${group.repoId ?? "(default)"}`, {
951
+ repoRoot: groupRepoRoot,
952
+ baseBranch: groupBaseBranch,
953
+ laneCount: group.lanes.length,
954
+ lanes: group.lanes.map(l => l.laneNumber).join(","),
955
+ });
956
+
957
+ // Build a filtered WaveExecutionResult containing only this group's lanes.
958
+ const groupLaneNumbers = new Set(group.lanes.map(l => l.laneNumber));
959
+ const filteredWaveResult: WaveExecutionResult = {
960
+ ...waveResult,
961
+ laneResults: waveResult.laneResults.filter(lr => groupLaneNumbers.has(lr.laneNumber)),
962
+ allocatedLanes: waveResult.allocatedLanes.filter(l => groupLaneNumbers.has(l.laneNumber)),
963
+ };
964
+
965
+ const groupResult = mergeWave(
966
+ group.lanes,
967
+ filteredWaveResult,
968
+ waveIndex,
969
+ config,
970
+ groupRepoRoot,
971
+ batchId,
972
+ groupBaseBranch,
973
+ );
974
+
975
+ // Accumulate lane results
976
+ allLaneResults.push(...groupResult.laneResults);
977
+
978
+ // Build per-repo outcome
979
+ const repoOutcome: RepoMergeOutcome = {
980
+ repoId: group.repoId,
981
+ status: groupResult.status,
982
+ laneResults: groupResult.laneResults,
983
+ failedLane: groupResult.failedLane,
984
+ failureReason: groupResult.failureReason,
985
+ };
986
+ repoOutcomes.push(repoOutcome);
987
+
988
+ // Track failures across repos (but continue to merge other repos).
989
+ // Check groupResult.status (not just failedLane) to catch setup failures
990
+ // where mergeWave() returns status="failed" with failedLane=null
991
+ // (e.g., temp branch creation or worktree creation failure).
992
+ if (groupResult.status !== "succeeded") {
993
+ anyRepoFailed = true;
994
+
995
+ if (firstFailureReason === null) {
996
+ firstFailedLane = groupResult.failedLane;
997
+ firstFailureReason = groupResult.failureReason
998
+ ? `[repo:${group.repoId ?? "default"}] ${groupResult.failureReason}`
999
+ : `[repo:${group.repoId ?? "default"}] Merge failed (setup error)`;
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ // ── Aggregate status ─────────────────────────────────────────
1005
+ // Use both lane-level and repo-level evidence for correct classification:
1006
+ // - anyLaneSucceeded: at least one lane merged successfully across all repos
1007
+ // - anyRepoFailed: at least one repo had a non-succeeded status (includes
1008
+ // both lane-level failures AND repo setup failures with failedLane=null)
1009
+ const anyLaneSucceeded = allLaneResults.some(
1010
+ r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
1011
+ );
1012
+
1013
+ let status: MergeWaveResult["status"];
1014
+ if (!anyRepoFailed) {
1015
+ status = "succeeded";
1016
+ } else if (anyLaneSucceeded) {
1017
+ status = "partial";
1018
+ } else {
1019
+ status = "failed";
1020
+ }
1021
+
1022
+ const totalDurationMs = Date.now() - startTime;
1023
+
1024
+ execLog("merge", `W${waveIndex}`, `repo-scoped wave merge complete: ${status}`, {
1025
+ repoCount: repoOutcomes.length,
1026
+ repoStatuses: repoOutcomes.map(r => `${r.repoId ?? "default"}:${r.status}`).join(", "),
1027
+ mergedLanes: allLaneResults.filter(r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED").length,
1028
+ duration: `${Math.round(totalDurationMs / 1000)}s`,
1029
+ });
1030
+
1031
+ return {
1032
+ waveIndex,
1033
+ status,
1034
+ laneResults: allLaneResults,
1035
+ failedLane: firstFailedLane,
1036
+ failureReason: firstFailureReason,
1037
+ totalDurationMs,
1038
+ repoResults: repoOutcomes,
1039
+ };
1040
+ }
1041
+
@@ -2,7 +2,7 @@
2
2
  * User-facing message templates (ORCH_MESSAGES)
3
3
  * @module orch/messages
4
4
  */
5
- import type { AbortMode } from "./types.ts";
5
+ import type { AbortMode, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome } from "./types.ts";
6
6
 
7
7
  // ── Message Templates ────────────────────────────────────────────────
8
8
 
@@ -19,7 +19,7 @@ export const ORCH_MESSAGES = {
19
19
  orchWaveComplete: (waveNum: number, succeeded: number, failed: number, skipped: number, elapsedSec: number) =>
20
20
  `βœ… Wave ${waveNum} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped (${elapsedSec}s)`,
21
21
  orchMergeStart: (waveNum: number, laneCount: number) =>
22
- `πŸ”€ [Wave ${waveNum}] Merging ${laneCount} lane(s) into develop...`,
22
+ `πŸ”€ [Wave ${waveNum}] Merging ${laneCount} lane(s) into target branch...`,
23
23
  orchMergeLaneSuccess: (laneNum: number, commit: string, durationSec: number) =>
24
24
  ` βœ… Lane ${laneNum} merged (${commit.slice(0, 8)}, ${durationSec}s)`,
25
25
  orchMergeLaneConflictResolved: (laneNum: number, conflictCount: number, durationSec: number) =>
@@ -35,7 +35,7 @@ export const ORCH_MESSAGES = {
35
35
  orchMergePlaceholder: (waveNum: number) =>
36
36
  `πŸ”€ [Wave ${waveNum}] Merge: placeholder β€” Step 3 (TS-008) will replace with mergeWave()`,
37
37
  orchWorktreeReset: (waveNum: number, lanes: number) =>
38
- `πŸ”„ Resetting ${lanes} worktree(s) to develop HEAD after wave ${waveNum}`,
38
+ `πŸ”„ Resetting ${lanes} worktree(s) to target branch HEAD after wave ${waveNum}`,
39
39
  orchBatchComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number) => {
40
40
  const lines = [`\n🏁 Batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s)`];
41
41
  if (failed > 0 || blocked > 0) {
@@ -117,9 +117,213 @@ export const ORCH_MESSAGES = {
117
117
  `No active batch to abort. Use /orch <areas|all> to start a batch.`,
118
118
  abortComplete: (mode: AbortMode, sessionsKilled: number) =>
119
119
  `🏁 Abort (${mode}) complete: ${sessionsKilled} session(s) terminated. Worktrees and branches preserved.`,
120
+ // /orch merge β€” repo-scoped partial summary (TP-005 Step 1)
121
+ orchMergePartialRepoSummary: (waveNum: number, repoLines: string[]) =>
122
+ `⚠️ [Wave ${waveNum}] Merge partially succeeded β€” repo outcomes diverged:\n${repoLines.join("\n")}`,
120
123
  } as const;
121
124
 
122
125
 
126
+ // ── Repo-Scoped Merge Summary (TP-005) ──────────────────────────────
127
+
128
+ /**
129
+ * Status emoji for repo merge outcome.
130
+ */
131
+ function repoStatusIcon(status: RepoMergeOutcome["status"]): string {
132
+ switch (status) {
133
+ case "succeeded": return "βœ…";
134
+ case "partial": return "⚠️";
135
+ case "failed": return "❌";
136
+ default: return "❓";
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Format a repo-divergence summary for a partial merge wave result.
142
+ *
143
+ * Returns null if:
144
+ * - repoResults is empty or undefined (mono-repo mode)
145
+ * - all repos have the same status (no divergence)
146
+ * - there is only one repo group (divergence is meaningless)
147
+ *
148
+ * When the partial result is caused by mixed-outcome lanes within
149
+ * a single repo (not repo divergence), this returns null to avoid
150
+ * misleading "cross-repo divergence" messaging.
151
+ *
152
+ * The returned string is a complete, ready-to-emit message.
153
+ *
154
+ * @param mergeResult - The MergeWaveResult with status "partial"
155
+ * @returns Formatted summary string, or null if no repo-divergence summary applies
156
+ */
157
+ export function formatRepoMergeSummary(mergeResult: MergeWaveResult): string | null {
158
+ const repoResults = mergeResult.repoResults;
159
+
160
+ // No repo attribution β†’ mono-repo mode, no summary
161
+ if (!repoResults || repoResults.length === 0) {
162
+ return null;
163
+ }
164
+
165
+ // Single repo group β†’ divergence is meaningless (partial is lane-level)
166
+ if (repoResults.length < 2) {
167
+ return null;
168
+ }
169
+
170
+ // Check for actual divergence: are there different statuses across repos?
171
+ const statuses = new Set(repoResults.map(r => r.status));
172
+ if (statuses.size < 2) {
173
+ // All repos have the same status (e.g., all "partial") β€”
174
+ // the partial is from within-repo lane failures, not cross-repo divergence
175
+ return null;
176
+ }
177
+
178
+ // Build per-repo summary lines (sorted by repoId, which repoResults already is)
179
+ const repoLines = repoResults.map(r => {
180
+ const repoLabel = r.repoId ?? "(default)";
181
+ const icon = repoStatusIcon(r.status);
182
+ const mergedCount = r.laneResults.filter(
183
+ lr => lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED",
184
+ ).length;
185
+ const totalCount = r.laneResults.length;
186
+ let detail = `${mergedCount}/${totalCount} lane(s) merged`;
187
+ if (r.failureReason) {
188
+ detail += ` β€” ${r.failureReason.slice(0, 150)}`;
189
+ }
190
+ return ` ${icon} ${repoLabel}: ${detail}`;
191
+ });
192
+
193
+ return ORCH_MESSAGES.orchMergePartialRepoSummary(mergeResult.waveIndex, repoLines);
194
+ }
195
+
196
+
197
+ // ── Merge Failure Policy Application (TP-005 Step 2) ─────────────────
198
+
199
+ /**
200
+ * Result of applying the merge failure policy.
201
+ *
202
+ * Pure function output β€” callers use this to perform state mutations
203
+ * and notifications consistently. Ensures engine.ts and resume.ts
204
+ * apply identical pause/abort transitions.
205
+ */
206
+ export interface MergeFailurePolicyResult {
207
+ /** The applied policy: "pause" or "abort". */
208
+ policy: "pause" | "abort";
209
+ /** Target phase for batchState.phase. */
210
+ targetPhase: "paused" | "stopped";
211
+ /** Error message to push to batchState.errors. */
212
+ errorMessage: string;
213
+ /** Persistence trigger label. */
214
+ persistTrigger: "merge-failure-pause" | "merge-failure-abort";
215
+ /** User-facing notification message. */
216
+ notifyMessage: string;
217
+ /** Notification level for onNotify. */
218
+ notifyLevel: "error";
219
+ /** Comma-separated failed lane identifiers for logging. */
220
+ failedLaneIds: string;
221
+ /** Structured log details for execLog. */
222
+ logDetails: {
223
+ failedLane: number;
224
+ failedLaneIds: string;
225
+ reason: string;
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Compute the merge failure policy application result.
231
+ *
232
+ * This is a **pure function** β€” it computes all outputs deterministically
233
+ * from the merge result and config, without performing any side effects.
234
+ *
235
+ * Both engine.ts and resume.ts MUST use this function to guarantee
236
+ * identical failure attribution, phase transitions, error messages,
237
+ * and notifications on repo-scoped merge failures.
238
+ *
239
+ * Failure attribution rules (priority chain):
240
+ * 1. Lane-level: lanes with CONFLICT_UNRESOLVED, BUILD_FAILURE, or error
241
+ * β†’ formatted as `lane-<N>` (comma-separated).
242
+ * 2. Fallback: if no lane-level failures but `mergeResult.failedLane`
243
+ * is non-null, uses `lane-<N>` as the identifier.
244
+ * 3. Repo-level: if no lane-level failures and failedLane is null
245
+ * (repo setup failure), uses `repo:<repoId>` from repoResults
246
+ * entries with non-succeeded status. Sorted deterministically.
247
+ * - The failure reason is truncated to 200 chars for notifications and
248
+ * logged in full in batchState.errors.
249
+ *
250
+ * @param mergeResult - The merge wave result with status "failed" or "partial"
251
+ * @param waveIndex - 0-based wave index (displayed as 1-indexed)
252
+ * @param config - Orchestrator configuration (for on_merge_failure policy)
253
+ * @returns Policy result object for callers to apply
254
+ */
255
+ export function computeMergeFailurePolicy(
256
+ mergeResult: MergeWaveResult,
257
+ waveIndex: number,
258
+ config: OrchestratorConfig,
259
+ ): MergeFailurePolicyResult {
260
+ const waveNum = waveIndex + 1;
261
+ const mergeFailurePolicy = config.failure.on_merge_failure;
262
+
263
+ // Build failed lane identifiers from lane results.
264
+ // Priority chain:
265
+ // 1. Lane-level: lanes with CONFLICT_UNRESOLVED, BUILD_FAILURE, or error
266
+ // 2. Fallback: failedLane from mergeResult (single lane ID)
267
+ // 3. Repo-level: repos with non-succeeded status from repoResults
268
+ // (catches setup failures where failedLane=null and no lane results)
269
+ let failedLaneIds = mergeResult.laneResults
270
+ .filter(r => r.result?.status === "CONFLICT_UNRESOLVED" || r.result?.status === "BUILD_FAILURE" || r.error)
271
+ .map(r => `lane-${r.laneNumber}`)
272
+ .join(", ");
273
+ if (!failedLaneIds && mergeResult.failedLane !== null) {
274
+ failedLaneIds = `lane-${mergeResult.failedLane}`;
275
+ }
276
+ if (!failedLaneIds && mergeResult.repoResults && mergeResult.repoResults.length > 0) {
277
+ // Repo-level fallback for setup failures (no lane results, failedLane=null).
278
+ // Uses sorted repoResults order for determinism.
279
+ failedLaneIds = mergeResult.repoResults
280
+ .filter(r => r.status !== "succeeded")
281
+ .map(r => `repo:${r.repoId ?? "default"}`)
282
+ .join(", ");
283
+ }
284
+
285
+ const reason = mergeResult.failureReason || "unknown";
286
+ const reasonTruncated = reason.slice(0, 200);
287
+
288
+ const logDetails = {
289
+ failedLane: mergeResult.failedLane ?? 0,
290
+ failedLaneIds,
291
+ reason: reasonTruncated,
292
+ };
293
+
294
+ const errorMessage =
295
+ `Merge failed at wave ${waveNum}: ${reason}. ` +
296
+ (mergeFailurePolicy === "pause"
297
+ ? `Batch paused. Resolve conflicts and use /orch-resume to continue.`
298
+ : `Batch aborted by on_merge_failure policy.`);
299
+
300
+ const laneDetail = failedLaneIds ? ` (${failedLaneIds})` : "";
301
+
302
+ let notifyMessage: string;
303
+ if (mergeFailurePolicy === "pause") {
304
+ notifyMessage =
305
+ `⏸️ Batch paused due to merge failure at wave ${waveNum}${laneDetail}. ` +
306
+ `Reason: ${reasonTruncated}. ` +
307
+ `Resolve conflicts and resume.`;
308
+ } else {
309
+ notifyMessage =
310
+ `β›” Batch aborted due to merge failure at wave ${waveNum}${laneDetail}. ` +
311
+ `Reason: ${reasonTruncated}.`;
312
+ }
313
+
314
+ return {
315
+ policy: mergeFailurePolicy,
316
+ targetPhase: mergeFailurePolicy === "pause" ? "paused" : "stopped",
317
+ errorMessage,
318
+ persistTrigger: mergeFailurePolicy === "pause" ? "merge-failure-pause" : "merge-failure-abort",
319
+ notifyMessage,
320
+ notifyLevel: "error",
321
+ failedLaneIds,
322
+ logDetails,
323
+ };
324
+ }
325
+
326
+
123
327
  // ── Resume ORCH_MESSAGES ─────────────────────────────────────────────
124
328
 
125
329
  // Note: These are added via extension to the ORCH_MESSAGES object below.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Naming contract helpers for team-scale collision resistance.
3
+ *
4
+ * Provides deterministic, human-readable identifiers for TMUX sessions,
5
+ * worktree directories, git branches, and merge artifacts. All naming
6
+ * components are sanitized for safe use in filesystem paths, git refs,
7
+ * and TMUX session names.
8
+ *
9
+ * @module orch/naming
10
+ */
11
+ import { basename, resolve } from "path";
12
+ import { userInfo } from "os";
13
+
14
+ import type { OrchestratorConfig } from "./types.ts";
15
+
16
+ // ── Sanitization ─────────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Sanitize a raw string into a safe naming component.
20
+ *
21
+ * Rules:
22
+ * - Lowercase
23
+ * - Replace non-alphanumeric characters (except hyphens) with hyphens
24
+ * - Collapse consecutive hyphens
25
+ * - Trim leading/trailing hyphens
26
+ * - Truncate to `maxLen` characters
27
+ *
28
+ * Safe for use in: TMUX session names, git branch refs, filesystem paths.
29
+ *
30
+ * @param raw - Raw input string
31
+ * @param maxLen - Maximum length (default: 16)
32
+ * @returns Sanitized string, or empty string if input sanitizes to nothing
33
+ */
34
+ export function sanitizeNameComponent(raw: string, maxLen: number = 16): string {
35
+ return raw
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9-]/g, "-")
38
+ .replace(/-+/g, "-")
39
+ .replace(/^-+|-+$/g, "")
40
+ .slice(0, maxLen);
41
+ }
42
+
43
+ // ── Operator ID ──────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Resolve the operator identifier from available sources.
47
+ *
48
+ * Resolution order (first non-empty wins):
49
+ * 1. `TASKPLANE_OPERATOR_ID` environment variable
50
+ * 2. `operator_id` field in OrchestratorConfig
51
+ * 3. Current OS username via `os.userInfo().username`
52
+ * 4. Fallback: `"op"`
53
+ *
54
+ * The resolved value is sanitized and truncated to 12 characters.
55
+ *
56
+ * @param config - Orchestrator configuration (may contain operator_id)
57
+ * @param env - Environment variables (defaults to process.env)
58
+ * @returns Sanitized operator identifier (never empty)
59
+ */
60
+ export function resolveOperatorId(
61
+ config: OrchestratorConfig,
62
+ env: Record<string, string | undefined> = process.env,
63
+ ): string {
64
+ const FALLBACK = "op";
65
+ const MAX_LEN = 12;
66
+
67
+ // 1. Environment variable
68
+ const envValue = env.TASKPLANE_OPERATOR_ID;
69
+ if (envValue && envValue.trim()) {
70
+ const sanitized = sanitizeNameComponent(envValue.trim(), MAX_LEN);
71
+ if (sanitized) return sanitized;
72
+ }
73
+
74
+ // 2. Config field
75
+ const configValue = config.orchestrator.operator_id;
76
+ if (configValue && configValue.trim()) {
77
+ const sanitized = sanitizeNameComponent(configValue.trim(), MAX_LEN);
78
+ if (sanitized) return sanitized;
79
+ }
80
+
81
+ // 3. OS username
82
+ try {
83
+ const username = userInfo().username;
84
+ if (username && username.trim()) {
85
+ const sanitized = sanitizeNameComponent(username.trim(), MAX_LEN);
86
+ if (sanitized) return sanitized;
87
+ }
88
+ } catch {
89
+ // userInfo() can throw on some platforms
90
+ }
91
+
92
+ // 4. Fallback
93
+ return FALLBACK;
94
+ }
95
+
96
+ // ── Repo Slug ────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Derive a repo slug from the repository root directory name.
100
+ *
101
+ * Provides cross-repo disambiguation when multiple repos share the
102
+ * same machine. Used in TMUX session names and worktree paths where
103
+ * names must be globally unique on the machine.
104
+ *
105
+ * @param repoRoot - Absolute path to the repository root
106
+ * @returns Sanitized repo slug (never empty; falls back to "repo")
107
+ */
108
+ export function resolveRepoSlug(repoRoot: string): string {
109
+ const FALLBACK = "repo";
110
+ const MAX_LEN = 16;
111
+
112
+ const dirName = basename(resolve(repoRoot));
113
+ if (!dirName) return FALLBACK;
114
+
115
+ const sanitized = sanitizeNameComponent(dirName, MAX_LEN);
116
+ return sanitized || FALLBACK;
117
+ }