taskplane 0.22.7 → 0.22.9

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.
@@ -2854,6 +2854,26 @@ export default function (pi: ExtensionAPI) {
2854
2854
  }
2855
2855
  }
2856
2856
 
2857
+ // ── Step transition: kill persistent reviewer for fresh context ──
2858
+ // When a step completes, the reviewer's context from that step is stale.
2859
+ // Kill it so the next step gets a clean reviewer session.
2860
+ if (newlyCompleted.length > 0 && state.persistentReviewerSession) {
2861
+ console.error(`[task-runner] step(s) completed — killing reviewer for fresh context`);
2862
+ logExecution(statusPath, "Reviewer cleanup",
2863
+ `killing persistent reviewer on step transition (${newlyCompleted.map(s => `Step ${s.number}`).join(", ")} completed)`);
2864
+ if (state.persistentReviewerKill) {
2865
+ try { state.persistentReviewerKill(); } catch {}
2866
+ }
2867
+ state.persistentReviewerSession = null;
2868
+ state.persistentReviewerKill = null;
2869
+ state.persistentReviewerSignalNum = 0;
2870
+ state.reviewerRespawnCount = 0;
2871
+ // Reset per-step code review counters for completed steps
2872
+ for (const step of newlyCompleted) {
2873
+ stepCodeReviewCounts.delete(step.number);
2874
+ }
2875
+ }
2876
+
2857
2877
  // Log iteration summary with progress delta and completed steps
2858
2878
  const completedNames = newlyCompleted.map(s => `Step ${s.number}`).join(", ");
