taskplane 0.30.5 → 0.30.6

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.
@@ -369,6 +369,10 @@ export interface TaskRunnerConfig {
369
369
  tools: string;
370
370
  /** Package specifiers to exclude from extension forwarding (exact match). @since TP-180 */
371
371
  excludeExtensions?: string[];
372
+ /** Ordered severity vocabulary for review finding-count analysis (review-boundary notifications). */
373
+ severityLabels?: string[];
374
+ /** Revision-spiral detection tuning. */
375
+ spiral?: import("./config-schema.ts").ReviewSpiralConfig;
372
376
  };
373
377
  /**
374
378
  * Worker agent model/thinking/tools configuration.
@@ -384,6 +388,8 @@ export interface TaskRunnerConfig {
384
388
  tools: string;
385
389
  /** Package specifiers to exclude from extension forwarding (exact match). @since TP-180 */
386
390
  excludeExtensions?: string[];
391
+ /** Exit-intercept supervisor-reply window in seconds (default 60; 15..1800). */
392
+ exitInterceptTimeoutSec?: number;
387
393
  };
388
394
  /** Worker agent extension exclusion list. @since TP-180 */
389
395
  workerExcludeExtensions?: string[];
@@ -561,6 +567,13 @@ export class WorktreeError extends Error {
561
567
  * catching errors for expected idempotent scenarios.
562
568
  */
563
569
  export interface RemoveWorktreeResult {
570
+ /**
571
+ * #628: removal was refused because the worktree has uncommitted changes and
572
+ * the caller did not pass allowDirty. The worktree and branch are preserved.
573
+ */
574
+ refusedDirty?: boolean;
575
+ /** Number of uncommitted paths found when refusedDirty is true. */
576
+ dirtyFileCount?: number;
564
577
  /** Whether the worktree directory was removed in this call */
565
578
  removed: boolean;
566
579
  /** Whether the worktree was already absent (idempotent no-op) */
@@ -1132,8 +1145,15 @@ export interface WaveExecutionResult {
1132
1145
  stoppedEarly: boolean;
1133
1146
  /** Task IDs that failed (including stalled) */
1134
1147
  failedTaskIds: string[];
1135
- /** Task IDs that were skipped (due to pause, prior failure, or policy) */
1148
+ /** Task IDs that were skipped (due to prior failure in lane, or policy) */
1136
1149
  skippedTaskIds: string[];
1150
+ /**
1151
+ * Task IDs that did NOT run to a terminal state because the batch was PAUSED
1152
+ * while they were pending/holding. They remain `pending` (not skipped, not
1153
+ * counted) and re-execute on resume. A wave with any paused task is not
1154
+ * complete; the engine finalizes the batch as `paused` instead of merging.
1155
+ */
1156
+ pausedTaskIds?: string[];
1137
1157
  /** Task IDs that succeeded */
1138
1158
  succeededTaskIds: string[];
1139
1159
  /** Task IDs blocked for future waves (transitive dependents of failed tasks) */
@@ -1191,6 +1211,20 @@ export type OrchBatchPhase =
1191
1211
  * - Tracks pauseSignal for /orch-pause
1192
1212
  * - Accumulates wave results for summary
1193
1213
  */
1214
+ /**
1215
+ * Shared pause signal (engine ⇄ waves ⇄ lanes).
1216
+ *
1217
+ * `cause` says WHY the batch is paused: `operator` = /orch-pause (or an orphan
1218
+ * engine winding down), `abort` = stop-all failure policy / orch_abort,
1219
+ * `stop-wave` reserved for the stop-wave policy. Tier-0 retry may clear ONLY
1220
+ * a policy cause — one boolean let a successful retry erase an operator's
1221
+ * pause (Sage review of the 20260906T194514 incident).
1222
+ */
1223
+ export interface PauseSignal {
1224
+ paused: boolean;
1225
+ cause?: "operator" | "stop-wave" | "abort" | "merge-failure";
1226
+ }
1227
+
1194
1228
  export interface OrchBatchRuntimeState {
1195
1229
  /** Current execution phase */
1196
1230
  phase: OrchBatchPhase;
@@ -1200,10 +1234,18 @@ export interface OrchBatchRuntimeState {
1200
1234
  baseBranch: string;
1201
1235
  /** Orchestrator-managed branch name (e.g., 'orch/henry-20260318T140000'). Empty = legacy mode (merge into baseBranch directly). */
1202
1236
  orchBranch: string;
1237
+ /**
1238
+ * #610: epoch ms when this batch was integrated (manual or auto). Set in
1239
+ * memory by the integration path so a batch-end epilogue that was DEFERRED
1240
+ * behind the integrating turn is skipped instead of showing stale "ready for
1241
+ * integration" banners. Not persisted (the persisted checkpoint is deleted
1242
+ * on integration; batch-history carries its own integratedAt).
1243
+ */
1244
+ integratedAt?: number;
1203
1245
  /** Workspace execution mode (v2). Defaults to "repo" for backward compatibility. */
1204
1246
  mode: WorkspaceMode;
1205
1247
  /** Shared pause signal — set by /orch-pause, read by executeLane/executeWave */
1206
- pauseSignal: { paused: boolean };
1248
+ pauseSignal: PauseSignal;
1207
1249
  /** All wave results in order (grows as waves complete) */
1208
1250
  waveResults: WaveExecutionResult[];
1209
1251
  /** Current wave index (0-based into waves array, -1 if not started) */
@@ -2028,7 +2070,14 @@ export type EngineEventType =
2028
2070
  | "merge_health_dead"
2029
2071
  | "merge_health_stuck"
2030
2072
  | "batch_complete"
2031
- | "batch_paused";
2073
+ | "batch_paused"
2074
+ // Review boundaries (review-boundary supervisor notifications). Bridged from
2075
+ // the per-agent RuntimeAgentEvent review_* stream by lane-runner so the
2076
+ // supervisor's live events.jsonl tailer surfaces every review start/end and
2077
+ // can adjudicate revisions case-by-case.
2078
+ | "review_started"
2079
+ | "review_completed"
2080
+ | "review_failed";
2032
2081
 
2033
2082
  /**
2034
2083
  * Structured engine event written to `.pi/supervisor/events.jsonl`.
@@ -2101,6 +2150,31 @@ export interface EngineEvent {
2101
2150
  healthStatus?: MergeHealthStatus;
2102
2151
  /** Minutes since last activity (for merge_health_warning, merge_health_stuck) */
2103
2152
  stalledMinutes?: number;
2153
+
2154
+ // ── Review-boundary fields (review_started/completed/failed) ────
2155
+
2156
+ /** Worker agent ID that owns the review (for review_* events) */
2157
+ agentId?: string;
2158
+ /** Step number under review (for review_* events) */
2159
+ reviewStep?: number;
2160
+ /** Review type, e.g. "plan" | "code" (for review_* events) */
2161
+ reviewType?: string;
2162
+ /** Normalized reviewer verdict (for review_completed, review_failed) */
2163
+ disposition?: ReviewDisposition;
2164
+ /** Per-step review round (Nth verdict-producing review of this step) */
2165
+ reviewRound?: number;
2166
+ /** Human/file-correlation label, e.g. "R008-code-step4" */
2167
+ reviewLabel?: string;
2168
+ /** Review file path (relative), for correlation / optional re-parse */
2169
+ reviewPath?: string;
2170
+ /** Finding counts by severity label for this review */
2171
+ findingCounts?: Record<string, number>;
2172
+ /** Converging-vs-circling trend vs the previous round */
2173
+ findingTrend?: "dropping" | "flat" | "rising";
2174
+ /** Per-severity delta (curr - prev) */
2175
+ findingDeltas?: Record<string, number>;
2176
+ /** Whether severities moved in opposing directions */
2177
+ findingMixed?: boolean;
2104
2178
  }
2105
2179
 
2106
2180
  /**
@@ -2145,7 +2219,21 @@ export type SupervisorAlertCategory =
2145
2219
  | "worker-exit-intercept"
2146
2220
  | "segment-expansion-requested"
2147
2221
  | "segment-expansion-approved"
2148
- | "segment-expansion-rejected";
2222
+ | "segment-expansion-rejected"
2223
+ // Review-boundary supervisor notifications: an actionable escalation when a
2224
+ // step's reviews are spiraling (repeated non-APPROVE) or the worker tripped
2225
+ // the order-of-operations guard (REFUSED). Delivered `steer` (urgent) so the
2226
+ // supervisor can adjudicate mid-run. `context.reviewInterventionKind`
2227
+ // distinguishes the two situations.
2228
+ | "review-intervention-needed";
2229
+
2230
+ /** Which review situation triggered a `review-intervention-needed` alert. */
2231
+ export type ReviewInterventionKind =
2232
+ | "revision-spiral"
2233
+ | "order-violation"
2234
+ // #626 minimal cut: a task attempted to finalize (.DONE) while a step's
2235
+ // LATEST review verdict is still REVISE/RETHINK — finalization was refused.
2236
+ | "unresolved-verdict";
2149
2237
 
2150
2238
  /**
2151
2239
  * Structured context payload for supervisor alerts.
@@ -2215,6 +2303,31 @@ export interface SupervisorAlertContext {
2215
2303
  messageId?: string;
2216
2304
  /** Segment expansion request ID (for segment-expansion alerts) */
2217
2305
  expansionRequestId?: string;
2306
+ // ── Review-intervention fields (review-intervention-needed alerts) ────
2307
+ /** Which review situation triggered the escalation. */
2308
+ reviewInterventionKind?: ReviewInterventionKind;
2309
+ /** Step number under review. */
2310
+ reviewStep?: number;
2311
+ /** Review type ("plan" | "code"). */
2312
+ reviewType?: string;
2313
+ /** Per-step review round (Nth verdict-producing review of this step). */
2314
+ reviewRound?: number;
2315
+ /** Human/file-correlation label, e.g. "R008-code-step4". */
2316
+ reviewLabel?: string;
2317
+ /** Latest normalized disposition. */
2318
+ disposition?: ReviewDisposition;
2319
+ /** Recent disposition history for this step (oldest→newest, bounded). */
2320
+ recentDispositions?: ReviewDisposition[];
2321
+ /** Consecutive non-APPROVE count for this step at escalation time. */
2322
+ consecutiveNonApprove?: number;
2323
+ /** Finding counts by severity label for the latest review. */
2324
+ findingCounts?: Record<string, number>;
2325
+ /** Converging-vs-circling trend vs the previous round. */
2326
+ findingTrend?: "dropping" | "flat" | "rising";
2327
+ /** Per-severity delta (curr - prev) for the latest review. */
2328
+ findingDeltas?: Record<string, number>;
2329
+ /** Whether severities moved in opposing directions. */
2330
+ findingMixed?: boolean;
2218
2331
  /** Whether partial progress was preserved (for task-failure alerts) */
2219
2332
  partialProgress?: boolean;
2220
2333
  /** Batch progress summary */
@@ -4211,6 +4324,31 @@ export type RuntimeAgentEventType =
4211
4324
  // Exit interception (TP-172)
4212
4325
  | "exit_intercepted";
4213
4326
 
4327
+ /**
4328
+ * Normalized outcome of a `review_step` tool call, extracted from the reviewer
4329
+ * verdict the tool returns to the worker. Used by the review-boundary
4330
+ * notification pipeline (agent-host emits it in `review_completed`; the
4331
+ * supervisor adjudicates on it).
4332
+ *
4333
+ * - `APPROVE` — reviewer approved the step.
4334
+ * - `REVISE` — changes requested (spiral-relevant).
4335
+ * - `RETHINK` — reconsider the approach (spiral-relevant).
4336
+ * - `REFUSED` — the TP-186 death-spiral guard refused to spawn a reviewer
4337
+ * (step prematurely marked Complete). A correctness signal,
4338
+ * not a normal verdict.
4339
+ * - `UNAVAILABLE` — the reviewer subprocess failed / produced no output. A
4340
+ * "reviewer broken" signal (surfaced as `review_failed`), NOT
4341
+ * counted toward the revision spiral.
4342
+ * - `UNKNOWN` — verdict could not be parsed.
4343
+ */
4344
+ export type ReviewDisposition =
4345
+ | "APPROVE"
4346
+ | "REVISE"
4347
+ | "RETHINK"
4348
+ | "REFUSED"
4349
+ | "UNAVAILABLE"
4350
+ | "UNKNOWN";
4351
+
4214
4352
  // ── Runtime V2 Path Helpers (TP-102) ─────────────────────────────────
4215
4353
 
4216
4354
  /**
@@ -9,7 +9,8 @@ import { join, basename, resolve } from "path";
9
9
  import { execLog } from "./execution.ts";
10
10
  import { runGit } from "./git.ts";
11
11
  import { resolveOperatorId } from "./naming.ts";
12
- import { DEFAULT_ORCHESTRATOR_CONFIG, WorktreeError } from "./types.ts";
12
+ import { DEFAULT_ORCHESTRATOR_CONFIG, WorktreeError, runtimeRoot } from "./types.ts";
13
+ import { assessEngineLiveness } from "./engine-identity.ts";
13
14
  import type {
14
15
  AllocatedLane,
15
16
  BulkWorktreeError,
@@ -716,10 +717,48 @@ export function runWindowsCmdRd(absolutePath: string): {
716
717
  * @throws WorktreeError with WORKTREE_REMOVE_FAILED for terminal (non-retriable) errors
717
718
  * @throws WorktreeError with WORKTREE_BRANCH_DELETE_FAILED if branch cleanup fails
718
719
  */
720
+ /**
721
+ * #628: does this worktree have uncommitted changes? Returns the count, 0 for
722
+ * clean, or null when it CANNOT be assessed — e.g. the path is a corrupted or
723
+ * orphaned worktree whose git context resolves to the PARENT repo (running
724
+ * `git status` there would report the parent's state, a false positive).
725
+ * Callers treat null as "proceed with removal" so corruption-recovery paths
726
+ * keep working; only a confirmed-dirty, functioning worktree refuses.
727
+ */
728
+ function worktreeUncommittedCount(worktreePath: string): number | null {
729
+ const top = runGit(["rev-parse", "--show-toplevel"], worktreePath);
730
+ if (!top.ok) return null;
731
+ const norm = (p: string) => {
732
+ // realpathSync.native expands Windows 8.3 short names (HENRYL~1 → HenryLach)
733
+ // so git's long-form output compares equal to a short-form input path.
734
+ let r: string;
735
+ try {
736
+ r = realpathSync.native(p.trim());
737
+ } catch {
738
+ r = resolve(p.trim());
739
+ }
740
+ r = r.replace(/[\\/]+/g, "/");
741
+ return process.platform === "win32" ? r.toLowerCase() : r;
742
+ };
743
+ if (norm(top.stdout) !== norm(worktreePath)) return null; // not this dir's own repo context
744
+ const st = runGit(["status", "--porcelain"], worktreePath);
745
+ if (!st.ok) return null;
746
+ const t = st.stdout.trim();
747
+ return t.length === 0 ? 0 : t.split(/\r?\n/).length;
748
+ }
749
+
719
750
  export function removeWorktree(
720
751
  worktree: WorktreeInfo,
721
752
  repoRoot: string,
722
753
  targetBranch?: string,
754
+ options?: {
755
+ /**
756
+ * #628: permit removal even when the worktree has uncommitted changes.
757
+ * Only pass true when the caller has ALREADY preserved progress (commit,
758
+ * stash, or progress branch). Default false = refuse when dirty.
759
+ */
760
+ allowDirty?: boolean;
761
+ },
723
762
  ): RemoveWorktreeResult {
724
763
  const { path: worktreePath, branch } = worktree;
725
764
 
@@ -755,6 +794,33 @@ export function removeWorktree(
755
794
  };
756
795
  }
757
796
 
797
+ // ── #628: uncommitted-work guard ────────────────────────────
798
+ // Removal uses `git worktree remove --force`, which destroys uncommitted
799
+ // changes. In the reported incident a takeover path removed a held lane's
800
+ // worktree and the worker's uncommitted files were lost (recovered only via
801
+ // dangling objects). Safety invariant: NEVER remove a worktree with
802
+ // uncommitted changes unless the caller explicitly opts in after preserving
803
+ // progress. Refusal is non-fatal — callers already handle removed:false.
804
+ if (pathExists && !options?.allowDirty) {
805
+ const dirtyFileCount = worktreeUncommittedCount(worktreePath);
806
+ if (dirtyFileCount !== null && dirtyFileCount > 0) {
807
+ execLog(
808
+ "cleanup",
809
+ "worktree",
810
+ `REFUSED to remove worktree with ${dirtyFileCount} uncommitted change(s) — commit/stash or pass allowDirty after preserving progress (#628)`,
811
+ { path: worktreePath, branch },
812
+ );
813
+ return {
814
+ removed: false,
815
+ alreadyRemoved: false,
816
+ branchDeleted: false,
817
+ branchPreserved: true,
818
+ refusedDirty: true,
819
+ dirtyFileCount,
820
+ };
821
+ }
822
+ }
823
+
758
824
  // ── Attempt removal with retry/backoff ───────────────────────
759
825
  const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 16000];
760
826
  const MAX_ATTEMPTS = RETRY_DELAYS_MS.length + 1; // first attempt + retries
@@ -2105,9 +2171,31 @@ export function forceCleanupWorktree(
2105
2171
  worktree: WorktreeInfo,
2106
2172
  repoRoot: string,
2107
2173
  batchId: string,
2174
+ options?: {
2175
+ /** #628: permit force-removal even with uncommitted changes. Only after preserving progress. */
2176
+ allowDirty?: boolean;
2177
+ },
2108
2178
  ): void {
2109
2179
  const { path: worktreePath, branch, laneNumber } = worktree;
2110
2180
 
2181
+ // ── #628: uncommitted-work guard (same invariant as removeWorktree) ───
2182
+ // This is the raw-rmSync last resort — without the guard it silently
2183
+ // destroys uncommitted worker files (e.g. batch-start cleanup of a prior
2184
+ // batch's held lane). "Force" here means stubborn-removal MECHANICS
2185
+ // (Windows reserved names), not overriding the data-safety invariant.
2186
+ if (existsSync(worktreePath) && !options?.allowDirty) {
2187
+ const dirtyFileCount = worktreeUncommittedCount(worktreePath);
2188
+ if (dirtyFileCount !== null && dirtyFileCount > 0) {
2189
+ execLog(
2190
+ "cleanup",
2191
+ `lane-${laneNumber}`,
2192
+ `REFUSED force-cleanup: worktree has ${dirtyFileCount} uncommitted change(s) — preserve progress first (#628)`,
2193
+ { path: worktreePath, branch, batchId },
2194
+ );
2195
+ return;
2196
+ }
2197
+ }
2198
+
2111
2199
  // Step 1: Force-remove the directory
2112
2200
  if (existsSync(worktreePath)) {
2113
2201
  try {
@@ -2615,6 +2703,33 @@ export interface StaleBranchCleanupResult {
2615
2703
  deletedSavedBranches: string[];
2616
2704
  /** Branches that failed to delete (best-effort) */
2617
2705
  failedDeletes: string[];
2706
+ /**
2707
+ * #631: branches of OTHER batches that were kept because that batch's engine
2708
+ * is alive, or its ownership is unknown (runtime dir present, no identity).
2709
+ */
2710
+ skippedOwnedBranches?: string[];
2711
+ }
2712
+
2713
+ /**
2714
+ * #631: may a lane branch belonging to ANOTHER batch be swept as an orphan?
2715
+ * The TP-051 operator-wide sweep is kept (orphans from finished batches do
2716
+ * accumulate), but never for a batch whose engine is alive, nor for one whose
2717
+ * ownership is unknown (a runtime dir exists with no engine identity — a
2718
+ * pre-#631 engine of unknown state). No runtime dir at all = no engine
2719
+ * evidence anywhere → a pure leftover → sweepable.
2720
+ */
2721
+ function otherBatchBranchSweepable(ownershipRoot: string, otherBatchId: string): boolean {
2722
+ const liveness = assessEngineLiveness(ownershipRoot, otherBatchId);
2723
+ if (liveness.status === "alive") return false;
2724
+ if (liveness.status === "none" && existsSync(runtimeRoot(ownershipRoot, otherBatchId)))
2725
+ return false;
2726
+ return true;
2727
+ }
2728
+
2729
+ /** `task/{opId}-lane-{N}-{batchId}` / `saved/task/…-{batchId}` → batchId (last dash segment). */
2730
+ function laneBranchBatchId(branch: string): string | null {
2731
+ const m = /-lane-\d+-([A-Za-z0-9._]+)$/.exec(branch);
2732
+ return m ? m[1] : null;
2618
2733
  }
2619
2734
 
2620
2735
  /**
@@ -2645,10 +2760,34 @@ export function deleteStaleBranches(
2645
2760
  repoRoot: string,
2646
2761
  opId: string,
2647
2762
  batchId: string,
2763
+ /**
2764
+ * #631: root under which `.pi/runtime/<batchId>/engine.json` lives (workspace
2765
+ * root in workspace mode). Defaults to repoRoot.
2766
+ */
2767
+ ownershipRoot: string = repoRoot,
2648
2768
  ): StaleBranchCleanupResult {
2649
2769
  const deletedTaskBranches: string[] = [];
2650
2770
  const deletedSavedBranches: string[] = [];
2651
2771
  const failedDeletes: string[] = [];
2772
+ const skippedOwnedBranches: string[] = [];
2773
+ // #631: a lane branch of another batch is only swept when that batch's engine
2774
+ // is verifiably gone and its ownership is not unknown.
2775
+ const guardOtherBatch = (branch: string): boolean => {
2776
+ const other = laneBranchBatchId(branch);
2777
+ if (!other || other === batchId) return true;
2778
+ if (otherBatchBranchSweepable(ownershipRoot, other)) return true;
2779
+ skippedOwnedBranches.push(branch);
2780
+ execLog(
2781
+ "cleanup",
2782
+ batchId,
2783
+ `kept lane branch of another batch (engine alive or ownership unknown, #631)`,
2784
+ {
2785
+ branch,
2786
+ otherBatchId: other,
2787
+ },
2788
+ );
2789
+ return false;
2790
+ };
2652
2791
 
2653
2792
  // 1. Delete task/{opId}-lane-* branches
2654
2793
  const taskBranchResult = runGit(["branch", "--list", `task/${opId}-lane-*`], repoRoot);
@@ -2659,6 +2798,7 @@ export function deleteStaleBranches(
2659
2798
  .filter(Boolean);
2660
2799
 
2661
2800
  for (const branch of branches) {
2801
+ if (!guardOtherBatch(branch)) continue;
2662
2802
  const deleted = deleteBranchBestEffort(branch, repoRoot);
2663
2803
  if (deleted) {
2664
2804
  deletedTaskBranches.push(branch);
@@ -2677,6 +2817,7 @@ export function deleteStaleBranches(
2677
2817
  .filter(Boolean);
2678
2818
 
2679
2819
  for (const branch of branches) {
2820
+ if (!guardOtherBatch(branch)) continue;
2680
2821
  const deleted = deleteBranchBestEffort(branch, repoRoot);
2681
2822
  if (deleted) {
2682
2823
  deletedSavedBranches.push(branch);
@@ -2721,5 +2862,5 @@ export function deleteStaleBranches(
2721
2862
  });
2722
2863
  }
2723
2864
 
2724
- return { deletedTaskBranches, deletedSavedBranches, failedDeletes };
2865
+ return { deletedTaskBranches, deletedSavedBranches, failedDeletes, skippedOwnedBranches };
2725
2866
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.30.5",
3
+ "version": "0.30.6",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",