taskplane 0.27.0 → 0.28.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.
@@ -662,6 +662,27 @@ export function parseWorktreeStatusMd(
662
662
  *
663
663
  * @since TP-070
664
664
  */
665
+
666
+ /**
667
+ * Parse STATUS.md directly from a known absolute path.
668
+ * Unlike parseWorktreeStatusMdAsync, this does NOT re-resolve the path —
669
+ * it reads exactly the file you point it to. Use this when the caller
670
+ * already has the authoritative statusPath (e.g., from buildExecutionUnit).
671
+ *
672
+ * @since TP-501
673
+ */
674
+ export async function parseStatusMdAtPath(
675
+ statusPath: string,
676
+ ): Promise<{ parsed: ParsedWorktreeStatus | null; error: string | null }> {
677
+ return parseStatusMdContent(statusPath);
678
+ }
679
+
680
+ /**
681
+ * Parse STATUS.md by resolving the path from taskFolder + worktree context.
682
+ * Use parseStatusMdAtPath instead when the caller already has the authoritative path.
683
+ *
684
+ * @since TP-070
685
+ */
665
686
  export async function parseWorktreeStatusMdAsync(
666
687
  taskFolder: string,
667
688
  worktreePath: string,
@@ -669,8 +690,13 @@ export async function parseWorktreeStatusMdAsync(
669
690
  isWorkspaceMode?: boolean,
670
691
  ): Promise<{ parsed: ParsedWorktreeStatus | null; error: string | null }> {
671
692
  const resolved = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot, isWorkspaceMode);
672
- const statusPath = resolved.statusPath;
693
+ return parseStatusMdContent(resolved.statusPath);
694
+ }
673
695
 
696
+ /** Shared STATUS.md content parser — reads and parses from a known path. Handles file-not-found. */
697
+ async function parseStatusMdContent(
698
+ statusPath: string,
699
+ ): Promise<{ parsed: ParsedWorktreeStatus | null; error: string | null }> {
674
700
  if (!(await fileExistsAsync(statusPath))) {
675
701
  return { parsed: null, error: `STATUS.md not found at ${statusPath}` };
676
702
  }
@@ -1188,7 +1214,7 @@ export async function monitorLanes(
1188
1214
  const unit = buildExecutionUnit(lane, task, repoRoot, isWorkspaceMode);
1189
1215
  const donePath = unit.packet.donePath;
1190
1216
  const statusPath = unit.packet.statusPath;
1191
- const statusResult = await parseWorktreeStatusMdAsync(dirname(statusPath), lane.worktreePath, repoRoot, false);
1217
+ const statusResult = await parseStatusMdAtPath(statusPath);
1192
1218
 
1193
1219
  const snapshot = await resolveTaskMonitorState(
1194
1220
  task.taskId,
@@ -2516,6 +2542,7 @@ export async function executeLaneV2(
2516
2542
  // rules: checkpoint discipline, STATUS.md resume algorithm, review_step instructions.
2517
2543
  // The local file (.pi/agents/task-worker.md) adds project-specific guidance.
2518
2544
  let workerSystemPrompt = "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each.";
2545
+ let workerSegmentPrompt = "";
2519
2546
  try {
2520
2547
  const basePrompt = loadBaseAgentPrompt("task-worker");
2521
2548
  const localPrompt = loadLocalAgentPrompt(stateRoot, "task-worker");
@@ -2526,6 +2553,9 @@ export async function executeLaneV2(
2526
2553
  } else if (localPrompt) {
2527
2554
  workerSystemPrompt = localPrompt;
2528
2555
  }
2556
+ // Load segment-scoped prompt overlay (appended when isSegmentScoped)
2557
+ const segPrompt = loadBaseAgentPrompt("task-worker-segment");
2558
+ if (segPrompt) workerSegmentPrompt = segPrompt;
2529
2559
  } catch { /* use default */ }
2530
2560
 
2531
2561
  execLog(laneId, "LANE", `starting Runtime V2 execution of ${lane.tasks.length} task(s)`, {
@@ -2572,6 +2602,7 @@ export async function executeLaneV2(
2572
2602
  workerTools: "read,write,edit,bash,grep,find,ls",
2573
2603
  workerThinking: "",
2574
2604
  workerSystemPrompt,
2605
+ workerSegmentPrompt,
2575
2606
  reviewerModel: extraEnvVars?.TASKPLANE_REVIEWER_MODEL || "",
2576
2607
  reviewerThinking: extraEnvVars?.TASKPLANE_REVIEWER_THINKING || "",
2577
2608
  reviewerTools: extraEnvVars?.TASKPLANE_REVIEWER_TOOLS || "",
@@ -62,10 +62,114 @@ import {
62
62
  type LaneTaskOutcome,
63
63
  type LaneTaskStatus,
64
64
  type SupervisorAlertCallback,
65
+ type StepSegmentMapping,
65
66
  } from "./types.ts";
66
67
 
67
68
  const LANE_RUNNER_DIR = dirname(fileURLToPath(import.meta.url));
68
69
 
70
+ // ── Segment Scoping Helpers (Phase A, TP-174) ────────────────────────
71
+
72
+ /**
73
+ * Get the set of step numbers that have segments for a given repoId.
74
+ *
75
+ * Used to filter the "remaining steps" view so the worker only sees steps
76
+ * that contain work for its repo.
77
+ *
78
+ * @param stepSegmentMap - Parsed step-segment mapping from PROMPT.md
79
+ * @param repoId - Repo ID to filter by
80
+ * @returns Set of step numbers that have at least one segment for this repoId
81
+ * @since TP-174
82
+ */
83
+ export function getStepsForRepoId(
84
+ stepSegmentMap: StepSegmentMapping[],
85
+ repoId: string,
86
+ ): Set<number> {
87
+ const stepNumbers = new Set<number>();
88
+ for (const step of stepSegmentMap) {
89
+ if (step.segments.some(seg => seg.repoId === repoId)) {
90
+ stepNumbers.add(step.stepNumber);
91
+ }
92
+ }
93
+ return stepNumbers;
94
+ }
95
+
96
+ /**
97
+ * Extract a segment's checkbox block from STATUS.md content for a given step and repoId.
98
+ *
99
+ * Looks for `#### Segment: <repoId>` headers within `### Step N:` sections,
100
+ * then returns the checkbox lines belonging to that segment block.
101
+ *
102
+ * @param statusContent - Raw STATUS.md content
103
+ * @param stepNumber - Step number to look in
104
+ * @param repoId - Repo ID of the segment
105
+ * @returns Object with checked/unchecked counts, or null if no segment block found
106
+ * @since TP-174
107
+ */
108
+ export function getSegmentCheckboxes(
109
+ statusContent: string,
110
+ stepNumber: number,
111
+ repoId: string,
112
+ ): { checked: number; unchecked: number; total: number; uncheckedTexts: string[] } | null {
113
+ const text = statusContent.replace(/\r\n/g, "\n");
114
+
115
+ // Find the step section
116
+ const stepHeaderPattern = new RegExp(`^###\\s+Step\\s+${stepNumber}:`, "m");
117
+ const stepMatch = text.match(stepHeaderPattern);
118
+ if (!stepMatch || stepMatch.index === undefined) return null;
119
+
120
+ // Find the end of this step section (next ### or end of file)
121
+ const afterStep = text.slice(stepMatch.index + stepMatch[0].length);
122
+ const nextStepMatch = afterStep.search(/^###\s+Step\s+\d+:/m);
123
+ const stepContent = nextStepMatch !== -1 ? afterStep.slice(0, nextStepMatch) : afterStep;
124
+
125
+ // Find the segment header within this step
126
+ const segHeaderPattern = new RegExp(`^####\\s+Segment:\\s*${repoId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
127
+ const segMatch = stepContent.match(segHeaderPattern);
128
+ if (!segMatch || segMatch.index === undefined) return null;
129
+
130
+ // Extract content from segment header to next #### header or ### header or ---
131
+ const afterSeg = stepContent.slice(segMatch.index + segMatch[0].length);
132
+ const nextSectionMatch = afterSeg.search(/^(?:####\s|###\s|---)/m);
133
+ const segContent = nextSectionMatch !== -1 ? afterSeg.slice(0, nextSectionMatch) : afterSeg;
134
+
135
+ // Count checkboxes
136
+ let checked = 0;
137
+ let unchecked = 0;
138
+ const uncheckedTexts: string[] = [];
139
+ const cbRegex = /^\s*-\s*\[([ xX])\]\s*(.*)/gm;
140
+ let m;
141
+ while ((m = cbRegex.exec(segContent)) !== null) {
142
+ if (m[1].toLowerCase() === "x") {
143
+ checked++;
144
+ } else {
145
+ unchecked++;
146
+ uncheckedTexts.push(m[2].trim());
147
+ }
148
+ }
149
+
150
+ return { checked, unchecked, total: checked + unchecked, uncheckedTexts };
151
+ }
152
+
153
+ /**
154
+ * Check if all checkboxes in a segment block are checked.
155
+ *
156
+ * @param statusContent - Raw STATUS.md content
157
+ * @param stepNumber - Step number to check
158
+ * @param repoId - Repo ID of the segment
159
+ * @returns true when all checkboxes in the segment block are checked
160
+ * @since TP-174
161
+ */
162
+ export function isSegmentComplete(
163
+ statusContent: string,
164
+ stepNumber: number,
165
+ repoId: string,
166
+ ): boolean {
167
+ const result = getSegmentCheckboxes(statusContent, stepNumber, repoId);
168
+ if (!result) return false;
169
+ if (result.total === 0) return false;
170
+ return result.unchecked === 0;
171
+ }
172
+
69
173
  // ── Types ────────────────────────────────────────────────────────────
70
174
 
71
175
  /**
@@ -94,8 +198,10 @@ export interface LaneRunnerConfig {
94
198
  workerTools: string;
95
199
  /** Worker thinking mode */
96
200
  workerThinking: string;
97
- /** Worker system prompt */
201
+ /** Worker system prompt (full-task mode) */
98
202
  workerSystemPrompt: string;
203
+ /** Worker system prompt for segment-scoped mode (appended to base) */
204
+ workerSegmentPrompt: string;
99
205
  /**
100
206
  * Reviewer model (empty string = inherit session default).
101
207
  * Set from TASKPLANE_REVIEWER_MODEL env var, sourced from runnerConfig.reviewer.model.
@@ -217,17 +323,53 @@ export async function executeTaskV2(
217
323
  // TP-115: carry latest worker telemetry across iterations and into post-loop terminal snapshots
218
324
  let lastTelemetry: Partial<AgentHostResult> = {};
219
325
 
326
+ // TP-174: Build segment context once for emitSnapshot calls.
327
+ // Available outside the loop so it can be passed to makeResult too.
328
+ const snapshotSegmentCtx: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null =
329
+ (segmentId && unit.task.stepSegmentMap && config.repoId)
330
+ ? (() => {
331
+ const repoSteps = getStepsForRepoId(unit.task.stepSegmentMap!, config.repoId);
332
+ return repoSteps.size > 0
333
+ ? { stepSegmentMap: unit.task.stepSegmentMap!, repoId: config.repoId }
334
+ : null;
335
+ })()
336
+ : null;
337
+
220
338
  for (let iter = 0; iter < config.maxIterations; iter++) {
221
339
  if (pauseSignal.paused) {
222
340
  logExecution(statusPath, "Paused", `User paused at iteration ${totalIterations}`);
223
341
  return makeResult(taskId, segmentId, workerAgentId, "skipped", startTime,
224
- "Paused by user", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath);
342
+ "Paused by user", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, undefined, snapshotSegmentCtx);
225
343
  }
226
344
 
227
345
  // Determine remaining steps
228
346
  const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
229
347
  const parsed = parsePromptMd(readFileSync(promptPath, "utf-8"), promptPath);
348
+
349
+ // TP-174: Resolve segment-scoped step filtering.
350
+ // Use config.repoId (structured identity) instead of parsing opaque segmentId.
351
+ const stepSegmentMap = unit.task.stepSegmentMap;
352
+ const currentRepoId = segmentId ? config.repoId : null;
353
+ const rawRepoStepNumbers = (stepSegmentMap && currentRepoId)
354
+ ? getStepsForRepoId(stepSegmentMap, currentRepoId)
355
+ : null;
356
+ // TP-174 legacy fallback: If no steps have segments for this repoId
357
+ // (multi-segment task without explicit markers, where all checkboxes
358
+ // are assigned to the fallback/packet repo), disable segment filtering.
359
+ const repoStepNumbers = (rawRepoStepNumbers && rawRepoStepNumbers.size > 0)
360
+ ? rawRepoStepNumbers
361
+ : null;
362
+
363
+ // TP-174: Read STATUS.md content once for segment-scoped checks
364
+ const iterStatusContent = readFileSync(statusPath, "utf-8");
365
+
230
366
  const remainingSteps = parsed.steps.filter(step => {
367
+ // TP-174: When segment-scoped, only show steps that have work for this repoId
368
+ if (repoStepNumbers && !repoStepNumbers.has(step.number)) return false;
369
+ // TP-174: Use segment-scoped completion check in segment mode
370
+ if (repoStepNumbers && currentRepoId) {
371
+ return !isSegmentComplete(iterStatusContent, step.number, currentRepoId);
372
+ }
231
373
  const ss = currentStatus.steps.find(s => s.number === step.number);
232
374
  return !isStepComplete(ss);
233
375
  });
@@ -247,12 +389,26 @@ export async function executeTaskV2(
247
389
  }
248
390
 
249
391
  // Count checkboxes before worker runs
250
- const prevTotalChecked = currentStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
392
+ // TP-174: When segment-scoped, count only this segment's checkboxes
393
+ let prevTotalChecked: number;
394
+ if (repoStepNumbers && currentRepoId) {
395
+ const preStatusContent = readFileSync(statusPath, "utf-8");
396
+ const segCbs = getSegmentCheckboxes(preStatusContent, firstStep.number, currentRepoId);
397
+ prevTotalChecked = segCbs ? segCbs.checked : 0;
398
+ } else {
399
+ prevTotalChecked = currentStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
400
+ }
251
401
 
252
402
  // ── Build worker prompt ─────────────────────────────────────
253
403
  const wrapUpFile = join(taskFolder, ".task-wrap-up");
254
404
  if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ }
255
405
 
406
+ // TP-174/TP-501: Compute segment scope mode BEFORE building prompt.
407
+ const isSegmentScoped = !!(stepSegmentMap && currentRepoId && repoStepNumbers
408
+ && remainingSteps.length > 0
409
+ && stepSegmentMap.find(s => s.stepNumber === remainingSteps[0].number)
410
+ ?.segments.find(seg => seg.repoId === currentRepoId));
411
+
256
412
  const promptLines = [
257
413
  `Read your task instructions at: ${promptPath}`,
258
414
  `Read your execution state at: ${statusPath}`,
@@ -266,7 +422,11 @@ export async function executeTaskV2(
266
422
  `- Execution repo ID: ${unit.executionRepoId}`,
267
423
  `- Execution worktree (worker cwd): ${unit.worktreePath}`,
268
424
  `- Lane repo ID: ${config.repoId}`,
269
- `- Active segment ID: ${segmentId ?? "(none / whole-task execution)"}`,
425
+ // Only show segment ID when segment-scoped. For FULL_TASK, omit to avoid
426
+ // workers incorrectly self-scoping based on segment metadata.
427
+ ...(isSegmentScoped
428
+ ? [`- Active segment ID: ${segmentId}`]
429
+ : []),
270
430
  ``,
271
431
  `Packet home context:`,
272
432
  `- Packet home repo ID: ${unit.packetHomeRepoId}`,
@@ -281,7 +441,8 @@ export async function executeTaskV2(
281
441
  `⚠️ CHECKPOINT RULE: After completing EACH checkbox item, immediately edit STATUS.md to check it off (- [ ] → - [x]) BEFORE starting the next item. Do NOT batch checkbox updates at the end of a step.`,
282
442
  ];
283
443
 
284
- const segmentDag = unit.task.explicitSegmentDag;
444
+ // Only show segment DAG in segment-scoped mode
445
+ const segmentDag = isSegmentScoped ? unit.task.explicitSegmentDag : null;
285
446
  if (segmentDag && segmentDag.repoIds.length > 0) {
286
447
  const edgeSummary = segmentDag.edges.length > 0
287
448
  ? segmentDag.edges.map(edge => `${edge.fromRepoId}->${edge.toRepoId}`).join(", ")
@@ -294,6 +455,68 @@ export async function executeTaskV2(
294
455
  );
295
456
  }
296
457
 
458
+ // Segment scope mode is determined by which system prompt was loaded.
459
+ // No SegmentScopeMode line needed — the prompt IS the mode.
460
+
461
+ // TP-174: Segment-scoped prompt — show only this segment's checkboxes
462
+ if (stepSegmentMap && currentRepoId && repoStepNumbers && remainingSteps.length > 0) {
463
+ const currentStepNum = remainingSteps[0].number;
464
+ const currentStepMapping = stepSegmentMap.find(s => s.stepNumber === currentStepNum);
465
+ const mySegment = currentStepMapping?.segments.find(seg => seg.repoId === currentRepoId);
466
+
467
+ // Only inject segment-scoped prompt when the current step has an explicit
468
+ // segment for this repoId. If mySegment is missing (legacy task without
469
+ // markers, or step has no work for this repo), skip and preserve legacy behavior.
470
+ if (currentStepMapping && mySegment) {
471
+ const otherSegments = currentStepMapping.segments.filter(seg => seg.repoId !== currentRepoId);
472
+
473
+ // Count total segments for this repo across all steps
474
+ const totalStepsForRepo = repoStepNumbers ? repoStepNumbers.size : 0;
475
+ const segmentIndexInStep = currentStepMapping.segments.findIndex(seg => seg.repoId === currentRepoId) + 1;
476
+ const totalSegmentsInStep = currentStepMapping.segments.length;
477
+
478
+ promptLines.push(
479
+ ``,
480
+ `Segment-scoped context (Phase A):`,
481
+ `Active segment: ${segmentId} (Step ${currentStepNum}, segment ${segmentIndexInStep} of ${totalSegmentsInStep})`,
482
+ `Your repo: ${currentRepoId}`,
483
+ ``,
484
+ );
485
+
486
+ if (mySegment && mySegment.checkboxes.length > 0) {
487
+ promptLines.push(`Your checkboxes for this step:`);
488
+ for (const cb of mySegment.checkboxes) {
489
+ promptLines.push(` ${cb}`);
490
+ }
491
+ }
492
+
493
+ if (otherSegments.length > 0) {
494
+ promptLines.push(``);
495
+ promptLines.push(`Other segments in this step (NOT yours — do not attempt):`);
496
+ for (const seg of otherSegments) {
497
+ promptLines.push(` - ${seg.repoId}: ${seg.checkboxes.length} checkbox(es) (will run in a separate segment)`);
498
+ }
499
+ }
500
+
501
+ // List completed steps for this repo
502
+ const completedForRepo = parsed.steps.filter(step => {
503
+ if (!repoStepNumbers || !repoStepNumbers.has(step.number)) return false;
504
+ const ss = currentStatus.steps.find(s => s.number === step.number);
505
+ return isStepComplete(ss);
506
+ });
507
+ if (completedForRepo.length > 0) {
508
+ promptLines.push(``);
509
+ promptLines.push(`Prior steps completed: ${completedForRepo.map(s => `Step ${s.number} (${s.name})`).join(", ")}`);
510
+ }
511
+
512
+ promptLines.push(
513
+ ``,
514
+ `When all YOUR checkboxes are checked, your segment is done — exit successfully.`,
515
+ `Do NOT attempt work in other repos.`,
516
+ );
517
+ }
518
+ }
519
+
297
520
  if (totalIterations > 1 && remainingSteps.length > 0) {
298
521
  const remainingSet = new Set(remainingSteps.map(s => s.number));
299
522
  const completedSteps = parsed.steps.filter(s => !remainingSet.has(s.number));
@@ -341,7 +564,9 @@ export async function executeTaskV2(
341
564
  repoId: config.repoId,
342
565
  cwd: unit.worktreePath,
343
566
  prompt: promptLines.join("\n"),
344
- systemPrompt: config.workerSystemPrompt || undefined,
567
+ systemPrompt: (isSegmentScoped && config.workerSegmentPrompt
568
+ ? config.workerSystemPrompt + "\n\n---\n\n" + config.workerSegmentPrompt
569
+ : config.workerSystemPrompt) || undefined,
345
570
  model: config.workerModel || undefined,
346
571
  tools: config.workerTools || "read,write,edit,bash,grep,find,ls",
347
572
  thinking: config.workerThinking || undefined,
@@ -363,7 +588,10 @@ export async function executeTaskV2(
363
588
  TASKPLANE_REVIEWER_STATE_PATH: reviewerStatePath,
364
589
  TASKPLANE_PROJECT_NAME: config.projectName || "project",
365
590
  TASKPLANE_TASK_ID: taskId,
366
- TASKPLANE_ACTIVE_SEGMENT_ID: segmentId ?? "",
591
+ // Hard-set segment env vars based on mode. In FULL_TASK mode,
592
+ // explicitly clear them to prevent env inheritance leaking segment cues.
593
+ TASKPLANE_ACTIVE_SEGMENT_ID: isSegmentScoped ? (segmentId ?? "") : "",
594
+ TASKPLANE_SEGMENT_ID: isSegmentScoped ? (segmentId ?? "") : "",
367
595
  TASKPLANE_SUPERVISOR_AUTONOMY: config.supervisorAutonomy || "autonomous",
368
596
  ORCH_BATCH_ID: config.batchId,
369
597
  ...(config.reviewerModel ? { TASKPLANE_REVIEWER_MODEL: config.reviewerModel } : {}),
@@ -379,8 +607,15 @@ export async function executeTaskV2(
379
607
  // 2. Blocker logged (non-empty Blockers section)
380
608
  try {
381
609
  const statusContent = readFileSync(statusPath, "utf-8");
382
- const midStatus = parseStatusMd(statusContent);
383
- const midTotalChecked = midStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
610
+ // TP-174: Use same scope as prevTotalChecked (segment or global)
611
+ let midTotalChecked: number;
612
+ if (repoStepNumbers && currentRepoId) {
613
+ const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
614
+ midTotalChecked = segCbs ? segCbs.checked : 0;
615
+ } else {
616
+ const midStatus = parseStatusMd(statusContent);
617
+ midTotalChecked = midStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
618
+ }
384
619
  if (midTotalChecked > prevTotalChecked) {
385
620
  // Worker checked off checkboxes — let it exit normally
386
621
  return null;
@@ -402,10 +637,20 @@ export async function executeTaskV2(
402
637
  const uncheckedItems: string[] = [];
403
638
  try {
404
639
  const statusContent = readFileSync(statusPath, "utf-8");
405
- const uncheckedMatches = statusContent.match(/^- \[ \] .+$/gm);
406
- if (uncheckedMatches) {
407
- for (const item of uncheckedMatches.slice(0, 5)) {
408
- uncheckedItems.push(item.replace(/^- \[ \] /, "").trim());
640
+ // TP-174: When segment-scoped, report only this segment's unchecked items
641
+ if (repoStepNumbers && currentRepoId) {
642
+ const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId);
643
+ if (segCbs) {
644
+ for (const text of segCbs.uncheckedTexts.slice(0, 5)) {
645
+ uncheckedItems.push(text);
646
+ }
647
+ }
648
+ } else {
649
+ const uncheckedMatches = statusContent.match(/^- \[ \] .+$/gm);
650
+ if (uncheckedMatches) {
651
+ for (const item of uncheckedMatches.slice(0, 5)) {
652
+ uncheckedItems.push(item.replace(/^- \[ \] /, "").trim());
653
+ }
409
654
  }
410
655
  }
411
656
  } catch { /* best effort */ }
@@ -523,7 +768,7 @@ export async function executeTaskV2(
523
768
  iterationTelemetry = telemetry;
524
769
  lastTelemetry = telemetry;
525
770
  // Emit lane snapshot
526
- emitSnapshot(config, taskId, segmentId, "running", telemetry, statusPath, reviewerStatePath);
771
+ emitSnapshot(config, taskId, segmentId, "running", telemetry, statusPath, reviewerStatePath, snapshotSegmentCtx);
527
772
  } catch { /* non-fatal: telemetry callback must never crash the engine */ }
528
773
  });
529
774
 
@@ -533,7 +778,7 @@ export async function executeTaskV2(
533
778
  let reviewerSnapshotFailures = 0;
534
779
  const reviewerRefreshFailureThreshold = 5;
535
780
  const reviewerRefresh = setInterval(() => {
536
- const ok = emitSnapshot(config, taskId, segmentId, "running", iterationTelemetry, statusPath, reviewerStatePath);
781
+ const ok = emitSnapshot(config, taskId, segmentId, "running", iterationTelemetry, statusPath, reviewerStatePath, snapshotSegmentCtx);
537
782
  if (ok) {
538
783
  reviewerSnapshotFailures = 0;
539
784
  return;
@@ -653,8 +898,16 @@ export async function executeTaskV2(
653
898
  `${statusMsg} in ${Math.round(workerResult.durationMs / 1000)}s, tools: ${workerResult.toolCalls}`);
654
899
 
655
900
  // ── Check progress ──────────────────────────────────────────
656
- const afterStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
657
- const afterTotalChecked = afterStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
901
+ const afterStatusContent = readFileSync(statusPath, "utf-8");
902
+ const afterStatus = parseStatusMd(afterStatusContent);
903
+ // TP-174: Segment-scoped progress delta
904
+ let afterTotalChecked: number;
905
+ if (repoStepNumbers && currentRepoId) {
906
+ const segCbs = getSegmentCheckboxes(afterStatusContent, firstStep.number, currentRepoId);
907
+ afterTotalChecked = segCbs ? segCbs.checked : 0;
908
+ } else {
909
+ afterTotalChecked = afterStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
910
+ }
658
911
  const progressDelta = afterTotalChecked - prevTotalChecked;
659
912
 
660
913
  if (progressDelta <= 0) {
@@ -689,7 +942,7 @@ export async function executeTaskV2(
689
942
  if (noProgressCount >= config.noProgressLimit) {
690
943
  logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
691
944
  return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
692
- `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
945
+ `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
693
946
  }
694
947
  }
695
948
  } else {
@@ -697,41 +950,93 @@ export async function executeTaskV2(
697
950
  }
698
951
 
699
952
  // Mark completed steps
700
- for (const step of parsed.steps) {
701
- const ss = afterStatus.steps.find(s => s.number === step.number);
702
- if (isStepComplete(ss)) {
703
- updateStepStatus(statusPath, step.number, "complete");
953
+ // TP-174: When segment-scoped, mark step complete when the segment's
954
+ // checkboxes are all checked (not the full step which may have other segments).
955
+ if (repoStepNumbers && currentRepoId) {
956
+ for (const stepNum of repoStepNumbers) {
957
+ if (isSegmentComplete(afterStatusContent, stepNum, currentRepoId)) {
958
+ // Only mark step complete in STATUS.md if ALL segments in that step
959
+ // are complete (not just ours). But for loop exit, we only care about ours.
960
+ const ss = afterStatus.steps.find(s => s.number === stepNum);
961
+ if (isStepComplete(ss)) {
962
+ updateStepStatus(statusPath, stepNum, "complete");
963
+ }
964
+ }
965
+ }
966
+ } else {
967
+ for (const step of parsed.steps) {
968
+ const ss = afterStatus.steps.find(s => s.number === step.number);
969
+ if (isStepComplete(ss)) {
970
+ updateStepStatus(statusPath, step.number, "complete");
971
+ }
704
972
  }
705
973
  }
706
974
 
707
975
  // Check if all steps are now complete
708
- const allComplete = parsed.steps.every(step => {
709
- const ss = afterStatus.steps.find(s => s.number === step.number);
710
- return isStepComplete(ss);
711
- });
976
+ // TP-174: When segment-scoped, exit when all steps for this repoId
977
+ // have their segment checkboxes complete.
978
+ let allComplete: boolean;
979
+ if (repoStepNumbers && currentRepoId) {
980
+ allComplete = [...repoStepNumbers].every(stepNum =>
981
+ isSegmentComplete(afterStatusContent, stepNum, currentRepoId),
982
+ );
983
+ } else {
984
+ allComplete = parsed.steps.every(step => {
985
+ const ss = afterStatus.steps.find(s => s.number === step.number);
986
+ return isStepComplete(ss);
987
+ });
988
+ }
712
989
  if (allComplete) break;
713
990
  }
714
991
 
715
992
  // ── 3. Post-loop completion check ───────────────────────────────
716
- const finalStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
993
+ const finalStatusContent = readFileSync(statusPath, "utf-8");
994
+ const finalStatus = parseStatusMd(finalStatusContent);
717
995
  const parsed = parsePromptMd(readFileSync(promptPath, "utf-8"), promptPath);
718
- const allStepsComplete = parsed.steps.every(step => {
719
- const ss = finalStatus.steps.find(s => s.number === step.number);
720
- return isStepComplete(ss);
721
- });
996
+
997
+ // TP-174: Segment-scoped post-loop check. Re-derive repo scoping since
998
+ // the iteration loop variables are out of scope here.
999
+ const postLoopRepoId = segmentId ? config.repoId : null;
1000
+ const postLoopStepSegMap = unit.task.stepSegmentMap;
1001
+ const postLoopRepoSteps = (postLoopStepSegMap && postLoopRepoId)
1002
+ ? getStepsForRepoId(postLoopStepSegMap, postLoopRepoId)
1003
+ : null;
1004
+ const effectivePostLoopRepoSteps = (postLoopRepoSteps && postLoopRepoSteps.size > 0)
1005
+ ? postLoopRepoSteps
1006
+ : null;
1007
+
1008
+ let allStepsComplete: boolean;
1009
+ if (effectivePostLoopRepoSteps && postLoopRepoId) {
1010
+ allStepsComplete = [...effectivePostLoopRepoSteps].every(stepNum =>
1011
+ isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId),
1012
+ );
1013
+ } else {
1014
+ allStepsComplete = parsed.steps.every(step => {
1015
+ const ss = finalStatus.steps.find(s => s.number === step.number);
1016
+ return isStepComplete(ss);
1017
+ });
1018
+ }
722
1019
 
723
1020
  if (!allStepsComplete) {
724
- const incomplete = parsed.steps
725
- .filter(step => {
726
- const ss = finalStatus.steps.find(s => s.number === step.number);
727
- return !isStepComplete(ss);
728
- })
729
- .map(s => `Step ${s.number}`)
730
- .join(", ");
1021
+ let incomplete: string;
1022
+ if (effectivePostLoopRepoSteps && postLoopRepoId) {
1023
+ incomplete = [...effectivePostLoopRepoSteps]
1024
+ .filter(stepNum => !isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId))
1025
+ .map(n => `Step ${n}`)
1026
+ .join(", ");
1027
+ } else {
1028
+ incomplete = parsed.steps
1029
+ .filter(step => {
1030
+ const ss = finalStatus.steps.find(s => s.number === step.number);
1031
+ return !isStepComplete(ss);
1032
+ })
1033
+ .map(s => `Step ${s.number}`)
1034
+ .join(", ");
1035
+ }
731
1036
  logExecution(statusPath, "Task incomplete", `Max iterations reached. Incomplete: ${incomplete}`);
732
1037
  return makeResult(taskId, segmentId, workerAgentId, "failed", startTime,
733
1038
  `Max iterations (${config.maxIterations}) reached with incomplete steps: ${incomplete}`,
734
- false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
1039
+ false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
735
1040
  }
736
1041
 
737
1042
  // TP-145: Determine if this is a non-final segment of a multi-segment task.
@@ -774,7 +1079,7 @@ export async function executeTaskV2(
774
1079
  ? "non-final"
775
1080
  : "pending expansion requests";
776
1081
  return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
777
- `Segment completed (${suppressionReason} — .DONE suppressed)`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
1082
+ `Segment completed (${suppressionReason} — .DONE suppressed)`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
778
1083
  }
779
1084
 
780
1085
  // Create .DONE if not already present (final segment or single-segment/whole-task execution)
@@ -785,7 +1090,7 @@ export async function executeTaskV2(
785
1090
  logExecution(statusPath, "Task complete", ".DONE created");
786
1091
 
787
1092
  return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime,
788
- ".DONE file created by lane-runner", true, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry);
1093
+ ".DONE file created by lane-runner", true, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx);
789
1094
  }
790
1095
 
791
1096
  // ── Helpers ──────────────────────────────────────────────────────────
@@ -846,6 +1151,8 @@ function makeResult(
846
1151
  statusPath?: string,
847
1152
  reviewerStatePath?: string,
848
1153
  finalTelemetry?: Partial<AgentHostResult>,
1154
+ /** TP-174: Segment context for segment-scoped snapshot progress */
1155
+ segmentCtx?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null,
849
1156
  ): LaneRunnerTaskResult {
850
1157
  const telemetry = status === "skipped"
851
1158
  ? undefined
@@ -880,7 +1187,7 @@ function makeResult(
880
1187
  // TP-115: Emit terminal snapshot with real telemetry from agent-host result
881
1188
  if (config && statusPath && reviewerStatePath) {
882
1189
  const terminalStatus = mapLaneTaskStatusToTerminalSnapshotStatus(status);
883
- emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath);
1190
+ emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath, segmentCtx);
884
1191
  }
885
1192
 
886
1193
  return result;
@@ -957,6 +1264,8 @@ function emitSnapshot(
957
1264
  telemetry: Partial<AgentHostResult>,
958
1265
  statusPath: string,
959
1266
  reviewerStatePath: string,
1267
+ /** TP-174: Optional segment context for segment-scoped progress reporting */
1268
+ segmentContext?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null,
960
1269
  ): boolean {
961
1270
  try {
962
1271
  // Parse progress from STATUS.md
@@ -965,8 +1274,30 @@ function emitSnapshot(
965
1274
  const content = readFileSync(statusPath, "utf-8");
966
1275
  const parsed = parseStatusMd(content);
967
1276
  const currentStepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
968
- const checked = parsed.steps.reduce((sum, s) => sum + s.totalChecked, 0);
969
- const total = parsed.steps.reduce((sum, s) => sum + s.totalItems, 0);
1277
+
1278
+ // TP-174: Segment-scoped progress when segment markers are present.
1279
+ // Only count checkboxes from steps that belong to this segment's repoId.
1280
+ let checked: number;
1281
+ let total: number;
1282
+ if (segmentContext) {
1283
+ const { stepSegmentMap, repoId } = segmentContext;
1284
+ const repoSteps = getStepsForRepoId(stepSegmentMap, repoId);
1285
+ let segChecked = 0;
1286
+ let segTotal = 0;
1287
+ for (const stepNum of repoSteps) {
1288
+ const segCbs = getSegmentCheckboxes(content, stepNum, repoId);
1289
+ if (segCbs) {
1290
+ segChecked += segCbs.checked;
1291
+ segTotal += segCbs.total;
1292
+ }
1293
+ }
1294
+ checked = segChecked;
1295
+ total = segTotal;
1296
+ } else {
1297
+ checked = parsed.steps.reduce((sum, s) => sum + s.totalChecked, 0);
1298
+ total = parsed.steps.reduce((sum, s) => sum + s.totalItems, 0);
1299
+ }
1300
+
970
1301
  progress = {
971
1302
  currentStep: currentStepMatch?.[1]?.trim() || "Unknown",
972
1303
  checked,