2859
2879
  if (newlyCompleted.length > 0) {
@@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "
6
6
  import { join, dirname, basename, resolve } from "path";
7
7
 
8
8
  import { FATAL_DISCOVERY_CODES } from "./types.ts";
9
- import type { DiscoveryError, DiscoveryResult, ParsedTask, TaskArea, WorkspaceConfig } from "./types.ts";
9
+ import type { DiscoveryError, DiscoveryResult, ParsedTask, PromptSegmentDagMetadata, TaskArea, WorkspaceConfig } from "./types.ts";
10
10
 
11
11
  // ── PROMPT.md Parsing ────────────────────────────────────────────────
12
12
 
@@ -56,6 +56,301 @@ export function normalizeDependencyReference(raw: string): string {
56
56
  return parsed.areaName ? `${parsed.areaName}/${parsed.taskId}` : parsed.taskId;
57
57
  }
58
58
 
59
+ const SEGMENT_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
60
+
61
+ function normalizeSegmentRepoToken(raw: string): string {
62
+ let token = raw.trim();
63
+ token = token.replace(/^`(.+)`$/, "$1").trim();
64
+ token = token.replace(/^\*\*(.+)\*\*$/, "$1").trim();
65
+ return token.toLowerCase();
66
+ }
67
+
68
+ interface ParsedSegmentDagBody {
69
+ metadata: PromptSegmentDagMetadata | null;
70
+ error: DiscoveryError | null;
71
+ }
72
+
73
+ /**
74
+ * Parse optional explicit segment DAG metadata from `## Segment DAG`.
75
+ *
76
+ * Supported v1 syntax:
77
+ *
78
+ * ## Segment DAG
79
+ * Repos:
80
+ * - api
81
+ * - web-client
82
+ * Edges:
83
+ * - api -> web-client
84
+ *
85
+ * Notes:
86
+ * - `Repos:` / `Edges:` keys accept markdown decoration (`**Repos:**`) and whitespace.
87
+ * - Repo IDs are normalized to lowercase and validated against routing repo ID rules.
88
+ * - Unknown edge endpoints (not present in explicit repo list) fail fast.
89
+ * - Self-edges and cycles fail fast with `SEGMENT_DAG_INVALID`.
90
+ */
91
+ function parseSegmentDagMetadata(
92
+ content: string,
93
+ taskId: string,
94
+ promptPath: string,
95
+ ): ParsedSegmentDagBody {
96
+ const headerMatch = content.match(/^##\s+Segment DAG\s*$/im);
97
+ if (!headerMatch || headerMatch.index === undefined) {
98
+ return { metadata: null, error: null };
99
+ }
100
+
101
+ const headerIndex = headerMatch.index;
102
+ const afterHeaderIndex = content.indexOf("\n", headerIndex);
103
+ if (afterHeaderIndex === -1) {
104
+ return { metadata: null, error: null };
105
+ }
106
+
107
+ const rest = content.slice(afterHeaderIndex + 1);
108
+ const nextBoundary = rest.search(/^##\s|^---/m);
109
+ const body = nextBoundary !== -1 ? rest.slice(0, nextBoundary) : rest;
110
+
111
+ const repoIds: string[] = [];
112
+ const repoSet = new Set<string>();
113
+ const edgePairs = new Set<string>();
114
+ const edges: Array<{ fromRepoId: string; toRepoId: string }> = [];
115
+ const baseLine = content.slice(0, afterHeaderIndex + 1).split(/\r?\n/).length;
116
+
117
+ let mode: "repos" | "edges" | null = null;
118
+ const lines = body.split(/\r?\n/);
119
+
120
+ for (let i = 0; i < lines.length; i++) {
121
+ const rawLine = lines[i];
122
+ const trimmed = rawLine.trim();
123
+ if (!trimmed) continue;
124
+
125
+ if (/^\*?\*?Repos:?\*?\*?\s*$/i.test(trimmed)) {
126
+ mode = "repos";
127
+ continue;
128
+ }
129
+ if (/^\*?\*?Edges:?\*?\*?\s*$/i.test(trimmed)) {
130
+ mode = "edges";
131
+ continue;
132
+ }
133
+
134
+ if (!mode) {
135
+ return {
136
+ metadata: null,
137
+ error: {
138
+ code: "SEGMENT_DAG_INVALID",
139
+ message:
140
+ `Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
141
+ `expected a Repos: or Edges: subsection header before entries.`,
142
+ taskId,
143
+ taskPath: promptPath,
144
+ },
145
+ };
146
+ }
147
+
148
+ const bulletMatch = rawLine.match(/^\s*[-*]\s+(.+)$/);
149
+ if (!bulletMatch) {
150
+ return {
151
+ metadata: null,
152
+ error: {
153
+ code: "SEGMENT_DAG_INVALID",
154
+ message:
155
+ `Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
156
+ `expected a bullet entry ("- ...").`,
157
+ taskId,
158
+ taskPath: promptPath,
159
+ },
160
+ };
161
+ }
162
+
163
+ const entry = bulletMatch[1].trim();
164
+ if (!entry) continue;
165
+
166
+ if (mode === "repos") {
167
+ if (entry.includes("->")) {
168
+ return {
169
+ metadata: null,
170
+ error: {
171
+ code: "SEGMENT_DAG_INVALID",
172
+ message:
173
+ `Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
174
+ `repo list entries must be a single repo ID.`,
175
+ taskId,
176
+ taskPath: promptPath,
177
+ },
178
+ };
179
+ }
180
+ const repoId = normalizeSegmentRepoToken(entry);
181
+ if (!SEGMENT_REPO_ID_PATTERN.test(repoId)) {
182
+ return {
183
+ metadata: null,
184
+ error: {
185
+ code: "SEGMENT_DAG_INVALID",
186
+ message:
187
+ `Task ${taskId} has invalid repo ID "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
188
+ `Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`,
189
+ taskId,
190
+ taskPath: promptPath,
191
+ },
192
+ };
193
+ }
194
+ if (!repoSet.has(repoId)) {
195
+ repoSet.add(repoId);
196
+ repoIds.push(repoId);
197
+ }
198
+ continue;
199
+ }
200
+
201
+ const edgeMatch = entry.match(/^(.+?)\s*->\s*(.+)$/);
202
+ if (!edgeMatch) {
203
+ return {
204
+ metadata: null,
205
+ error: {
206
+ code: "SEGMENT_DAG_INVALID",
207
+ message:
208
+ `Task ${taskId} has malformed edge "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
209
+ `Expected format: <repo-a> -> <repo-b>.`,
210
+ taskId,
211
+ taskPath: promptPath,
212
+ },
213
+ };
214
+ }
215
+
216
+ const fromRepoId = normalizeSegmentRepoToken(edgeMatch[1]);
217
+ const toRepoId = normalizeSegmentRepoToken(edgeMatch[2]);
218
+ if (!SEGMENT_REPO_ID_PATTERN.test(fromRepoId) || !SEGMENT_REPO_ID_PATTERN.test(toRepoId)) {
219
+ return {
220
+ metadata: null,
221
+ error: {
222
+ code: "SEGMENT_DAG_INVALID",
223
+ message:
224
+ `Task ${taskId} has malformed edge "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
225
+ `Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`,
226
+ taskId,
227
+ taskPath: promptPath,
228
+ },
229
+ };
230
+ }
231
+ if (fromRepoId === toRepoId) {
232
+ return {
233
+ metadata: null,
234
+ error: {
235
+ code: "SEGMENT_DAG_INVALID",
236
+ message:
237
+ `Task ${taskId} has self-edge "${fromRepoId} -> ${toRepoId}" in ## Segment DAG at line ${baseLine + i}.`,
238
+ taskId,
239
+ taskPath: promptPath,
240
+ },
241
+ };
242
+ }
243
+
244
+ const edgeKey = `${fromRepoId}->${toRepoId}`;
245
+ if (!edgePairs.has(edgeKey)) {
246
+ edgePairs.add(edgeKey);
247
+ edges.push({ fromRepoId, toRepoId });
248
+ }
249
+ }
250
+
251
+ if (repoIds.length === 0 && edges.length === 0) {
252
+ return { metadata: null, error: null };
253
+ }
254
+
255
+ for (const edge of edges) {
256
+ if (!repoSet.has(edge.fromRepoId)) {
257
+ return {
258
+ metadata: null,
259
+ error: {
260
+ code: "SEGMENT_REPO_UNKNOWN",
261
+ message:
262
+ `Task ${taskId} has edge endpoint repo "${edge.fromRepoId}" in ## Segment DAG that is not declared in Repos:.`,
263
+ taskId,
264
+ taskPath: promptPath,
265
+ },
266
+ };
267
+ }
268
+ if (!repoSet.has(edge.toRepoId)) {
269
+ return {
270
+ metadata: null,
271
+ error: {
272
+ code: "SEGMENT_REPO_UNKNOWN",
273
+ message:
274
+ `Task ${taskId} has edge endpoint repo "${edge.toRepoId}" in ## Segment DAG that is not declared in Repos:.`,
275
+ taskId,
276
+ taskPath: promptPath,
277
+ },
278
+ };
279
+ }
280
+ }
281
+
282
+ const sortedEdges = [...edges].sort((a, b) => {
283
+ if (a.fromRepoId !== b.fromRepoId) return a.fromRepoId.localeCompare(b.fromRepoId);
284
+ return a.toRepoId.localeCompare(b.toRepoId);
285
+ });
286
+
287
+ const adjacency = new Map<string, string[]>();
288
+ for (const repoId of repoIds) {
289
+ adjacency.set(repoId, []);
290
+ }
291
+ for (const edge of sortedEdges) {
292
+ adjacency.get(edge.fromRepoId)!.push(edge.toRepoId);
293
+ }
294
+ for (const neighbors of adjacency.values()) {
295
+ neighbors.sort();
296
+ }
297
+
298
+ const visited = new Set<string>();
299
+ const stack = new Set<string>();
300
+ const path: string[] = [];
301
+ let cycle: string[] | null = null;
302
+
303
+ function dfs(repoId: string): void {
304
+ if (cycle) return;
305
+ visited.add(repoId);
306
+ stack.add(repoId);
307
+ path.push(repoId);
308
+
309
+ const neighbors = adjacency.get(repoId) || [];
310
+ for (const next of neighbors) {
311
+ if (cycle) return;
312
+ if (!visited.has(next)) {
313
+ dfs(next);
314
+ continue;
315
+ }
316
+ if (stack.has(next)) {
317
+ const start = path.indexOf(next);
318
+ cycle = [...path.slice(start), next];
319
+ return;
320
+ }
321
+ }
322
+
323
+ path.pop();
324
+ stack.delete(repoId);
325
+ }
326
+
327
+ for (const repoId of [...repoIds].sort()) {
328
+ if (!visited.has(repoId)) dfs(repoId);
329
+ if (cycle) break;
330
+ }
331
+
332
+ if (cycle) {
333
+ return {
334
+ metadata: null,
335
+ error: {
336
+ code: "SEGMENT_DAG_INVALID",
337
+ message:
338
+ `Task ${taskId} has cyclic ## Segment DAG metadata: ${cycle.join(" -> ")}.`,
339
+ taskId,
340
+ taskPath: promptPath,
341
+ },
342
+ };
343
+ }
344
+
345
+ return {
346
+ metadata: {
347
+ repoIds,
348
+ edges: sortedEdges,
349
+ },
350
+ error: null,
351
+ };
352
+ }
353
+
59
354
  /**
60
355
  * Parse a PROMPT.md file and extract orchestrator-relevant metadata.
61
356
  *
@@ -255,6 +550,16 @@ export function parsePromptForOrchestrator(
255
550
  }
256
551
  }
257
552
 
553
+ // ── Extract optional explicit segment DAG metadata ──────────
554
+ const segmentDagResult = parseSegmentDagMetadata(content, taskId, resolve(promptPath));
555
+ if (segmentDagResult.error) {
556
+ return {
557
+ task: null,
558
+ error: segmentDagResult.error,
559
+ };
560
+ }
561
+ const explicitSegmentDag = segmentDagResult.metadata;
562
+
258
563
  return {
259
564
  task: {
260
565
  taskId,
@@ -268,6 +573,7 @@ export function parsePromptForOrchestrator(
268
573
  areaName,
269
574
  status: "pending",
270
575
  ...(promptRepoId ? { promptRepoId } : {}),
576
+ ...(explicitSegmentDag ? { explicitSegmentDag } : {}),
271
577
  },
272
578
  error: null,
273
579
  };
@@ -893,6 +1199,22 @@ export function resolveTaskRouting(
893
1199
  const strictMode = workspaceConfig.routing.strict === true;
894
1200
 
895
1201
  for (const task of discovery.pending.values()) {
1202
+ // ── Explicit segment DAG repo validation (workspace IDs) ─
1203
+ if (task.explicitSegmentDag) {
1204
+ const unknownRepos = task.explicitSegmentDag.repoIds.filter((repoId) => !validRepoIds.has(repoId));
1205
+ if (unknownRepos.length > 0) {
1206
+ errors.push({
1207
+ code: "SEGMENT_REPO_UNKNOWN",
1208
+ message:
1209
+ `Task ${task.taskId} declares unknown repo ID(s) in ## Segment DAG: ${unknownRepos.join(", ")}. ` +
1210
+ `Known repos: ${[...validRepoIds.keys()].join(", ")}`,
1211
+ taskId: task.taskId,
1212
+ taskPath: task.promptPath,
1213
+ });
1214
+ continue;
1215
+ }
1216
+ }
1217
+
896
1218
  // ── Strict mode enforcement ──────────────────────────────
897
1219
  // When strict routing is enabled, every task MUST declare an
898
1220
  // explicit execution target in PROMPT.md. Area-level and
@@ -60,6 +60,26 @@ export interface OrchestratorConfig {
60
60
  };
61
61
  }
62
62
 
63
+ /** Stable segment identifier: `<taskId>::<repoId>` */
64
+ export type SegmentId = `${string}::${string}`;
65
+
66
+ /** How an intra-task segment edge was produced (for observability/debugging). */
67
+ export type SegmentEdgeProvenance = "explicit" | "inferred";
68
+
69
+ /** Repo-scoped edge parsed from optional `## Segment DAG` prompt metadata. */
70
+ export interface PromptSegmentDagEdge {
71
+ fromRepoId: string;
72
+ toRepoId: string;
73
+ }
74
+
75
+ /** Optional explicit segment metadata parsed from PROMPT.md. */
76
+ export interface PromptSegmentDagMetadata {
77
+ /** Repo IDs participating in this task's segment graph, first-seen order. */
78
+ repoIds: string[];
79
+ /** Directed repo-level edges, sorted by `fromRepoId` then `toRepoId`. */
80
+ edges: PromptSegmentDagEdge[];
81
+ }
82
+
63
83
  /** A parsed task from PROMPT.md, enriched for orchestrator use */
64
84
  export interface ParsedTask {
65
85
  taskId: string;
@@ -76,8 +96,61 @@ export interface ParsedTask {
76
96
  promptRepoId?: string;
77
97
  /** Resolved repo ID after routing precedence (workspace mode only). Undefined in repo mode. */
78
98
  resolvedRepoId?: string;
99
+ /** Optional explicit segment DAG metadata from `## Segment DAG`. */
100
+ explicitSegmentDag?: PromptSegmentDagMetadata;
101
+ }
102
+
103
+ /** Build a stable segment ID from task + repo identity (`<taskId>::<repoId>`). */
104
+ export function buildSegmentId(taskId: string, repoId: string): SegmentId {
105
+ return `${taskId}::${repoId}` as SegmentId;
106
+ }
107
+
108
+ /** One repo-scoped segment node for a task. */
109
+ export interface TaskSegmentNode {
110
+ segmentId: SegmentId;
111
+ taskId: string;
112
+ repoId: string;
113
+ /**
114
+ * Deterministic segment order within a task (0-indexed).
115
+ * Stable tie-break: repoId lexical order.
116
+ */
117
+ order: number;
79
118
  }
80
119
 
120
+ /** Directed edge between two segment nodes in the same task. */
121
+ export interface TaskSegmentEdge {
122
+ fromSegmentId: SegmentId;
123
+ toSegmentId: SegmentId;
124
+ provenance: SegmentEdgeProvenance;
125
+ /** Optional explanation of why this edge exists (debug/telemetry aid). */
126
+ reason?: string;
127
+ }
128
+
129
+ /**
130
+ * Deterministic segment plan for one task.
131
+ *
132
+ * Ordering contract:
133
+ * - `segments`: sorted by `order`, then `repoId`
134
+ * - `edges`: sorted by `fromSegmentId`, then `toSegmentId`
135
+ */
136
+ export interface TaskSegmentPlan {
137
+ taskId: string;
138
+ segments: TaskSegmentNode[];
139
+ edges: TaskSegmentEdge[];
140
+ /**
141
+ * explicit-dag: parsed from prompt metadata
142
+ * inferred-sequential: deterministic fallback inference
143
+ * repo-singleton: repo mode fallback (`resolvedRepoId ?? "default"`)
144
+ */
145
+ mode: "explicit-dag" | "inferred-sequential" | "repo-singleton";
146
+ }
147
+
148
+ /**
149
+ * TaskId-keyed segment plans.
150
+ * Iteration order must be deterministic: sort task IDs lexicographically.
151
+ */
152
+ export type TaskSegmentPlanMap = Map<string, TaskSegmentPlan>;
153
+
81
154
  /** A wave: a group of tasks whose dependencies are all satisfied */
82
155
  export interface WaveAssignment {
83
156
  waveNumber: number;
@@ -403,7 +476,9 @@ export interface DiscoveryError {
403
476
  | "DEP_SOURCE_FALLBACK"
404
477
  | "TASK_REPO_UNRESOLVED"
405
478
  | "TASK_REPO_UNKNOWN"
406
- | "TASK_ROUTING_STRICT";
479
+ | "TASK_ROUTING_STRICT"
480
+ | "SEGMENT_DAG_INVALID"
481
+ | "SEGMENT_REPO_UNKNOWN";
407
482
  message: string;
408
483
  taskPath?: string;
409
484
  taskId?: string;
@@ -425,6 +500,8 @@ export const FATAL_DISCOVERY_CODES: ReadonlyArray<DiscoveryError["code"]> = [
425
500
  "TASK_REPO_UNRESOLVED",
426
501
  "TASK_REPO_UNKNOWN",
427
502
  "TASK_ROUTING_STRICT",
503
+ "SEGMENT_DAG_INVALID",
504
+ "SEGMENT_REPO_UNKNOWN",
428
505
  ] as const;
429
506
 
430
507
  /** Result of the full discovery pipeline */
@@ -457,6 +534,8 @@ export interface GraphValidationResult {
457
534
  export interface WaveComputationResult {
458
535
  waves: WaveAssignment[];
459
536
  errors: DiscoveryError[];
537
+ /** Optional task→segment planning map (TP-080, additive contract). */
538
+ segmentPlans?: TaskSegmentPlanMap;
460
539
  }
461
540
 
462
541
 
@@ -6,8 +6,8 @@ import { join } from "path";
6
6
 
7
7
  import { parseDependencyReference } from "./discovery.ts";
8
8
  import { resolveOperatorId } from "./naming.ts";
9
- import { AllocationError, getTaskDurationMinutes } from "./types.ts";
10
- import type { AllocatedLane, AllocatedTask, AllocateLanesResult, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, WaveAssignment, WaveComputationResult, WorkspaceConfig, WorktreeInfo } from "./types.ts";
9
+ import { AllocationError, buildSegmentId, getTaskDurationMinutes } from "./types.ts";
10
+ import type { AllocatedLane, AllocatedTask, AllocateLanesResult, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, TaskSegmentPlan, TaskSegmentPlanMap, WaveAssignment, WaveComputationResult, WorkspaceConfig, WorktreeInfo } from "./types.ts";
11
11
  import { getCurrentBranch } from "./git.ts";
12
12
  import { ensureLaneWorktrees, removeAllWorktrees, removeWorktree } from "./worktree.ts";
13
13
 
@@ -606,6 +606,196 @@ export function resolveBaseBranch(
606
606
  }
607
607
 
608
608
 
609
+ // ── Segment Planning (TP-080) ───────────────────────────────────────
610
+
611
+ const SEGMENT_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
612
+ const INFERRED_LINEAR_REASON = "inferred:first-appearance-linear-chain";
613
+
614
+ function normalizeRepoIdCandidate(raw: string): string | null {
615
+ const candidate = raw.trim().toLowerCase();
616
+ if (!SEGMENT_REPO_ID_PATTERN.test(candidate)) return null;
617
+ return candidate;
618
+ }
619
+
620
+ function collectKnownRepoIds(pending: Map<string, ParsedTask>): Set<string> {
621
+ const known = new Set<string>();
622
+ for (const task of pending.values()) {
623
+ if (task.resolvedRepoId) {
624
+ const repoId = normalizeRepoIdCandidate(task.resolvedRepoId);
625
+ if (repoId) known.add(repoId);
626
+ }
627
+ if (task.explicitSegmentDag) {
628
+ for (const repoIdRaw of task.explicitSegmentDag.repoIds) {
629
+ const repoId = normalizeRepoIdCandidate(repoIdRaw);
630
+ if (repoId) known.add(repoId);
631
+ }
632
+ }
633
+ }
634
+ return known;
635
+ }
636
+
637
+ function extractRepoPrefixFromFileScope(fileScopeEntry: string): string | null {
638
+ const normalized = fileScopeEntry.replace(/\\/g, "/").trim();
639
+ if (!normalized) return null;
640
+ const firstSegment = normalized.split("/")[0]?.trim();
641
+ if (!firstSegment) return null;
642
+ return normalizeRepoIdCandidate(firstSegment);
643
+ }
644
+
645
+ interface InferredRepoOrder {
646
+ repoIds: string[];
647
+ usedFallback: boolean;
648
+ }
649
+
650
+ /**
651
+ * Build deterministic repo ordering for inferred segment plans.
652
+ *
653
+ * Signal precedence:
654
+ * 1) file scope repo prefixes (first appearance)
655
+ * 2) dependency task repos (first appearance)
656
+ * 3) fallback to `resolvedRepoId`, then synthetic `default`
657
+ */
658
+ export function inferTaskRepoOrder(
659
+ task: ParsedTask,
660
+ pending: Map<string, ParsedTask>,
661
+ knownRepoIds: Set<string>,
662
+ ): InferredRepoOrder {
663
+ const firstAppearance = new Map<string, number>();
664
+ let cursor = 0;
665
+
666
+ function record(repoIdRaw: string, requireKnown = false): string | null {
667
+ const repoId = normalizeRepoIdCandidate(repoIdRaw);
668
+ if (!repoId) return null;
669
+ if (requireKnown && knownRepoIds.size > 0 && !knownRepoIds.has(repoId)) return null;
670
+ if (!firstAppearance.has(repoId)) {
671
+ firstAppearance.set(repoId, cursor++);
672
+ }
673
+ return repoId;
674
+ }
675
+
676
+ let hasPrimarySignal = false;
677
+
678
+ for (const scopeEntry of task.fileScope) {
679
+ if (knownRepoIds.size === 0) {
680
+ // Repo-mode guard: without known workspace repo IDs, fileScope prefixes like
681
+ // "src/" or "lib/" are ambiguous and should not create synthetic segments.
682
+ continue;
683
+ }
684
+ const repoId = extractRepoPrefixFromFileScope(scopeEntry);
685
+ if (!repoId) continue;
686
+ if (record(repoId, true) !== null) {
687
+ hasPrimarySignal = true;
688
+ }
689
+ }
690
+
691
+ for (const depRaw of task.dependencies) {
692
+ const depId = parseDependencyReference(depRaw).taskId;
693
+ const depTask = pending.get(depId);
694
+ if (depTask?.resolvedRepoId && record(depTask.resolvedRepoId, true) !== null) {
695
+ hasPrimarySignal = true;
696
+ }
697
+ }
698
+
699
+ if (!hasPrimarySignal) {
700
+ const fallback = normalizeRepoIdCandidate(task.resolvedRepoId ?? "") || "default";
701
+ return {
702
+ repoIds: [fallback],
703
+ usedFallback: true,
704
+ };
705
+ }
706
+
707
+ if (task.resolvedRepoId) {
708
+ record(task.resolvedRepoId, true);
709
+ }
710
+
711
+ const repoIds = [...firstAppearance.entries()]
712
+ .sort((a, b) => {
713
+ if (a[1] !== b[1]) return a[1] - b[1];
714
+ return a[0].localeCompare(b[0]);
715
+ })
716
+ .map(([repoId]) => repoId);
717
+
718
+ return {
719
+ repoIds,
720
+ usedFallback: false,
721
+ };
722
+ }
723
+
724
+ function sortSegmentEdges<T extends { fromSegmentId: string; toSegmentId: string }>(
725
+ edges: T[],
726
+ ): T[] {
727
+ return [...edges].sort((a, b) => {
728
+ if (a.fromSegmentId !== b.fromSegmentId) return a.fromSegmentId.localeCompare(b.fromSegmentId);
729
+ return a.toSegmentId.localeCompare(b.toSegmentId);
730
+ });
731
+ }
732
+
733
+ function buildSegmentNodes(taskId: string, repoIds: string[]) {
734
+ const nodes = repoIds.map((repoId, order) => ({
735
+ segmentId: buildSegmentId(taskId, repoId),
736
+ taskId,
737
+ repoId,
738
+ order,
739
+ }));
740
+ return nodes.sort((a, b) => (a.order - b.order) || a.repoId.localeCompare(b.repoId));
741
+ }
742
+
743
+ export function buildSegmentPlanForTask(
744
+ task: ParsedTask,
745
+ pending: Map<string, ParsedTask>,
746
+ knownRepoIds: Set<string>,
747
+ ): TaskSegmentPlan {
748
+ if (task.explicitSegmentDag) {
749
+ const repoIds = [...task.explicitSegmentDag.repoIds];
750
+ const segments = buildSegmentNodes(task.taskId, repoIds);
751
+ const edges = sortSegmentEdges(
752
+ task.explicitSegmentDag.edges.map((edge) => ({
753
+ fromSegmentId: buildSegmentId(task.taskId, edge.fromRepoId),
754
+ toSegmentId: buildSegmentId(task.taskId, edge.toRepoId),
755
+ provenance: "explicit" as const,
756
+ reason: "prompt:segment-dag",
757
+ })),
758
+ );
759
+ return {
760
+ taskId: task.taskId,
761
+ segments,
762
+ edges,
763
+ mode: "explicit-dag",
764
+ };
765
+ }
766
+
767
+ const inferred = inferTaskRepoOrder(task, pending, knownRepoIds);
768
+ const segments = buildSegmentNodes(task.taskId, inferred.repoIds);
769
+ const edges = sortSegmentEdges(
770
+ segments.slice(0, -1).map((segment, idx) => ({
771
+ fromSegmentId: segment.segmentId,
772
+ toSegmentId: segments[idx + 1].segmentId,
773
+ provenance: "inferred" as const,
774
+ reason: INFERRED_LINEAR_REASON,
775
+ })),
776
+ );
777
+
778
+ return {
779
+ taskId: task.taskId,
780
+ segments,
781
+ edges,
782
+ mode: inferred.usedFallback ? "repo-singleton" : "inferred-sequential",
783
+ };
784
+ }
785
+
786
+ /** Build a deterministic taskId→segmentPlan map for the whole pending set. */
787
+ export function buildTaskSegmentPlans(pending: Map<string, ParsedTask>): TaskSegmentPlanMap {
788
+ const knownRepoIds = collectKnownRepoIds(pending);
789
+ const plans: TaskSegmentPlanMap = new Map();
790
+ for (const taskId of [...pending.keys()].sort()) {
791
+ const task = pending.get(taskId);
792
+ if (!task) continue;
793
+ plans.set(taskId, buildSegmentPlanForTask(task, pending, knownRepoIds));
794
+ }
795
+ return plans;
796
+ }
797
+
798
+
609
799
  // ── Lane Assignment ──────────────────────────────────────────────────
610
800
 
611
801
  /**
@@ -1181,6 +1371,9 @@ export function computeWaveAssignments(
1181
1371
  return { waves: [], errors: waveErrors };
1182
1372
  }
1183
1373
 
1374
+ // Step 3.5: Build additive segment planning output (deterministic map)
1375
+ const segmentPlans = buildTaskSegmentPlans(pending);
1376
+
1184
1377
  // Step 4: Assign tasks to lanes within each wave
1185
1378
  const waveAssignments: WaveAssignment[] = [];
1186
1379
  for (let i = 0; i < rawWaves.length; i++) {
@@ -1199,5 +1392,5 @@ export function computeWaveAssignments(
1199
1392
  });
1200
1393
  }
1201
1394
 
1202
- return { waves: waveAssignments, errors };
1395
+ return { waves: waveAssignments, errors, segmentPlans };
1203
1396
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.22.7",
3
+ "version": "0.22.9",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -70,12 +70,11 @@ Use the source branch and merge message from the merge request.
70
70
 
71
71
  ### Step 4: Verification
72
72
 
73
- Run each verification command from the merge request. Typical commands:
73
+ Run each verification command from the merge request's `## Verification Commands`
74
+ section **exactly as written**. Do NOT substitute your own test commands — the
75
+ merge request contains the project's actual test command.
74
76
 
75
- ```bash
76
- npm test # Unit/integration checks
77
- npm run build # Build/compile checks
78
- ```
77
+ If the verification section is empty, skip verification and proceed with the merge.
79
78
 
80
79
  **If verification passes:** Write result with `status: "SUCCESS"` (or
81
80
  `"CONFLICT_RESOLVED"` if conflicts were auto-resolved).
@@ -205,7 +205,8 @@ value.
205
205
  - **APPROVE** → proceed to next step
206
206
  - **RETHINK** → reconsider your plan approach, adjust, then implement
207
207
  - **REVISE** → read the review file in `.reviews/` for detailed feedback,
208
- address the issues, commit fixes, then proceed
208
+ address the issues, commit fixes, then **call `review_step` again** for re-review.
209
+ The same reviewer evaluates whether your fixes address its concerns.
209
210
  - **UNAVAILABLE** → reviewer failed, proceed with caution
210
211
 
211
212
  **Example flow for a Review Level 2 task, Step 3:**
@@ -215,8 +216,9 @@ value.
215
216
  4. Implement Step 3
216
217
  5. Commit changes
217
218
  6. Call `review_step(step=3, type="code", baseline="<saved SHA>")` → get code feedback
218
- 7. If REVISE: fix issues, commit again
219
- 8. Move to Step 4
219
+ 7. If REVISE: fix issues, commit, call `review_step(step=3, type="code")` again
220
+ 8. Repeat 7 until APPROVE (max 2 code review cycles per step)
221
+ 9. Move to Step 4
220
222
 
221
223
  If the `review_step` tool is not available (e.g., non-orchestrated mode), skip
222
224
  this protocol entirely — the task-runner handles reviews externally.