taskplane 0.7.2 → 0.8.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.
@@ -17,9 +17,11 @@ function formatDuration(ms) {
17
17
  return `${m}m ${String(s).padStart(2, "0")}s`;
18
18
  }
19
19
 
20
- function relativeTime(epochMs) {
21
- if (!epochMs) return "";
22
- const diff = Date.now() - epochMs;
20
+ function relativeTime(epochOrIso) {
21
+ if (!epochOrIso) return "";
22
+ const ts = typeof epochOrIso === "string" ? new Date(epochOrIso).getTime() : epochOrIso;
23
+ if (isNaN(ts)) return "";
24
+ const diff = Date.now() - ts;
23
25
  if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
24
26
  if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
25
27
  return `${Math.floor(diff / 3600000)}h ago`;
@@ -711,17 +713,16 @@ function renderNoBatch() {
711
713
  if ($mergePanel) $mergePanel.style.display = "none";
712
714
  if ($errorsPanel) $errorsPanel.style.display = "none";
713
715
 
714
- // Try to show the latest history entry
715
- if (historyList.length > 0 && !viewingHistoryId) {
716
- viewHistoryEntry(historyList[0].batchId);
717
- $historySelect.value = historyList[0].batchId;
718
- } else if (historyList.length === 0) {
719
- // No history yet — show placeholder in the history panel
716
+ // Show a placeholder while history loads. loadHistoryList() (called in
717
+ // render() just before this) is async — the fresh list may not be
718
+ // available yet. The loadHistoryList callback will replace this with
719
+ // the actual latest entry once it resolves.
720
+ if (!viewingHistoryId) {
720
721
  $historyBody.innerHTML = `
721
722
  <div class="no-batch">
722
723
  <div class="no-batch-icon">⏳</div>
723
- <div class="no-batch-title">No batch running</div>
724
- <div class="no-batch-hint">.pi/batch-state.json not found<br>Start an orchestrator batch to see the dashboard.</div>
724
+ <div class="no-batch-title">Batch complete</div>
725
+ <div class="no-batch-hint">Loading history…</div>
725
726
  </div>`;
726
727
  $historyPanel.style.display = "";
727
728
  }
@@ -1405,10 +1406,22 @@ function loadHistoryList() {
1405
1406
  .then(list => {
1406
1407
  historyList = list || [];
1407
1408
  renderHistoryDropdown();
1408
- // If no live batch and no history shown yet, auto-select latest
1409
- if (noBatchRendered && !viewingHistoryId && historyList.length > 0) {
1409
+ // Auto-select the latest history entry when no live batch is running.
1410
+ // Always update the view here renderNoBatch() shows a placeholder
1411
+ // while this async fetch completes, so we need to replace it with
1412
+ // the actual latest entry. This fixes #20 where the stale cached
1413
+ // historyList caused the previous batch to be shown.
1414
+ if (noBatchRendered && historyList.length > 0) {
1410
1415
  viewHistoryEntry(historyList[0].batchId);
1411
1416
  $historySelect.value = historyList[0].batchId;
1417
+ } else if (noBatchRendered && historyList.length === 0) {
1418
+ $historyBody.innerHTML = `
1419
+ <div class="no-batch">
1420
+ <div class="no-batch-icon">⏳</div>
1421
+ <div class="no-batch-title">No batch running</div>
1422
+ <div class="no-batch-hint">.pi/batch-state.json not found<br>Start an orchestrator batch to see the dashboard.</div>
1423
+ </div>`;
1424
+ $historyPanel.style.display = "";
1412
1425
  }
1413
1426
  })
1414
1427
  .catch(() => {});
@@ -164,7 +164,7 @@ const DEFAULT_CONFIG: TaskConfig = {
164
164
  worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "off" },
165
165
  reviewer: { model: "openai/gpt-5.3-codex", tools: "read,bash,grep,find,ls", thinking: "on" },
166
166
  context: {
167
- worker_context_window: 200000, warn_percent: 70, kill_percent: 85,
167
+ worker_context_window: 0, warn_percent: 85, kill_percent: 95,
168
168
  max_worker_iterations: 20, max_review_cycles: 2, no_progress_limit: 3,
169
169
  },
170
170
  quality_gate: {
@@ -309,6 +309,46 @@ function getMaxWorkerMinutes(config: TaskConfig): number {
309
309
  return 30;
310
310
  }
311
311
 
312
+ // ── Context Window Resolution ─────────────────────────────────────────
313
+
314
+ /** Default fallback context window when neither config nor model provides a value. */
315
+ const FALLBACK_CONTEXT_WINDOW = 200_000;
316
+
317
+ /**
318
+ * Resolve the effective context window size for worker spawning.
319
+ *
320
+ * Resolution order (first non-zero value wins):
321
+ * 1. Explicit user config (worker_context_window > 0 in config)
322
+ * 2. Auto-detect from pi model registry (ctx.model.contextWindow)
323
+ * 3. Fallback to 200K tokens
324
+ *
325
+ * A config value of 0 signals "auto-detect" — the default when no explicit
326
+ * value is configured. This allows pi's model registry to provide the real
327
+ * context window for the active model.
328
+ *
329
+ * @returns Object with `contextWindow` (resolved size) and `source` (diagnostic label)
330
+ */
331
+ function resolveContextWindow(
332
+ config: TaskConfig,
333
+ ctx: ExtensionContext,
334
+ ): { contextWindow: number; source: string } {
335
+ // 1. Explicit user config — non-zero means the user set it deliberately
336
+ const configVal = config.context.worker_context_window;
337
+ if (configVal > 0) {
338
+ return { contextWindow: configVal, source: "explicit config" };
339
+ }
340
+
341
+ // 2. Auto-detect from pi model registry
342
+ const modelWindow = ctx.model?.contextWindow;
343
+ if (modelWindow && modelWindow > 0) {
344
+ const modelId = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unknown";
345
+ return { contextWindow: modelWindow, source: `auto-detected from ${modelId}` };
346
+ }
347
+
348
+ // 3. Fallback
349
+ return { contextWindow: FALLBACK_CONTEXT_WINDOW, source: `fallback ${FALLBACK_CONTEXT_WINDOW}` };
350
+ }
351
+
312
352
  // ── Orchestrator Sidecar Files ────────────────────────────────────────
313
353
 
314
354
  /**
@@ -876,6 +916,27 @@ function getHeadCommitSha(): string {
876
916
  }
877
917
  }
878
918
 
919
+ /**
920
+ * Find the git commit SHA where a specific step was completed.
921
+ * Workers commit at step boundaries with messages like:
922
+ * feat(TP-048): complete Step N — description
923
+ * Returns the commit SHA if found, or empty string.
924
+ */
925
+ function findStepBoundaryCommit(stepNumber: number, taskId: string, since?: string): string {
926
+ try {
927
+ // Search git log for the step completion commit
928
+ const args = ["log", "--oneline", "--grep", `complete Step ${stepNumber}`, "--grep", taskId, "--all-match", "-1", "--format=%H"];
929
+ if (since) args.push(`${since}..HEAD`);
930
+ const result = spawnSync("git", args, {
931
+ encoding: "utf-8",
932
+ timeout: 5000,
933
+ });
934
+ return result.status === 0 ? (result.stdout || "").trim() : "";
935
+ } catch {
936
+ return "";
937
+ }
938
+ }
939
+
879
940
  // ── Standards Resolution ─────────────────────────────────────────────
880
941
 
881
942
  /**
@@ -1296,6 +1357,8 @@ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): Sideca
1296
1357
  export const _tailSidecarJsonl = tailSidecarJsonl;
1297
1358
  export const _createSidecarTailState = createSidecarTailState;
1298
1359
  export const _getSidecarDir = getSidecarDir;
1360
+ export const _resolveContextWindow = resolveContextWindow;
1361
+ export const _FALLBACK_CONTEXT_WINDOW = FALLBACK_CONTEXT_WINDOW;
1299
1362
  export type { SidecarTailState, SidecarTelemetryDelta };
1300
1363
 
1301
1364
  // ── Exit Summary & Diagnostic ────────────────────────────────────────
@@ -1907,29 +1970,266 @@ export default function (pi: ExtensionAPI) {
1907
1970
  updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
1908
1971
  logExecution(statusPath, "Task started", "Extension-driven execution");
1909
1972
 
1910
- // Find first incomplete step
1911
- const status = parseStatusMd(readFileSync(statusPath, "utf-8"));
1912
- let startStep = 0;
1913
- for (const s of status.steps) {
1914
- if (s.status === "complete") startStep = s.number + 1;
1915
- else break;
1973
+ // ── Per-task worker loop ─────────────────────────────────────
1974
+ // Spawn one worker per iteration; each worker handles ALL remaining
1975
+ // steps. Reviews run after the worker exits, per newly-completed step.
1976
+ // If context limit is hit mid-task, the next iteration picks up from
1977
+ // the first incomplete step via STATUS.md same recovery mechanism.
1978
+
1979
+ // Collect baseline commits per step (for code review diffs).
1980
+ // Baselines are updated at step boundaries so each code review
1981
+ // sees only that step's changes, not cumulative diffs.
1982
+ const stepBaselineCommits = new Map<number, string>();
1983
+
1984
+ // Track steps that received a REVISE verdict and need rework.
1985
+ // This prevents the checkbox-count heuristic from re-completing them
1986
+ // before the worker has had a chance to address reviewer feedback.
1987
+ const needsRework = new Set<number>();
1988
+
1989
+ // Track which steps have already received a plan review so we don't
1990
+ // re-run plan review on rework cycles (only code review reruns).
1991
+ const planReviewedSteps = new Set<number>();
1992
+
1993
+ // Mark all incomplete steps as in-progress and capture the baseline
1994
+ // commit for the first one. Reviews are transition-based: they run
1995
+ // when a step newly completes after the worker exits, not up-front.
1996
+ {
1997
+ const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
1998
+ for (const step of task.steps) {
1999
+ const ss = currentStatus.steps.find(s => s.number === step.number);
2000
+ if (ss?.status === "complete") continue;
2001
+
2002
+ // Mark step as in-progress and log its start
2003
+ updateStepStatus(statusPath, step.number, "in-progress");
2004
+ logExecution(statusPath, `Step ${step.number} started`, step.name);
2005
+
2006
+ // Capture baseline commit for the FIRST incomplete step only.
2007
+ // Later steps get their baselines when the prior step completes
2008
+ // (see the newlyCompleted handler in the iteration loop).
2009
+ // This prevents cross-step diff bleeding in code reviews.
2010
+ if (!stepBaselineCommits.size) {
2011
+ stepBaselineCommits.set(step.number, getHeadCommitSha());
2012
+ }
2013
+ }
1916
2014
  }
1917
2015
 
1918
- for (let i = 0; i < task.steps.length; i++) {
1919
- const step = task.steps[i];
1920
- if (step.number < startStep) continue;
2016
+ // Helper: determine if a parsed step is complete.
2017
+ // A step in needsRework (set after REVISE) is never complete, even if
2018
+ // all checkboxes are checked — the worker must address the feedback first.
2019
+ function isStepComplete(ss: StepInfo | undefined): boolean {
2020
+ if (!ss) return false;
2021
+ if (needsRework.has(ss.number)) return false;
2022
+ if (ss.status === "complete") return true;
2023
+ // Fallback: infer from checkboxes (covers "in-progress" and "not-started")
2024
+ return ss.totalChecked === ss.totalItems && ss.totalItems > 0;
2025
+ }
2026
+
2027
+ let noProgressCount = 0;
2028
+ for (let iter = 0; iter < config.context.max_worker_iterations; iter++) {
1921
2029
  if (state.phase === "paused") {
1922
- logExecution(statusPath, "Paused", `User paused at Step ${step.number}`);
1923
- ctx.ui.notify(`Task paused at Step ${step.number}`, "info");
2030
+ logExecution(statusPath, "Paused", `User paused at iteration ${iter + 1}`);
2031
+ ctx.ui.notify(`Task paused at iteration ${iter + 1}`, "info");
1924
2032
  return;
1925
2033
  }
1926
2034
 
1927
- state.currentStep = step.number;
2035
+ // Determine remaining (incomplete) steps
2036
+ const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
2037
+ const remainingSteps: StepInfo[] = [];
2038
+ for (const step of task.steps) {
2039
+ const ss = currentStatus.steps.find(s => s.number === step.number);
2040
+ if (!isStepComplete(ss)) remainingSteps.push(step);
2041
+ }
2042
+
2043
+ if (remainingSteps.length === 0) break; // All steps done
2044
+
2045
+ state.currentStep = remainingSteps[0].number;
2046
+ updateStatusField(statusPath, "Current Step", `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`);
2047
+ state.workerIteration = iter + 1;
2048
+ state.totalIterations++;
2049
+ updateStatusField(statusPath, "Iteration", `${state.totalIterations}`);
1928
2050
  updateWidgets();
1929
2051
 
1930
- await executeStep(step, ctx);
2052
+ // Count total checked checkboxes across all steps BEFORE worker runs
2053
+ const prevTotalChecked = currentStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
2054
+
2055
+ // Track which steps are complete before the worker runs
2056
+ const completedBefore = new Set<number>();
2057
+ for (const ss of currentStatus.steps) {
2058
+ if (isStepComplete(ss)) completedBefore.add(ss.number);
2059
+ }
2060
+
2061
+ // Ensure the first remaining step has a baseline. On subsequent
2062
+ // iterations (after context-limit recovery), the first remaining step
2063
+ // may not have a baseline yet if it wasn't the first step originally.
2064
+ if (remainingSteps.length > 0 && !stepBaselineCommits.has(remainingSteps[0].number)) {
2065
+ stepBaselineCommits.set(remainingSteps[0].number, getHeadCommitSha());
2066
+ }
2067
+
2068
+ await runWorker(remainingSteps, ctx);
2069
+
2070
+ if (state.phase === "error") return;
1931
2071
 
1932
- if (state.phase === "error" || state.phase === "paused") return;
2072
+ // ── Post-worker: determine which steps were newly completed ──
2073
+ const afterStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
2074
+ const afterTotalChecked = afterStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
2075
+
2076
+ // Progress tracking: compare total checked across ALL steps
2077
+ const progressDelta = afterTotalChecked - prevTotalChecked;
2078
+ if (progressDelta <= 0) {
2079
+ noProgressCount++;
2080
+ logExecution(statusPath, "No progress", `Iteration ${iter + 1}: 0 new checkboxes (${noProgressCount}/${config.context.no_progress_limit} stall limit)`);
2081
+ ctx.ui.notify(`⚠️ No progress in iteration ${iter + 1} (${noProgressCount}/${config.context.no_progress_limit})`, "warning");
2082
+ if (noProgressCount >= config.context.no_progress_limit) {
2083
+ logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
2084
+ ctx.ui.notify(`⚠️ Task blocked — no progress after ${noProgressCount} iterations`, "error");
2085
+ state.phase = "error";
2086
+ return;
2087
+ }
2088
+ } else {
2089
+ noProgressCount = 0;
2090
+ }
2091
+
2092
+ // Find newly completed steps.
2093
+ // For steps in needsRework, the worker must have addressed the
2094
+ // reviewer feedback — we detect this by checking if STATUS.md was
2095
+ // updated (the worker adds/checks revision items). We temporarily
2096
+ // remove the step from needsRework to let isStepComplete() evaluate
2097
+ // the checkbox state, then re-add if it's still not complete.
2098
+ const newlyCompleted: StepInfo[] = [];
2099
+ for (const step of task.steps) {
2100
+ if (completedBefore.has(step.number)) continue;
2101
+ const ss = afterStatus.steps.find(s => s.number === step.number);
2102
+ if (needsRework.has(step.number)) {
2103
+ // For rework steps, check if the worker set status to "complete"
2104
+ // or if the step now has all checkboxes checked (worker addressed feedback)
2105
+ if (ss?.status === "complete" ||
2106
+ (ss && ss.totalChecked === ss.totalItems && ss.totalItems > 0)) {
2107
+ needsRework.delete(step.number);
2108
+ updateStepStatus(statusPath, step.number, "complete");
2109
+ logExecution(statusPath, `Step ${step.number} complete`, `${step.name} (rework)`);
2110
+ newlyCompleted.push(step);
2111
+ }
2112
+ } else if (isStepComplete(ss)) {
2113
+ updateStepStatus(statusPath, step.number, "complete");
2114
+ logExecution(statusPath, `Step ${step.number} complete`, step.name);
2115
+ newlyCompleted.push(step);
2116
+ }
2117
+ }
2118
+
2119
+ // Update baselines for subsequent steps using step boundary commits.
2120
+ // When the worker completes multiple steps in one iteration, each step's
2121
+ // commit becomes the baseline for the next step's code review.
2122
+ if (newlyCompleted.length > 1) {
2123
+ for (let i = 0; i < newlyCompleted.length; i++) {
2124
+ const completedStep = newlyCompleted[i];
2125
+ const boundaryCommit = findStepBoundaryCommit(
2126
+ completedStep.number, task.taskId,
2127
+ stepBaselineCommits.get(completedStep.number)
2128
+ );
2129
+ if (boundaryCommit && i + 1 < newlyCompleted.length) {
2130
+ // Use this step's completion commit as the next step's baseline
2131
+ stepBaselineCommits.set(newlyCompleted[i + 1].number, boundaryCommit);
2132
+ }
2133
+ }
2134
+ }
2135
+
2136
+ // Log iteration summary with progress delta and completed steps
2137
+ const completedNames = newlyCompleted.map(s => `Step ${s.number}`).join(", ");
2138
+ if (newlyCompleted.length > 0) {
2139
+ logExecution(statusPath, `Iteration ${iter + 1} summary`, `+${progressDelta} checkboxes, completed: ${completedNames}`);
2140
+ ctx.ui.notify(`Iteration ${iter + 1}: completed ${completedNames} (+${progressDelta} checkboxes)`, "info");
2141
+ } else if (progressDelta > 0) {
2142
+ logExecution(statusPath, `Iteration ${iter + 1} summary`, `+${progressDelta} checkboxes, no steps fully completed`);
2143
+ ctx.ui.notify(`Iteration ${iter + 1}: +${progressDelta} checkboxes (no steps fully completed)`, "info");
2144
+ }
2145
+
2146
+ // ── Run reviews for newly completed steps (transition-based) ──
2147
+ // Plan reviews run once per step (first completion); code reviews
2148
+ // run on every completion (including rework). Both respect review
2149
+ // level and low-risk skip logic.
2150
+ // Gate on phase !== "error" so reviews still run when paused
2151
+ // (pause is honored after reviews, before next iteration).
2152
+ if (state.phase !== "error") {
2153
+ for (const step of newlyCompleted) {
2154
+ const lowRisk = isLowRiskStep(step.number, task.steps.length);
2155
+
2156
+ // ── Plan review (level ≥ 1, first completion only) ──
2157
+ if (task.reviewLevel >= 1 && !planReviewedSteps.has(step.number)) {
2158
+ if (lowRisk) {
2159
+ const label = step.number === 0 ? "Preflight" : "final step";
2160
+ logExecution(statusPath, `Skip plan review`, `Step ${step.number} (${label}) — low-risk`);
2161
+ ctx.ui.notify(`⏭️ Skipping plan review for Step ${step.number} (${label})`, "info");
2162
+ } else {
2163
+ const verdict = await doReview("plan", step, ctx);
2164
+ if (verdict === "RETHINK") {
2165
+ ctx.ui.notify(`Reviewer: RETHINK on Step ${step.number} plan. Proceeding with caution.`, "warning");
2166
+ }
2167
+ }
2168
+ planReviewedSteps.add(step.number);
2169
+ }
2170
+
2171
+ // ── Code review (level ≥ 2) ──
2172
+ if (task.reviewLevel >= 2) {
2173
+ if (lowRisk) {
2174
+ const label = step.number === 0 ? "Preflight" : "final step";
2175
+ logExecution(statusPath, `Skip code review`, `Step ${step.number} (${label}) — low-risk`);
2176
+ ctx.ui.notify(`⏭️ Skipping code review for Step ${step.number} (${label})`, "info");
2177
+ } else {
2178
+ const baseline = stepBaselineCommits.get(step.number);
2179
+ const verdict = await doReview("code", step, ctx, baseline);
2180
+ if (verdict === "REVISE") {
2181
+ ctx.ui.notify(`Reviewer: REVISE on Step ${step.number}. Will rework in next iteration.`, "warning");
2182
+ // Mark step as needing rework — undo the "complete" status.
2183
+ // needsRework ensures isStepComplete() won't re-complete this
2184
+ // step based on checkbox counts alone; the worker must address
2185
+ // the reviewer feedback first, which will update STATUS.md and
2186
+ // clear the rework flag when the step is newly completed again.
2187
+ needsRework.add(step.number);
2188
+ updateStepStatus(statusPath, step.number, "in-progress");
2189
+ // Update baseline so the next code review for this step
2190
+ // diffs only the rework changes, not the original step work
2191
+ stepBaselineCommits.set(step.number, getHeadCommitSha());
2192
+ }
2193
+ }
2194
+ }
2195
+ }
2196
+ }
2197
+
2198
+ // Update local cache
2199
+ const refreshed = parseStatusMd(readFileSync(statusPath, "utf-8"));
2200
+ for (const s of refreshed.steps) state.stepStatuses.set(s.number, s);
2201
+ updateWidgets();
2202
+
2203
+ // Check if all steps are now complete
2204
+ const allComplete = task.steps.every(step => {
2205
+ const ss = refreshed.steps.find(s => s.number === step.number);
2206
+ return isStepComplete(ss);
2207
+ });
2208
+ if (allComplete) break;
2209
+ }
2210
+
2211
+ // ── Post-loop safety check: ensure all steps are actually complete ──
2212
+ // If the iteration cap was hit without completing all steps, fail explicitly
2213
+ // rather than falling through to quality gate / .DONE creation.
2214
+ if (state.phase === "running") {
2215
+ const finalStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
2216
+ const allStepsComplete = task.steps.every(step => {
2217
+ const ss = finalStatus.steps.find(s => s.number === step.number);
2218
+ return isStepComplete(ss);
2219
+ });
2220
+ if (!allStepsComplete) {
2221
+ const incomplete = task.steps
2222
+ .filter(step => {
2223
+ const ss = finalStatus.steps.find(s => s.number === step.number);
2224
+ return !isStepComplete(ss);
2225
+ })
2226
+ .map(s => `Step ${s.number}`)
2227
+ .join(", ");
2228
+ logExecution(statusPath, "Task incomplete", `Max iterations (${config.context.max_worker_iterations}) reached with incomplete steps: ${incomplete}`);
2229
+ ctx.ui.notify(`⚠️ Task incomplete — max iterations reached. Incomplete: ${incomplete}`, "error");
2230
+ state.phase = "error";
2231
+ return;
2232
+ }
1933
2233
  }
1934
2234
 
1935
2235
  // All steps done — run quality gate if enabled, then create .DONE
@@ -2079,111 +2379,9 @@ export default function (pi: ExtensionAPI) {
2079
2379
  ctx.ui.notify(`✅ Task ${task.taskId} complete!`, "success");
2080
2380
  }
2081
2381
 
2082
- async function executeStep(step: StepInfo, ctx: ExtensionContext): Promise<void> {
2083
- if (!state.task || !state.config) return;
2084
-
2085
- const task = state.task;
2086
- const config = state.config;
2087
- const statusPath = join(task.taskFolder, "STATUS.md");
2088
-
2089
- // Capture git HEAD before the step starts so code reviewers can
2090
- // diff the full step's changes (workers commit via checkpoints).
2091
- const stepBaselineCommit = getHeadCommitSha();
2092
-
2093
- updateStepStatus(statusPath, step.number, "in-progress");
2094
- updateStatusField(statusPath, "Current Step", `Step ${step.number}: ${step.name}`);
2095
- logExecution(statusPath, `Step ${step.number} started`, step.name);
2096
- updateWidgets();
2097
-
2098
- // Skip reviews for low-risk steps (Step 0 / Preflight and final step / Delivery)
2099
- const _isLowRiskStep = isLowRiskStep(step.number, task.steps.length);
2100
-
2101
- // Plan review (level ≥ 1)
2102
- if (task.reviewLevel >= 1) {
2103
- if (_isLowRiskStep) {
2104
- const label = step.number === 0 ? "Preflight" : "final step";
2105
- logExecution(statusPath, `Skip plan review`, `Step ${step.number} (${label}) — low-risk`);
2106
- ctx.ui.notify(`⏭️ Skipping plan review for Step ${step.number} (${label})`, "info");
2107
- } else {
2108
- const verdict = await doReview("plan", step, ctx, stepBaselineCommit);
2109
- if (verdict === "RETHINK") {
2110
- ctx.ui.notify(`Reviewer: RETHINK on Step ${step.number} plan. Proceeding with caution.`, "warning");
2111
- }
2112
- }
2113
- }
2114
-
2115
- // Worker loop
2116
- let noProgressCount = 0;
2117
- for (let iter = 0; iter < config.context.max_worker_iterations; iter++) {
2118
- if (state.phase === "paused") return;
2119
-
2120
- // Re-read STATUS.md
2121
- const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
2122
- const stepStatus = currentStatus.steps.find(s => s.number === step.number);
2123
- if (stepStatus?.status === "complete" || (stepStatus && stepStatus.totalChecked === stepStatus.totalItems && stepStatus.totalItems > 0)) {
2124
- updateStepStatus(statusPath, step.number, "complete");
2125
- break;
2126
- }
2127
-
2128
- const prevChecked = stepStatus?.totalChecked || 0;
2129
- state.workerIteration = iter + 1;
2130
- state.totalIterations++;
2131
- updateStatusField(statusPath, "Iteration", `${state.totalIterations}`);
2132
- updateWidgets();
2133
-
2134
- await runWorker(step, ctx);
2135
-
2136
- // Check progress
2137
- const afterStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
2138
- const afterStep = afterStatus.steps.find(s => s.number === step.number);
2139
- const afterChecked = afterStep?.totalChecked || 0;
2140
-
2141
- if (afterChecked <= prevChecked) {
2142
- noProgressCount++;
2143
- if (noProgressCount >= config.context.no_progress_limit) {
2144
- logExecution(statusPath, `Step ${step.number} blocked`, `No progress after ${noProgressCount} iterations`);
2145
- ctx.ui.notify(`⚠️ Step ${step.number} blocked — no progress after ${noProgressCount} iterations`, "error");
2146
- state.phase = "error";
2147
- return;
2148
- }
2149
- } else {
2150
- noProgressCount = 0;
2151
- }
2152
-
2153
- if (afterStep?.status === "complete" || (afterStep && afterStep.totalChecked === afterStep.totalItems && afterStep.totalItems > 0)) {
2154
- updateStepStatus(statusPath, step.number, "complete");
2155
- break;
2156
- }
2157
- }
2158
-
2159
- // Code review (level ≥ 2)
2160
- if (task.reviewLevel >= 2 && state.phase === "running") {
2161
- if (_isLowRiskStep) {
2162
- const label = step.number === 0 ? "Preflight" : "final step";
2163
- logExecution(statusPath, `Skip code review`, `Step ${step.number} (${label}) — low-risk`);
2164
- ctx.ui.notify(`⏭️ Skipping code review for Step ${step.number} (${label})`, "info");
2165
- } else {
2166
- const verdict = await doReview("code", step, ctx, stepBaselineCommit);
2167
- if (verdict === "REVISE") {
2168
- ctx.ui.notify(`Reviewer: REVISE on Step ${step.number}. Running worker to fix...`, "warning");
2169
- await runWorker(step, ctx); // One more pass to address issues
2170
- }
2171
- }
2172
- }
2173
-
2174
- if (state.phase === "running") {
2175
- updateStepStatus(statusPath, step.number, "complete");
2176
- logExecution(statusPath, `Step ${step.number} complete`, step.name);
2177
- // Update local cache
2178
- const refreshed = parseStatusMd(readFileSync(statusPath, "utf-8"));
2179
- for (const s of refreshed.steps) state.stepStatuses.set(s.number, s);
2180
- updateWidgets();
2181
- }
2182
- }
2183
-
2184
2382
  // ── Worker ───────────────────────────────────────────────────────
2185
2383
 
2186
- async function runWorker(step: StepInfo, ctx: ExtensionContext): Promise<void> {
2384
+ async function runWorker(remainingSteps: StepInfo[], ctx: ExtensionContext): Promise<void> {
2187
2385
  if (!state.task || !state.config) return;
2188
2386
 
2189
2387
  const task = state.task;
@@ -2228,8 +2426,16 @@ export default function (pi: ExtensionAPI) {
2228
2426
  "Just create the .DONE file in the task folder when complete."
2229
2427
  : "";
2230
2428
 
2429
+ // Build step listing for the worker prompt — show ALL steps with status
2430
+ const remainingSet = new Set(remainingSteps.map(s => s.number));
2431
+ const stepListing = task.steps.map(s =>
2432
+ remainingSet.has(s.number)
2433
+ ? ` - Step ${s.number}: ${s.name}`
2434
+ : ` - Step ${s.number}: ${s.name} [already complete — skip]`
2435
+ ).join("\n");
2436
+
2231
2437
  const prompt = [
2232
- `Execute Step ${step.number}: ${step.name}`,
2438
+ `Execute all remaining steps for task ${task.taskId}.`,
2233
2439
  ``,
2234
2440
  `Task: ${task.taskId} — ${task.taskName}`,
2235
2441
  `Task folder: ${task.taskFolder}/`,
@@ -2238,7 +2444,17 @@ export default function (pi: ExtensionAPI) {
2238
2444
  ``,
2239
2445
  `This is iteration ${state.totalIterations}.`,
2240
2446
  `Read STATUS.md FIRST to find where you left off.`,
2241
- `Work ONLY on Step ${step.number}. Do not proceed to other steps.`,
2447
+ ``,
2448
+ `Steps:`,
2449
+ stepListing,
2450
+ ``,
2451
+ `Work through these steps in order. For each step:`,
2452
+ `1. Read STATUS.md to find unchecked items for that step`,
2453
+ `2. Complete all items for the step`,
2454
+ `3. Update STATUS.md step status to "complete"`,
2455
+ `4. Commit your changes: feat(${task.taskId}): complete Step N — description`,
2456
+ `5. Check for wrap-up signal files before starting the next step`,
2457
+ `6. Proceed to the next incomplete step`,
2242
2458
  ``,
2243
2459
  `Wrap-up signal files: ${wrapUpFile} (primary), ${legacyWrapUpFile} (legacy)`,
2244
2460
  `Check for either file after each checkpoint. If one exists, stop.`,
@@ -2273,15 +2489,18 @@ export default function (pi: ExtensionAPI) {
2273
2489
  // Exit summary path — set only in tmux mode (rpc-wrapper produces this file).
2274
2490
  let exitSummaryPath: string | null = null;
2275
2491
 
2492
+ // Resolve context window: explicit config → model registry → 200K fallback
2493
+ const { contextWindow, source: contextWindowSource } = resolveContextWindow(config, ctx);
2494
+ const warnPct = config.context.warn_percent;
2495
+ const killPct = config.context.kill_percent;
2496
+ console.error(`[task-runner] worker context window: ${contextWindow} (${contextWindowSource})`);
2497
+
2276
2498
  if (spawnMode === "tmux") {
2277
2499
  // ── TMUX mode ────────────────────────────────────────
2278
2500
  // Sidecar JSONL provides telemetry parity: tokens, cost, context%,
2279
2501
  // tool calls, and retry events — same signals as subprocess mode.
2280
2502
  // Kill via wall-clock timeout (context-% wrap-up also available via sidecar).
2281
2503
  const sessionName = `${getTmuxPrefix()}-worker`;
2282
- const contextWindow = config.context.worker_context_window;
2283
- const warnPct = config.context.warn_percent;
2284
- const killPct = config.context.kill_percent;
2285
2504
 
2286
2505
  const spawned = spawnAgentTmux({
2287
2506
  sessionName,
@@ -2373,9 +2592,9 @@ export default function (pi: ExtensionAPI) {
2373
2592
  thinking: config.worker.thinking || "off",
2374
2593
  systemPrompt,
2375
2594
  prompt,
2376
- contextWindow: config.context.worker_context_window,
2377
- warnPct: config.context.warn_percent,
2378
- killPct: config.context.kill_percent,
2595
+ contextWindow,
2596
+ warnPct,
2597
+ killPct,
2379
2598
  wrapUpFile,
2380
2599
  onToolCall: (toolName, args) => {
2381
2600
  state.workerToolCount++;
@@ -2405,7 +2624,7 @@ export default function (pi: ExtensionAPI) {
2405
2624
  },
2406
2625
  onContextPct: (pct) => {
2407
2626
  state.workerContextPct = pct;
2408
- if (pct >= config.context.warn_percent) {
2627
+ if (pct >= warnPct) {
2409
2628
  writeWrapUpSignal(`Wrap up (context ${Math.round(pct)}%)`);
2410
2629
  }
2411
2630
  updateWidgets();
@@ -760,7 +760,7 @@ export function toOrchestratorConfig(config: TaskplaneConfig): import("./types.t
760
760
  tools: o.merge.tools,
761
761
  verify: [...o.merge.verify],
762
762
  order: o.merge.order,
763
- timeout_minutes: o.merge.timeoutMinutes ?? 10,
763
+ timeout_minutes: o.merge.timeoutMinutes ?? 90,
764
764
  },
765
765
  failure: {
766
766
  on_task_failure: o.failure.onTaskFailure,
@@ -125,7 +125,9 @@ export interface ReviewerConfig {
125
125
 
126
126
  /** Context/resource limits for task execution */
127
127
  export interface ContextConfig {
128
- /** Context window size used for worker context pressure tracking */
128
+ /** Context window size used for worker context pressure tracking.
129
+ * Set to 0 (default) for auto-detection from the pi model registry.
130
+ * When 0, the task-runner resolves at runtime: ctx.model.contextWindow → 200K fallback. */
129
131
  workerContextWindow: number;
130
132
  /** Warn threshold for context utilization (percent) */
131
133
  warnPercent: number;
@@ -494,9 +496,9 @@ export const DEFAULT_TASK_RUNNER_SECTION: TaskRunnerSection = {
494
496
  worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "off" },
495
497
  reviewer: { model: "openai/gpt-5.3-codex", tools: "read,bash,grep,find,ls", thinking: "on" },
496
498
  context: {
497
- workerContextWindow: 200000,
498
- warnPercent: 70,
499
- killPercent: 85,
499
+ workerContextWindow: 0,
500
+ warnPercent: 85,
501
+ killPercent: 95,
500
502
  maxWorkerIterations: 20,
501
503
  maxReviewCycles: 2,
502
504
  noProgressLimit: 3,
@@ -545,7 +547,7 @@ export const DEFAULT_ORCHESTRATOR_SECTION: OrchestratorSection = {
545
547
  tools: "read,write,edit,bash,grep,find,ls",
546
548
  verify: [],
547
549
  order: "fewest-files-first",
548
- timeoutMinutes: 10,
550
+ timeoutMinutes: 90,
549
551
  },
550
552
  failure: {
551
553
  onTaskFailure: "skip-dependents",
@@ -43,6 +43,7 @@ import {
43
43
  } from "./index.ts";
44
44
  import { buildExecutionContext } from "./workspace.ts";
45
45
  import { openSettingsTui } from "./settings-tui.ts";
46
+ import { loadProjectConfig } from "./config-loader.ts";
46
47
  import {
47
48
  activateSupervisor,
48
49
  deactivateSupervisor,
@@ -767,10 +768,11 @@ export function validateModelAvailability(
767
768
  runnerConfig: TaskRunnerConfig,
768
769
  supervisorConfig: SupervisorConfig,
769
770
  ctx: ExtensionContext,
771
+ agentModels?: { workerModel?: string; reviewerModel?: string },
770
772
  ): ModelCheckResult[] {
771
773
  const entries: ModelCheckEntry[] = [
772
- { role: "Worker", modelStr: runnerConfig.worker?.model ?? "" },
773
- { role: "Reviewer", modelStr: runnerConfig.reviewer?.model ?? "" },
774
+ { role: "Worker", modelStr: agentModels?.workerModel ?? (runnerConfig as any).worker?.model ?? "" },
775
+ { role: "Reviewer", modelStr: agentModels?.reviewerModel ?? (runnerConfig as any).reviewer?.model ?? "" },
774
776
  { role: "Merger", modelStr: orchConfig.merge?.model ?? "" },
775
777
  { role: "Supervisor", modelStr: supervisorConfig.model ?? "" },
776
778
  ];
@@ -1401,7 +1403,18 @@ export default function (pi: ExtensionAPI) {
1401
1403
  // Validate that all configured agent models are resolvable in
1402
1404
  // the model registry before starting. Catches misconfigured
1403
1405
  // model names early instead of failing hours into a batch.
1404
- const modelResults = validateModelAvailability(orchConfig, runnerConfig, supervisorConfig, ctx);
1406
+ // Note: runnerConfig (TaskRunnerConfig) is a stripped type without
1407
+ // worker/reviewer model fields. Load the full unified config to
1408
+ // get the actual agent model strings (including user preferences).
1409
+ let agentModels: { workerModel?: string; reviewerModel?: string } | undefined;
1410
+ try {
1411
+ const fullConfig = loadProjectConfig(execCtx!.repoRoot);
1412
+ agentModels = {
1413
+ workerModel: fullConfig.taskRunner.worker.model || "",
1414
+ reviewerModel: fullConfig.taskRunner.reviewer.model || "",
1415
+ };
1416
+ } catch { /* fall through — validateModelAvailability handles empty strings */ }
1417
+ const modelResults = validateModelAvailability(orchConfig, runnerConfig, supervisorConfig, ctx, agentModels);
1405
1418
  const modelFailures = modelResults.filter(r => r.status === "not-found");
1406
1419
  ctx.ui.notify(formatModelValidation(modelResults), modelFailures.length > 0 ? "error" : "info");
1407
1420
  if (modelFailures.length > 0) {
@@ -449,7 +449,7 @@ export async function spawnMergeAgent(
449
449
  export function reloadMergeTimeoutMs(configRoot: string, pointerConfigRoot?: string): number {
450
450
  try {
451
451
  const freshConfig = loadOrchestratorConfig(configRoot, pointerConfigRoot);
452
- const minutes = freshConfig.merge.timeout_minutes ?? 10;
452
+ const minutes = freshConfig.merge.timeout_minutes ?? 90;
453
453
  return minutes * 60 * 1000;
454
454
  } catch (err: unknown) {
455
455
  // Config re-read is best-effort — fall back to default on failure
@@ -2028,7 +2028,38 @@ When the conversation reaches the config generation phase, create ALL of these
2028
2028
  - \`.gitignore\` entries — add Taskplane working file patterns if not already present
2029
2029
 
2030
2030
  Use conservative creation: check if each file exists before writing. If files
2031
- already exist (partial setup), read and merge rather than overwrite.`;
2031
+ already exist (partial setup), read and merge rather than overwrite.
2032
+
2033
+ ### CRITICAL: Task Area Registration
2034
+
2035
+ **Every task folder MUST be registered in \`.pi/taskplane-config.json\` under
2036
+ \`taskRunner.taskAreas\`.** Without registration, \`/orch all\` will fail with
2037
+ "no task areas configured" — even if the folders and tasks physically exist.
2038
+
2039
+ When creating a task folder (e.g., \`taskplane-tasks/\`):
2040
+ 1. Create the folder and its \`CONTEXT.md\`
2041
+ 2. Register it in \`.pi/taskplane-config.json\`:
2042
+ \`\`\`json
2043
+ {
2044
+ "taskRunner": {
2045
+ "taskAreas": {
2046
+ "general": {
2047
+ "path": "taskplane-tasks",
2048
+ "prefix": "TP",
2049
+ "context": "taskplane-tasks/CONTEXT.md"
2050
+ }
2051
+ }
2052
+ }
2053
+ }
2054
+ \`\`\`
2055
+ 3. **Verify** by reading the config back to confirm the area is registered
2056
+
2057
+ When creating tasks inside an area, check that the area is registered first.
2058
+ If it's not (e.g., operator created the folder manually), register it before
2059
+ proceeding.
2060
+
2061
+ This also applies when creating tasks later in the conversation — always verify
2062
+ the task area is registered in the config before offering to run \`/orch all\`.`;
2032
2063
  break;
2033
2064
 
2034
2065
  case "pending-tasks":
@@ -2068,7 +2099,14 @@ Follow the primer's **"Script 6: Batch Planning"** section
2068
2099
  5. **Offer a health check** (Script 7) if the operator prefers to assess
2069
2100
  project state rather than create tasks
2070
2101
  6. **Graceful fallback**: If \`gh\` CLI is unavailable, skip GitHub checks and
2071
- mention it to the operator — continue with CONTEXT.md and TODO scanning`;
2102
+ mention it to the operator — continue with CONTEXT.md and TODO scanning
2103
+
2104
+ ### Important: Task Area Verification
2105
+
2106
+ Before creating any tasks, verify that the target task area folder is registered
2107
+ in \`.pi/taskplane-config.json\` under \`taskRunner.taskAreas\`. If it's missing
2108
+ (e.g., the folder exists but was never registered), register it first. Without
2109
+ registration, \`/orch all\` will fail with "no task areas configured."`;
2072
2110
  break;
2073
2111
 
2074
2112
  case "completed-batch":
@@ -3045,11 +3083,16 @@ export function startHeartbeat(
3045
3083
  return;
3046
3084
  }
3047
3085
 
3048
- // Update heartbeat
3086
+ // Update heartbeat (and refresh batchId if it was initially unknown)
3049
3087
  try {
3050
3088
  const lock = readLockfile(stateRoot);
3051
3089
  if (lock && lock.sessionId === sessionId) {
3052
3090
  lock.heartbeat = new Date().toISOString();
3091
+ // TP-130: batchId may have been "(initializing)" at lock creation
3092
+ // because the batch hadn't started yet. Refresh from live state ref.
3093
+ if (state.batchStateRef?.batchId && lock.batchId !== state.batchStateRef.batchId) {
3094
+ lock.batchId = state.batchStateRef.batchId;
3095
+ }
3053
3096
  writeLockfile(stateRoot, lock);
3054
3097
  }
3055
3098
  } catch {
@@ -182,7 +182,7 @@ export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = {
182
182
  tools: "read,write,edit,bash,grep,find,ls",
183
183
  verify: [],
184
184
  order: "fewest-files-first",
185
- timeout_minutes: 10,
185
+ timeout_minutes: 90,
186
186
  },
187
187
  failure: {
188
188
  on_task_failure: "skip-dependents",
@@ -1225,7 +1225,7 @@ export class MergeError extends Error {
1225
1225
  * is generous and covers verification (go build) on large codebases.
1226
1226
  */
1227
1227
  /** Default merge agent timeout. Use config.merge.timeout_minutes to override. */
1228
- export const MERGE_TIMEOUT_MS = 10 * 60 * 1000;
1228
+ export const MERGE_TIMEOUT_MS = 90 * 60 * 1000;
1229
1229
 
1230
1230
  /**
1231
1231
  * Polling interval for merge result file (ms).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -13,8 +13,9 @@ name: task-worker
13
13
 
14
14
  The base prompt (maintained by taskplane) handles:
15
15
  - STATUS.md-first workflow and checkpoint discipline
16
- - Fresh-context loop behavior and iteration rules
17
- - Git commit conventions and .DONE file creation
16
+ - Multi-step execution (worker handles all remaining steps per invocation)
17
+ - Iteration recovery (context limit next invocation resumes from STATUS.md)
18
+ - Git commit conventions (per-step commits) and .DONE file creation
18
19
  - Review response handling
19
20
 
20
21
  Add project-specific rules below. Common examples:
@@ -1,21 +1,25 @@
1
1
  ---
2
2
  name: task-worker
3
- description: Autonomous task execution agent — works on individual steps with checkpoint discipline
3
+ description: Autonomous task execution agent — works through remaining steps with checkpoint discipline
4
4
  tools: read,write,edit,bash,grep,find,ls
5
5
  # model:
6
6
  ---
7
- You are a task execution agent running in a **fresh-context loop**. Each time you
8
- are invoked, you have ZERO memory of prior invocations. STATUS.md on disk is your
9
- ONLY memory.
7
+ You are a task execution agent. You may be invoked multiple times across
8
+ iterations each invocation starts with ZERO memory of prior ones.
9
+ STATUS.md on disk is your ONLY memory.
10
+
11
+ Your prompt tells you which steps remain. Work through them **in order**,
12
+ completing each step before moving to the next.
10
13
 
11
14
  ## Resume Algorithm (MANDATORY — Do This First)
12
15
 
13
16
  1. Read STATUS.md completely
14
- 2. Find the step you have been assigned (specified in your prompt)
17
+ 2. Find the **first incomplete step** listed in your prompt
15
18
  3. **Hydrate if needed** (see STATUS.md Hydration below)
16
19
  4. Within that step, find the **first unchecked checkbox** (`- [ ]`)
17
20
  5. Resume from there — do NOT redo checked items (`- [x]`)
18
- 6. If all items in your assigned step are checked, report completion
21
+ 6. When a step's items are all checked, proceed to the next incomplete step
22
+ 7. If all steps are complete, report completion
19
23
 
20
24
  ## Checkpoint Discipline (CRITICAL)
21
25
 
@@ -68,9 +72,9 @@ dozens of micro-commits that nobody reads.
68
72
 
69
73
  STATUS.md is the worker's memory, not git. Checking off items in STATUS.md
70
74
  ensures the next worker iteration knows where to resume. Git commits preserve
71
- file changes at meaningful milestones. Per-checkbox commits waste tool calls
72
- on git housekeeping without adding recovery value — the files are already on
73
- disk in the worktree.
75
+ file changes at meaningful milestones — one per completed step. Per-checkbox
76
+ commits waste tool calls on git housekeeping without adding recovery value —
77
+ the files are already on disk in the worktree.
74
78
 
75
79
  ## STATUS.md Hydration (MANDATORY)
76
80
 
@@ -94,7 +98,7 @@ instead of solving the problem.
94
98
 
95
99
  Before implementing anything, assess whether the step needs expansion:
96
100
 
97
- 1. **Read the PROMPT.md step details** for your assigned step
101
+ 1. **Read the PROMPT.md step details** for the step you're entering
98
102
  2. **Look for `⚠️ Hydrate` markers** — these signal the task creator expected
99
103
  you to expand based on runtime discoveries
100
104
  3. **If expansion is needed**, add checkboxes for **distinct outcomes** you've
@@ -149,9 +153,9 @@ When a reviewer returns REVISE with specific feedback items:
149
153
 
150
154
  ## Scope Rules
151
155
 
152
- - Work ONLY on the step assigned in your prompt
153
- - Do NOT proceed to other steps
154
- - Do NOT expand task scope
156
+ - Work through all remaining steps listed in your prompt, **in order**
157
+ - Do NOT skip ahead complete each step before starting the next
158
+ - Do NOT expand task scope beyond what the steps require
155
159
  - If you discover something out of scope, note it in STATUS.md Discoveries table
156
160
 
157
161
  ## Self-Documentation
@@ -77,7 +77,7 @@ merge:
77
77
  order: "fewest-files-first"
78
78
 
79
79
  # Merge agent timeout in minutes. Increase for large batches with many files.
80
- timeout_minutes: 10
80
+ timeout_minutes: 90
81
81
 
82
82
  # ── Failure Handling ──────────────────────────────────────────────────
83
83
 
@@ -57,9 +57,9 @@ reviewer:
57
57
  thinking: "off"
58
58
 
59
59
  context:
60
- worker_context_window: 200000
61
- warn_percent: 70
62
- kill_percent: 85
60
+ # worker_context_window: 200000 # 0 or omit = auto-detect from model registry; set explicitly to override
61
+ warn_percent: 85
62
+ kill_percent: 95
63
63
  max_worker_iterations: 20
64
64
  max_review_cycles: 2
65
65
  no_progress_limit: 3