taskplane 0.5.6 → 0.5.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.
@@ -2,8 +2,8 @@
2
2
  * Main batch execution engine
3
3
  * @module orch/engine
4
4
  */
5
- import { existsSync, readFileSync, readdirSync, unlinkSync } from "fs";
6
- import { dirname, join, resolve } from "path";
5
+ import { readFileSync, readdirSync, unlinkSync } from "fs";
6
+ import { join, resolve } from "path";
7
7
 
8
8
  import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
9
9
  import { execLog, executeWave, tmuxKillSession } from "./execution.ts";
@@ -190,17 +190,42 @@ export async function executeOrchBatch(
190
190
  // Worktrees branch from it; merges target it via update-ref.
191
191
  const opId = resolveOperatorId(orchConfig);
192
192
  const orchBranch = `orch/${opId}-${batchState.batchId}`;
193
- const branchResult = runGit(["branch", orchBranch, batchState.baseBranch], repoRoot);
194
- if (!branchResult.ok) {
195
- batchState.phase = "failed";
196
- batchState.endedAt = Date.now();
197
- const errDetail = branchResult.stderr || branchResult.stdout || "unknown error";
198
- batchState.errors.push(`Failed to create orch branch '${orchBranch}': ${errDetail}`);
199
- onNotify(`❌ Failed to create orch branch '${orchBranch}': ${errDetail}`, "error");
200
- return;
193
+
194
+ // In workspace mode, create the orch branch in every repo that might
195
+ // have tasks. In repo mode, create it only in the single repo.
196
+ if (workspaceConfig) {
197
+ let orchBranchFailed = false;
198
+ for (const [repoId, repoConf] of workspaceConfig.repos) {
199
+ const rRoot = repoConf.path;
200
+ const repoBranch = getCurrentBranch(rRoot) || "HEAD";
201
+ const result = runGit(["branch", orchBranch, repoBranch], rRoot);
202
+ if (result.ok) {
203
+ execLog("batch", batchState.batchId, `created orch branch in ${repoId}`, { orchBranch, base: repoBranch });
204
+ } else {
205
+ const errDetail = result.stderr || result.stdout || "unknown error";
206
+ execLog("batch", batchState.batchId, `failed to create orch branch in ${repoId}: ${errDetail}`);
207
+ batchState.phase = "failed";
208
+ batchState.endedAt = Date.now();
209
+ batchState.errors.push(`Failed to create orch branch '${orchBranch}' in ${repoId}: ${errDetail}`);
210
+ onNotify(`❌ Failed to create orch branch '${orchBranch}' in ${repoId}: ${errDetail}`, "error");
211
+ orchBranchFailed = true;
212
+ break;
213
+ }
214
+ }
215
+ if (orchBranchFailed) return;
216
+ } else {
217
+ const branchResult = runGit(["branch", orchBranch, batchState.baseBranch], repoRoot);
218
+ if (!branchResult.ok) {
219
+ batchState.phase = "failed";
220
+ batchState.endedAt = Date.now();
221
+ const errDetail = branchResult.stderr || branchResult.stdout || "unknown error";
222
+ batchState.errors.push(`Failed to create orch branch '${orchBranch}': ${errDetail}`);
223
+ onNotify(`❌ Failed to create orch branch '${orchBranch}': ${errDetail}`, "error");
224
+ return;
225
+ }
226
+ execLog("batch", batchState.batchId, "created orch branch", { orchBranch, baseBranch: batchState.baseBranch });
201
227
  }
202
228
  batchState.orchBranch = orchBranch;
203
- execLog("batch", batchState.batchId, "created orch branch", { orchBranch, baseBranch: batchState.baseBranch });
204
229
 
205
230
  onNotify(
206
231
  ORCH_MESSAGES.orchStarting(batchState.batchId, rawWaves.length, batchState.totalTasks),
@@ -339,16 +364,6 @@ export async function executeOrchBatch(
339
364
  }
340
365
  }
341
366
 
342
- // ── Workspace mode: commit task artifacts to task-area repos ─
343
- // In workspace mode, workers write .DONE and STATUS.md to the
344
- // canonical task folder (e.g., shared-libs/task-management/...) via
345
- // absolute paths, not to the lane worktree. These changes land as
346
- // uncommitted modifications in the task-area repo's working tree.
347
- // Commit them before the merge step so they appear in the orch branch.
348
- if (workspaceConfig && waveResult.succeededTaskIds.length > 0) {
349
- commitWorkspaceTaskArtifacts(discoveryRef, workspaceRoot ?? repoRoot, waveIdx + 1, batchState.batchId);
350
- }
351
-
352
367
  // ── Wave Merge ───────────────────────────────────────────
353
368
  // Only merge if there are succeeded tasks in this wave
354
369
  let mergeResult: MergeWaveResult | null = null;
@@ -857,78 +872,5 @@ export async function executeOrchBatch(
857
872
  }
858
873
 
859
874
 
860
- // ── Workspace Task Artifact Commit ───────────────────────────────────
861
-
862
- /**
863
- * In workspace mode, commit task artifacts (.DONE, STATUS.md) that workers
864
- * wrote to the canonical task folder in the task-area repo.
865
- *
866
- * Workers write to absolute paths (e.g., shared-libs/task-management/.../TP-002/.DONE)
867
- * which land as uncommitted changes in the task-area repo's working tree.
868
- * This function finds all task-area repos with dirty task files and commits them
869
- * so they appear in the lane branches and merge correctly.
870
- *
871
- * Best-effort: failures are logged but don't block the batch.
872
- */
873
- function commitWorkspaceTaskArtifacts(
874
- discovery: DiscoveryResult | null,
875
- workspaceRoot: string,
876
- waveIndex: number,
877
- batchId: string,
878
- ): void {
879
- if (!discovery) return;
880
-
881
- // Collect unique repo roots that contain task folders
882
- const repoRootsWithTasks = new Set<string>();
883
- for (const [, task] of discovery.pending) {
884
- const taskFolder = resolve(task.taskFolder);
885
- // Walk up to find the git repo root for this task folder
886
- const gitResult = runGit(["rev-parse", "--show-toplevel"], dirname(taskFolder));
887
- if (gitResult.ok) {
888
- repoRootsWithTasks.add(gitResult.stdout.trim().replace(/\\/g, "/"));
889
- }
890
- }
891
-
892
- for (const taskRepoRoot of repoRootsWithTasks) {
893
- // Check for uncommitted changes
894
- const statusResult = runGit(["status", "--porcelain", "--", "task-management/"], taskRepoRoot);
895
- if (!statusResult.ok) {
896
- // Try without path filter (task area might have different name)
897
- const statusAll = runGit(["status", "--porcelain"], taskRepoRoot);
898
- if (!statusAll.ok || !statusAll.stdout.trim()) continue;
899
- }
900
- if (statusResult.ok && !statusResult.stdout.trim()) continue;
901
-
902
- // Stage task artifacts (only .DONE and STATUS.md files)
903
- const lines = (statusResult.stdout || "").split("\n").filter(l => l.trim());
904
- let hasTaskArtifacts = false;
905
- for (const line of lines) {
906
- const file = line.slice(3).trim();
907
- if (file.endsWith(".DONE") || file.endsWith("STATUS.md")) {
908
- const addResult = runGit(["add", file], taskRepoRoot);
909
- if (addResult.ok) hasTaskArtifacts = true;
910
- }
911
- }
912
-
913
- if (!hasTaskArtifacts) continue;
914
-
915
- // Commit
916
- const commitResult = runGit(
917
- ["commit", "-m", `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md)`],
918
- taskRepoRoot,
919
- );
920
- if (commitResult.ok) {
921
- execLog("batch", batchId, `committed workspace task artifacts`, {
922
- repoRoot: taskRepoRoot,
923
- wave: waveIndex,
924
- });
925
- } else if (!commitResult.stderr.includes("nothing to commit")) {
926
- execLog("batch", batchId, `workspace task artifact commit failed (non-fatal): ${commitResult.stderr.slice(0, 200)}`, {
927
- repoRoot: taskRepoRoot,
928
- });
929
- }
930
- }
931
- }
932
-
933
875
  // ── Dashboard Widget (Step 6) ────────────────────────────────────────
934
876
 
@@ -2,9 +2,9 @@
2
2
  * Merge orchestration, merge agents, merge worktree
3
3
  * @module orch/merge
4
4
  */
5
- import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
5
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdirSync } from "fs";
6
6
  import { spawnSync } from "child_process";
7
- import { join } from "path";
7
+ import { join, dirname } from "path";
8
8
 
9
9
  import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
10
10
  import { resolveOperatorId } from "./naming.ts";
@@ -755,6 +755,55 @@ export function mergeWave(
755
755
  }
756
756
  }
