taskplane 0.9.1 → 0.9.3

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.
@@ -530,7 +530,10 @@ function renderLanesTasks(batch, tmuxSessions) {
530
530
  // Worker stats from lane state sidecar + telemetry badges
531
531
  let workerHtml = "";
532
532
  const telemBadges = task.status !== "pending" ? telemetryBadgesHtml(tel) : "";
533
- const reviewerActive = ls && ls.reviewerStatus === "running";
533
+ // Reviewer sub-row should only appear under the task currently being reviewed,
534
+ // not all tasks in the lane. The lane-state sidecar is per-lane (shared by all
535
+ // tasks in the lane), so check that the sidecar's current taskId matches this task.
536
+ const reviewerActive = ls && ls.reviewerStatus === "running" && ls.taskId === task.taskId;
534
537
  if (ls && ls.workerStatus === "running" && task.status === "running") {
535
538
  const elapsed = ls.workerElapsed ? `${Math.round(ls.workerElapsed / 1000)}s` : "";
536
539
  const tools = ls.workerToolCount || 0;
@@ -15,6 +15,7 @@ import {
15
15
  computeWaveAssignments,
16
16
  createOrchWidget,
17
17
  deleteBatchState,
18
+ deleteStaleBranches,
18
19
  detectOrphanSessions,
19
20
  executeLane,
20
21
  executeOrchBatch,
@@ -407,6 +408,10 @@ export function executeIntegration(
407
408
  }
408
409
 
409
410
  if (!result.ok) {
411
+ // TP-052: Include branch protection hint when merge fails
412
+ const protectionHint = result.stderr.includes("protected") || result.stderr.includes("permission")
413
+ ? `\n\n šŸ’” If the branch is protected, use --pr to create a pull request.`
414
+ : "";
410
415
  return {
411
416
  success: false,
412
417
  integratedLocally: false,
@@ -417,7 +422,8 @@ export function executeIntegration(
417
422
  `${result.stderr}\n\n` +
418
423
  `Try:\n` +
419
424
  ` /orch-integrate --merge Create a merge commit\n` +
420
- ` /orch-integrate --pr Create a pull request instead`,
425
+ ` /orch-integrate --pr Create a pull request instead` +
426
+ protectionHint,
421
427
  };
422
428
  }
423
429
  // Count commits that were applied
@@ -451,6 +457,10 @@ export function executeIntegration(
451
457
  }
452
458
 
453
459
  if (!result.ok) {
460
+ // TP-052: Include branch protection hint when merge fails
461
+ const mergeProtectionHint = result.stderr.includes("protected") || result.stderr.includes("permission")
462
+ ? `\n\n šŸ’” If the branch is protected, use --pr to create a pull request.`
463
+ : "";
454
464
  return {
455
465
  success: false,
456
466
  integratedLocally: false,
@@ -460,7 +470,8 @@ export function executeIntegration(
460
470
  `āŒ Merge failed — there may be conflicts.\n` +
461
471
  `${result.stderr}\n\n` +
462
472
  `Resolve conflicts manually, or try:\n` +
463
- ` /orch-integrate --pr Create a pull request instead`,
473
+ ` /orch-integrate --pr Create a pull request instead` +
474
+ mergeProtectionHint,
464
475
  };
465
476
  }
466
477
  return performCleanup(deps, orchBranch, {
@@ -644,7 +655,7 @@ export function collectRepoCleanupFindings(
644
655
  findings.staleWorktrees = wts.map(wt => wt.path);
645
656
  } catch { /* best effort — git worktree list may fail in unusual states */ }
646
657
 
647
- // 2. Lane branches — task/{opId}-lane-*
658
+ // 2. Lane branches — task/{opId}-lane-* and saved/task/{opId}-lane-*
648
659
  try {
649
660
  const branchResult = runGit(["branch", "--list", `task/${opId}-lane-*`], repoRoot);
650
661
  if (branchResult.ok && branchResult.stdout.trim()) {
@@ -653,6 +664,15 @@ export function collectRepoCleanupFindings(
653
664
  .map(b => b.replace(/^\*?\s+/, "").trim())
654
665
  .filter(Boolean);
655
666
  }
667
+ // Also detect saved lane branches (preserved refs from worktree removal)
668
+ const savedBranchResult = runGit(["branch", "--list", `saved/task/${opId}-lane-*`], repoRoot);
669
+ if (savedBranchResult.ok && savedBranchResult.stdout.trim()) {
670
+ const savedBranches = savedBranchResult.stdout
671
+ .split("\n")
672
+ .map(b => b.replace(/^\*?\s+/, "").trim())
673
+ .filter(Boolean);
674
+ findings.staleLaneBranches.push(...savedBranches);
675
+ }
656
676
  } catch { /* best effort */ }
657
677
 
658
678
  // 3. Orch branch — check if the specific orch branch still exists
@@ -899,7 +919,7 @@ export function startBatchAsync(
899
919
  *
900
920
  * @since TP-043 R002
901
921
  */
902
- export function buildIntegrationExecutor(repoRoot: string): IntegrationExecutor {
922
+ export function buildIntegrationExecutor(repoRoot: string, opId?: string): IntegrationExecutor {
903
923
  return (mode, context) => {
904
924
  // Ensure we're on the base branch before integrating
905
925
  const currentBranch = getCurrentBranch(repoRoot);
@@ -942,10 +962,22 @@ export function buildIntegrationExecutor(repoRoot: string): IntegrationExecutor
942
962
  },
943
963
  };
944
964
 
945
- return executeIntegration(mode as IntegrateMode, {
965
+ const result = executeIntegration(mode as IntegrateMode, {
946
966
  ...context,
947
967
  currentBranch: context.baseBranch,
948
968
  }, deps);
969
+
970
+ // TP-051: Clean up stale task/* and saved/* branches after successful integration.
971
+ // This ensures auto-mode integration (supervisor path) gets the same cleanup
972
+ // as the manual /orch-integrate handler.
973
+ if (result.success && result.integratedLocally && context.batchId && opId) {
974
+ try {
975
+ deleteStaleBranches(repoRoot, opId, context.batchId);
976
+ dropBatchAutostash(repoRoot, context.batchId);
977
+ } catch { /* best effort — don't fail integration for cleanup errors */ }
978
+ }
979
+
980
+ return result;
949
981
  };
950
982
  }
951
983
 
@@ -1512,7 +1544,7 @@ export default function (pi: ExtensionAPI) {
1512
1544
  orchBatchState,
1513
1545
  mode,
1514
1546
  repoRoot,
1515
- buildIntegrationExecutor(repoRoot),
1547
+ buildIntegrationExecutor(repoRoot, opId),
1516
1548
  buildCiDeps(repoRoot),
1517
1549
  sDeps,
1518
1550
  );
@@ -1858,7 +1890,7 @@ export default function (pi: ExtensionAPI) {
1858
1890
  orchBatchState,
1859
1891
  mode,
1860
1892
  execCtx!.repoRoot,
1861
- buildIntegrationExecutor(execCtx!.repoRoot),
1893
+ buildIntegrationExecutor(execCtx!.repoRoot, opId),
1862
1894
  buildCiDeps(execCtx!.repoRoot),
1863
1895
  sDeps,
1864
1896
  );
@@ -1893,12 +1925,18 @@ export default function (pi: ExtensionAPI) {
1893
1925
  `Batch **${orchBatchState.batchId}** completed — ` +
1894
1926
  `${orchBatchState.succeededTasks}/${orchBatchState.totalTasks} tasks succeeded.\n\n` +
1895
1927
  `The orch branch \`${orchBatchState.orchBranch}\` is ready to integrate.\n` +
1896
- `Would you like me to integrate it, or would you prefer to review first?`,
1928
+ `Would you like me to integrate it, or would you prefer to review first?\n\n` +
1929
+ `You can also:\n` +
1930
+ `• Run \`/orch-integrate\` (or \`/orch-integrate --pr\`) to integrate\n` +
1931
+ `• Create new tasks for the next batch\n` +
1932
+ `• Run a health check`,
1897
1933
  }
1898
1934
  : {
1899
1935
  routingState: "no-tasks",
1900
1936
  contextMessage:
1901
1937
  `Batch **${orchBatchState.batchId}** ended (${orchBatchState.phase}).\n\n` +
1938
+ `${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ` +
1939
+ `${orchBatchState.skippedTasks} skipped.\n\n` +
1902
1940
  `What would you like to do next?`,
1903
1941
  };
1904
1942
  transitionToRoutingMode(pi, supervisorState, postBatchContext);
@@ -2352,7 +2390,26 @@ export default function (pi: ExtensionAPI) {
2352
2390
  ctx.ui.notify(notice, "info");
2353
2391
  }
2354
2392
 
2355
- // ── Step 2: Pre-integration summary ──────────────────────
2393
+ // ── Step 2a: Branch protection pre-check (TP-052) ───────
2394
+ // When using ff or merge mode (direct push), check if the target
2395
+ // branch has protection rules. If protected, warn and suggest --pr.
2396
+ // Graceful degradation: if gh is unavailable, skip the check.
2397
+ if (parsed.mode !== "pr") {
2398
+ const { detectBranchProtection } = await import("./supervisor.ts");
2399
+ const protectionStatus = detectBranchProtection(baseBranch, repoRoot);
2400
+ if (protectionStatus === "protected") {
2401
+ ctx.ui.notify(
2402
+ `āš ļø Branch \`${baseBranch}\` has branch protection rules enabled.\n` +
2403
+ `Direct merges may be blocked by your repository settings.\n\n` +
2404
+ `Recommended: use \`/orch-integrate --pr\` to create a pull request instead.`,
2405
+ "warning",
2406
+ );
2407
+ // Don't block — proceed with the attempt. The merge will fail
2408
+ // gracefully and show a clear error if protection blocks it.
2409
+ }
2410
+ }
2411
+
2412
+ // ── Step 2b: Pre-integration summary ─────────────────────
2356
2413
  // Count commits ahead
2357
2414
  const revListResult = runGit(
2358
2415
  ["rev-list", "--count", `${currentBranch}..${orchBranch}`],
@@ -2469,6 +2526,30 @@ export default function (pi: ExtensionAPI) {
2469
2526
  dropBatchAutostash(repo.root, batchId);
2470
2527
  }
2471
2528
 
2529
+ // TP-051: Delete stale task/* and saved/task/* branches from all repos.
2530
+ // These accumulate after each batch and clutter `git branch` output.
2531
+ // Deletes both current-batch branches and orphans from previous batches.
2532
+ const branchCleanupLines: string[] = [];
2533
+ for (const repo of allRepos) {
2534
+ const branchCleanup = deleteStaleBranches(repo.root, opId, batchId);
2535
+ const totalDeleted = branchCleanup.deletedTaskBranches.length + branchCleanup.deletedSavedBranches.length;
2536
+ if (totalDeleted > 0 || branchCleanup.failedDeletes.length > 0) {
2537
+ const label = repo.id === "(default)" ? "" : ` (${repo.id})`;
2538
+ if (branchCleanup.deletedTaskBranches.length > 0) {
2539
+ branchCleanupLines.push(` šŸ—‘ļø Deleted ${branchCleanup.deletedTaskBranches.length} task branch(es)${label}`);
2540
+ }
2541
+ if (branchCleanup.deletedSavedBranches.length > 0) {
2542
+ branchCleanupLines.push(` šŸ—‘ļø Deleted ${branchCleanup.deletedSavedBranches.length} saved branch(es)${label}`);
2543
+ }
2544
+ if (branchCleanup.failedDeletes.length > 0) {
2545
+ branchCleanupLines.push(` āš ļø Failed to delete ${branchCleanup.failedDeletes.length} branch(es)${label}: ${branchCleanup.failedDeletes.join(", ")}`);
2546
+ }
2547
+ }
2548
+ }
2549
+ if (branchCleanupLines.length > 0) {
2550
+ ctx.ui.notify("Branch cleanup:\n" + branchCleanupLines.join("\n"), "info");
2551
+ }
2552
+
2472
2553
  // Run acceptance checks across all workspace repos.
2473
2554
  // In PR mode, the orch branch is intentionally preserved for the PR,
2474
2555
  // so we skip orch branch detection to avoid contradictory output.
@@ -51,15 +51,18 @@ export const ORCH_MESSAGES = {
51
51
  }
52
52
  if (orchBranch && succeeded > 0) {
53
53
  lines.push("");
54
- lines.push(` ℹ All work is on orch branch: ${orchBranch}`);
55
- lines.push(` Your ${baseBranch || "working"} branch was not modified.`);
54
+ lines.push(" ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”");
55
+ lines.push(` │ Your changes are on branch: ${orchBranch}`);
56
+ lines.push(` │ Your ${baseBranch || "working"} branch was not modified.`);
56
57
  if (baseBranch) {
57
- lines.push(` Preview: git log ${baseBranch}..${orchBranch}`);
58
+ lines.push(` │ Preview: git log ${baseBranch}..${orchBranch}`);
58
59
  }
59
- lines.push("");
60
- lines.push(" To apply the changes:");
61
- lines.push(" • /orch-integrate Apply now (fast-forward, recommended)");
62
- lines.push(" • /orch-integrate --pr Push orch branch & open a PR for team review");
60
+ lines.push(" │");
61
+ lines.push(" │ šŸ‘‰ To bring changes into your working branch:");
62
+ lines.push(" │");
63
+ lines.push(" │ /orch-integrate — merge directly (recommended)");
64
+ lines.push(" │ /orch-integrate --pr — create a pull request");
65
+ lines.push(" ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜");
63
66
  }
64
67
  return lines.join("\n");
65
68
  },
@@ -170,13 +170,15 @@ export function syncTaskOutcomesFromMonitor(
170
170
  }
171
171
 
172
172
  // Completed tasks => succeeded
173
+ // Use existing endTime if already set — prevents changed=true on every
174
+ // poll tick (lastPollTime differs each tick, causing persist log spam).
173
175
  for (const taskId of lane.completedTasks) {
174
176
  const existing = outcomes.find(o => o.taskId === taskId);
175
177
  changed = upsertTaskOutcome(outcomes, {
176
178
  taskId,
177
179
  status: "succeeded",
178
180
  startTime: existing?.startTime ?? null,
179
- endTime: monitorState.lastPollTime,
181
+ endTime: existing?.endTime ?? monitorState.lastPollTime,
180
182
  exitReason: existing?.exitReason || ".DONE file created by task-runner",
181
183
  sessionName: existing?.sessionName || lane.sessionName,
182
184
  doneFileFound: true,
@@ -193,7 +195,7 @@ export function syncTaskOutcomesFromMonitor(
193
195
  taskId,
194
196
  status: "failed",
195
197
  startTime: existing?.startTime ?? null,
196
- endTime: monitorState.lastPollTime,
198
+ endTime: existing?.endTime ?? monitorState.lastPollTime,
197
199
  exitReason: existing?.exitReason || "Task failed or stalled",
198
200
  sessionName: existing?.sessionName || lane.sessionName,
199
201
  doneFileFound: false,
@@ -219,10 +221,14 @@ export function syncTaskOutcomesFromMonitor(
219
221
  const mappedStatus = monitorToLane[snap.status];
220
222
  const terminal = mappedStatus === "succeeded" || mappedStatus === "failed" || mappedStatus === "stalled" || mappedStatus === "skipped";
221
223
 
224
+ // TP-051: Use snap.observedAt (Date.now() from monitor poll) instead of
225
+ // snap.lastHeartbeat (STATUS.md mtime) for task start time. The mtime
226
+ // reflects when STATUS.md was last edited, which may be long before
227
+ // actual execution started (e.g., during task staging).
222
228
  changed = upsertTaskOutcome(outcomes, {
223
229
  taskId: lane.currentTaskId,
224
230
  status: mappedStatus,
225
- startTime: existing?.startTime ?? snap.lastHeartbeat ?? snap.observedAt,
231
+ startTime: existing?.startTime ?? snap.observedAt,
226
232
  endTime: terminal ? (existing?.endTime ?? snap.observedAt) : null,
227
233
  exitReason: existing?.exitReason || (mappedStatus === "running" ? "Task in progress" : (snap.stallReason || "Task reached terminal state")),
228
234
  sessionName: existing?.sessionName || lane.sessionName,
@@ -2615,15 +2615,19 @@ export async function transitionToRoutingMode(
2615
2615
  // Keep batchStateRef/orchConfigRef/stateRoot — routing prompt may need them
2616
2616
  // Keep model override — don't switch models mid-conversation
2617
2617
 
2618
- // Notify the operator that conversational mode is back
2618
+ // TP-052: Send a prominent conversational message that clearly signals
2619
+ // the supervisor is ready for input. Uses triggerTurn to force an LLM
2620
+ // response, which ensures the pi TUI redraws and shows the input prompt.
2619
2621
  pi.sendMessage(
2620
2622
  {
2621
2623
  customType: "supervisor-routing-transition",
2622
2624
  content: [{
2623
2625
  type: "text",
2624
2626
  text:
2625
- `šŸ”€ **Supervisor returning to conversational mode.**\n\n` +
2626
- routingContext.contextMessage,
2627
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
2628
+ `šŸ”€ **Ready for your input.**\n\n` +
2629
+ routingContext.contextMessage +
2630
+ `\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
2627
2631
  }],
2628
2632
  display: `Supervisor — ${routingContext.routingState}`,
2629
2633
  },
@@ -2323,3 +2323,127 @@ export function preserveFailedLaneProgress(
2323
2323
  return { results, preservedBranches, unsafeBranches };
2324
2324
  }
2325
2325
 
2326
+
2327
+ // ── Stale Branch Cleanup (TP-051) ────────────────────────────────────
2328
+
2329
+ /**
2330
+ * Result of stale branch cleanup after integration.
2331
+ */
2332
+ export interface StaleBranchCleanupResult {
2333
+ /** task/* branches deleted */
2334
+ deletedTaskBranches: string[];
2335
+ /** saved/task/* branches deleted */
2336
+ deletedSavedBranches: string[];
2337
+ /** Branches that failed to delete (best-effort) */
2338
+ failedDeletes: string[];
2339
+ }
2340
+
2341
+ /**
2342
+ * Delete stale task/* and saved/* branches after integration.
2343
+ *
2344
+ * After `/orch-integrate` merges or creates a PR, the lane branches
2345
+ * (`task/{opId}-lane-{N}-{batchId}`) and their saved counterparts
2346
+ * are no longer needed. This function cleans them up.
2347
+ *
2348
+ * Cleanup scope:
2349
+ * 1. **Lane branches:** `task/{opId}-lane-*` (any batch from this operator)
2350
+ * 2. **Saved lane branches:** `saved/task/{opId}-lane-*` (preserved lane refs)
2351
+ * 3. **Partial-progress branches:** `saved/{opId}-*` (per-task partial progress refs)
2352
+ *
2353
+ * Targets all branches matching the operator's prefix, not just the current
2354
+ * batch — this also cleans up orphans from previous batches that were never
2355
+ * cleaned.
2356
+ *
2357
+ * All deletions are best-effort — individual failures are logged but don't
2358
+ * prevent other branches from being cleaned.
2359
+ *
2360
+ * @param repoRoot - Repository root directory
2361
+ * @param opId - Operator identifier (e.g., "henrylach")
2362
+ * @param batchId - Current batch ID (for logging context)
2363
+ * @returns Cleanup result with lists of deleted and failed branches
2364
+ */
2365
+ export function deleteStaleBranches(
2366
+ repoRoot: string,
2367
+ opId: string,
2368
+ batchId: string,
2369
+ ): StaleBranchCleanupResult {
2370
+ const deletedTaskBranches: string[] = [];
2371
+ const deletedSavedBranches: string[] = [];
2372
+ const failedDeletes: string[] = [];
2373
+
2374
+ // 1. Delete task/{opId}-lane-* branches
2375
+ const taskBranchResult = runGit(["branch", "--list", `task/${opId}-lane-*`], repoRoot);
2376
+ if (taskBranchResult.ok && taskBranchResult.stdout.trim()) {
2377
+ const branches = taskBranchResult.stdout
2378
+ .split("\n")
2379
+ .map(b => b.replace(/^\*?\s+/, "").trim())
2380
+ .filter(Boolean);
2381
+
2382
+ for (const branch of branches) {
2383
+ const deleted = deleteBranchBestEffort(branch, repoRoot);
2384
+ if (deleted) {
2385
+ deletedTaskBranches.push(branch);
2386
+ } else {
2387
+ failedDeletes.push(branch);
2388
+ }
2389
+ }
2390
+ }
2391
+
2392
+ // 2. Delete saved/task/{opId}-lane-* branches (preserved lane refs)
2393
+ const savedTaskResult = runGit(["branch", "--list", `saved/task/${opId}-lane-*`], repoRoot);
2394
+ if (savedTaskResult.ok && savedTaskResult.stdout.trim()) {
2395
+ const branches = savedTaskResult.stdout
2396
+ .split("\n")
2397
+ .map(b => b.replace(/^\*?\s+/, "").trim())
2398
+ .filter(Boolean);
2399
+
2400
+ for (const branch of branches) {
2401
+ const deleted = deleteBranchBestEffort(branch, repoRoot);
2402
+ if (deleted) {
2403
+ deletedSavedBranches.push(branch);
2404
+ } else {
2405
+ failedDeletes.push(branch);
2406
+ }
2407
+ }
2408
+ }
2409
+
2410
+ // 3. Delete saved/{opId}-*-{batchId} branches (partial-progress refs from this batch)
2411
+ // Pattern: saved/{opId}-{taskId}-{batchId} or saved/{opId}-{repoId}-{taskId}-{batchId}
2412
+ // Only deletes branches ending with the current batchId to avoid removing
2413
+ // partial-progress refs from other batches that the operator may still need.
2414
+ const savedProgressResult = runGit(["branch", "--list", `saved/${opId}-*`], repoRoot);
2415
+ if (savedProgressResult.ok && savedProgressResult.stdout.trim()) {
2416
+ const branches = savedProgressResult.stdout
2417
+ .split("\n")
2418
+ .map(b => b.replace(/^\*?\s+/, "").trim())
2419
+ .filter(Boolean);
2420
+
2421
+ const batchSuffix = `-${batchId}`;
2422
+ for (const branch of branches) {
2423
+ // Avoid double-deleting saved/task/* already handled above
2424
+ if (branch.startsWith("saved/task/")) continue;
2425
+ // Only delete partial-progress refs from the current batch
2426
+ if (!branch.endsWith(batchSuffix)) continue;
2427
+ const deleted = deleteBranchBestEffort(branch, repoRoot);
2428
+ if (deleted) {
2429
+ deletedSavedBranches.push(branch);
2430
+ } else {
2431
+ failedDeletes.push(branch);
2432
+ }
2433
+ }
2434
+ }
2435
+
2436
+ const totalDeleted = deletedTaskBranches.length + deletedSavedBranches.length;
2437
+ if (totalDeleted > 0) {
2438
+ execLog("cleanup", "branches", `deleted ${totalDeleted} stale branch(es) for batch ${batchId}`, {
2439
+ taskBranches: deletedTaskBranches.length,
2440
+ savedBranches: deletedSavedBranches.length,
2441
+ failed: failedDeletes.length,
2442
+ });
2443
+ }
2444
+
2445
+ return { deletedTaskBranches, deletedSavedBranches, failedDeletes };
2446
+ }
2447
+
2448
+
2449
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",