757
757
 
758
+ // ── Stage workspace task artifacts into merge worktree ──────────
759
+ // In workspace mode, workers write .DONE and STATUS.md to the canonical
760
+ // task folder (e.g., shared-libs/task-management/...) which is the repo's
761
+ // checked-out working tree (develop). These files need to be on the orch
762
+ // branch, not develop. Copy them into the merge worktree (which is on the
763
+ // orch branch's temp) and commit, so they're included in the update-ref.
764
+ if (mergeWorkDir) {
765
+ const statusResult = spawnSync("git", ["status", "--porcelain"], { cwd: repoRoot, encoding: "utf-8" });
766
+ if (statusResult.status === 0 && statusResult.stdout) {
767
+ const lines = statusResult.stdout.split("\n").filter((l: string) => l.trim());
768
+ const artifactFiles = lines
769
+ .map((l: string) => l.slice(3).trim())
770
+ .filter((f: string) => f.endsWith(".DONE") || f.endsWith("STATUS.md"));
771
+
772
+ if (artifactFiles.length > 0) {
773
+ let staged = 0;
774
+ for (const file of artifactFiles) {
775
+ const srcPath = join(repoRoot, file);
776
+ const destPath = join(mergeWorkDir, file);
777
+ try {
778
+ if (existsSync(srcPath)) {
779
+ mkdirSync(dirname(destPath), { recursive: true });
780
+ copyFileSync(srcPath, destPath);
781
+ spawnSync("git", ["add", file], { cwd: mergeWorkDir });
782
+ staged++;
783
+ }
784
+ } catch { /* best effort */ }
785
+ }
786
+ if (staged > 0) {
787
+ spawnSync("git", ["commit", "-m", `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md)`], { cwd: mergeWorkDir });
788
+ execLog("merge", `W${waveIndex}`, `committed ${staged} task artifact(s) to merge worktree`);
789
+
790
+ // Restore the repo's working tree — remove the artifacts from develop's working tree
791
+ // so they don't cause conflicts on /orch-integrate
792
+ for (const file of artifactFiles) {
793
+ spawnSync("git", ["checkout", "--", file], { cwd: repoRoot });
794
+ }
795
+ // Also remove any untracked .DONE files
796
+ for (const file of artifactFiles) {
797
+ if (file.endsWith(".DONE")) {
798
+ const srcPath = join(repoRoot, file);
799
+ try { if (existsSync(srcPath)) unlinkSync(srcPath); } catch { /* best effort */ }
800
+ }
801
+ }
802
+ }
803
+ }
804
+ }
805
+ }
806
+
758
807
  // ── Update target branch ref and clean up merge worktree ────────
759
808
  const anySuccess = laneResults.some(
760
809
  r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
@@ -1025,7 +1074,11 @@ export function mergeWaveByRepo(
1025
1074
 
1026
1075
  for (const group of repoGroups) {
1027
1076
  const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig);
1028
- const groupBaseBranch = resolveBaseBranch(group.repoId, groupRepoRoot, baseBranch, workspaceConfig);
1077
+ // In workspace mode with orch branch, always merge into the orch branch
1078
+ // (passed as baseBranch from engine.ts). Do NOT use resolveBaseBranch()
1079
+ // which returns the repo's current branch (e.g., develop), bypassing
1080
+ // the orch branch model entirely.
1081
+ const groupBaseBranch = baseBranch;
1029
1082
 
1030
1083
  execLog("merge", `W${waveIndex}`, `merging repo group: ${group.repoId ?? "(default)"}`, {
1031
1084
  repoRoot: groupRepoRoot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",