taskplane 0.25.7 → 0.26.1
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.
- package/bin/rpc-wrapper.mjs +0 -1
- package/bin/taskplane.mjs +1 -1
- package/dashboard/public/app.js +44 -1
- package/dashboard/server.cjs +7 -6
- package/extensions/task-orchestrator.ts +1 -1
- package/extensions/taskplane/config-loader.ts +53 -0
- package/extensions/taskplane/context-window.ts +66 -0
- package/extensions/taskplane/execution.ts +111 -17
- package/extensions/taskplane/extension.ts +20 -0
- package/extensions/taskplane/merge.ts +2917 -2900
- package/extensions/taskplane/path-resolver.ts +1 -1
- package/extensions/taskplane/sidecar-telemetry.ts +252 -0
- package/extensions/taskplane/supervisor-primer.md +2 -0
- package/extensions/taskplane/supervisor.ts +1 -1
- package/extensions/taskplane/task-executor-core.ts +2 -5
- package/package.json +1 -3
- package/templates/agents/task-worker.md +387 -387
- package/templates/tasks/CONTEXT.md +3 -4
- package/extensions/task-runner.ts +0 -2784
|
@@ -1,2784 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Task Runner — Autonomous task execution with live dashboard
|
|
3
|
-
*
|
|
4
|
-
* Replaces the Ralph Wiggum bash loop with a Pi extension. Workers are
|
|
5
|
-
* fresh-context subprocesses; STATUS.md is persistent memory. Supports
|
|
6
|
-
* cross-model review (reviewer uses a different model than the worker).
|
|
7
|
-
*
|
|
8
|
-
* Commands:
|
|
9
|
-
* /task <path/to/PROMPT.md> — Start executing a task
|
|
10
|
-
* /task-status — Re-read and display STATUS.md progress
|
|
11
|
-
* /task-pause — Pause after current worker finishes
|
|
12
|
-
* /task-resume — Resume a paused task
|
|
13
|
-
*
|
|
14
|
-
* Configuration: .pi/task-runner.yaml (project-specific settings)
|
|
15
|
-
* Agents: .pi/agents/task-worker.md, .pi/agents/task-reviewer.md
|
|
16
|
-
*
|
|
17
|
-
* Usage: pi -e extensions/task-runner.ts
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
21
|
-
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
22
|
-
import { Type } from "@mariozechner/pi-ai";
|
|
23
|
-
import { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
24
|
-
import { spawn, spawnSync } from "child_process";
|
|
25
|
-
import {
|
|
26
|
-
readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync,
|
|
27
|
-
readdirSync, statSync, openSync, readSync, closeSync,
|
|
28
|
-
} from "fs";
|
|
29
|
-
import { tmpdir, userInfo } from "os";
|
|
30
|
-
import { join, dirname, basename, resolve } from "path";
|
|
31
|
-
import { ConfigLoadError, loadProjectConfig, toTaskConfig } from "./taskplane/config-loader.ts";
|
|
32
|
-
import { loadWorkspaceConfig, resolvePointer } from "./taskplane/workspace.ts";
|
|
33
|
-
import type { PointerResolution } from "./taskplane/types.ts";
|
|
34
|
-
import {
|
|
35
|
-
REVIEWER_SHUTDOWN_GRACE_MS,
|
|
36
|
-
REVIEWER_SIGNAL_PREFIX,
|
|
37
|
-
REVIEWER_SHUTDOWN_SIGNAL,
|
|
38
|
-
} from "./taskplane/types.ts";
|
|
39
|
-
import { classifyExit } from "./taskplane/diagnostics.ts";
|
|
40
|
-
import type { TaskExitDiagnostic, ExitSummary } from "./taskplane/diagnostics.ts";
|
|
41
|
-
import {
|
|
42
|
-
parsePromptMd as coreParsePromptMd,
|
|
43
|
-
parseStatusMd as coreParseStatusMd,
|
|
44
|
-
generateStatusMd as coreGenerateStatusMd,
|
|
45
|
-
updateStatusField as coreUpdateStatusField,
|
|
46
|
-
updateStepStatus as coreUpdateStepStatus,
|
|
47
|
-
appendTableRow as coreAppendTableRow,
|
|
48
|
-
logExecution as coreLogExecution,
|
|
49
|
-
logReview as coreLogReview,
|
|
50
|
-
sanitizeSteeringContent as coreSanitizeSteeringContent,
|
|
51
|
-
isStepComplete as coreIsStepComplete,
|
|
52
|
-
isLowRiskStep as coreIsLowRiskStep,
|
|
53
|
-
extractVerdict as coreExtractVerdict,
|
|
54
|
-
getHeadCommitSha as coreGetHeadCommitSha,
|
|
55
|
-
findStepBoundaryCommit as coreFindStepBoundaryCommit,
|
|
56
|
-
resolveStandards as coreResolveStandards,
|
|
57
|
-
generateReviewRequest as coreGenerateReviewRequest,
|
|
58
|
-
displayName as coreDisplayName,
|
|
59
|
-
type StepInfo,
|
|
60
|
-
type CoreParsedTask,
|
|
61
|
-
type ParsedStatus,
|
|
62
|
-
} from "./taskplane/task-executor-core.ts";
|
|
63
|
-
import {
|
|
64
|
-
generateQualityGatePrompt,
|
|
65
|
-
generateFeedbackMd,
|
|
66
|
-
buildFixAgentPrompt,
|
|
67
|
-
readAndEvaluateVerdict,
|
|
68
|
-
VERDICT_FILENAME,
|
|
69
|
-
FEEDBACK_FILENAME,
|
|
70
|
-
applyStatusReconciliation,
|
|
71
|
-
type QualityGateContext,
|
|
72
|
-
type QualityGateResult,
|
|
73
|
-
type ReviewVerdict,
|
|
74
|
-
type VerdictEvaluation,
|
|
75
|
-
} from "./taskplane/quality-gate.ts";
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// ── Types ────────────────────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
interface TaskConfig {
|
|
81
|
-
project: { name: string; description: string };
|
|
82
|
-
paths: { tasks: string; architecture?: string };
|
|
83
|
-
testing: { commands: Record<string, string> };
|
|
84
|
-
standards: { docs: string[]; rules: string[] };
|
|
85
|
-
standards_overrides: Record<string, { docs?: string[]; rules?: string[] }>;
|
|
86
|
-
task_areas: Record<string, { path: string; [key: string]: any }>;
|
|
87
|
-
worker: {
|
|
88
|
-
model: string;
|
|
89
|
-
tools: string;
|
|
90
|
-
thinking: string;
|
|
91
|
-
spawn_mode?: "subprocess";
|
|
92
|
-
};
|
|
93
|
-
reviewer: { model: string; tools: string; thinking: string };
|
|
94
|
-
context: {
|
|
95
|
-
worker_context_window: number;
|
|
96
|
-
warn_percent: number;
|
|
97
|
-
kill_percent: number;
|
|
98
|
-
max_worker_iterations: number;
|
|
99
|
-
max_review_cycles: number;
|
|
100
|
-
no_progress_limit: number;
|
|
101
|
-
max_worker_minutes?: number;
|
|
102
|
-
};
|
|
103
|
-
quality_gate: {
|
|
104
|
-
enabled: boolean;
|
|
105
|
-
review_model: string;
|
|
106
|
-
max_review_cycles: number;
|
|
107
|
-
max_fix_cycles: number;
|
|
108
|
-
pass_threshold: "no_critical" | "no_important" | "all_clear";
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
interface StepInfo {
|
|
113
|
-
number: number;
|
|
114
|
-
name: string;
|
|
115
|
-
status: "not-started" | "in-progress" | "complete";
|
|
116
|
-
checkboxes: { text: string; checked: boolean }[];
|
|
117
|
-
totalChecked: number;
|
|
118
|
-
totalItems: number;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
interface ParsedTask {
|
|
122
|
-
taskId: string;
|
|
123
|
-
taskName: string;
|
|
124
|
-
reviewLevel: number;
|
|
125
|
-
size: string;
|
|
126
|
-
steps: StepInfo[];
|
|
127
|
-
contextDocs: string[];
|
|
128
|
-
taskFolder: string;
|
|
129
|
-
promptPath: string;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
type TaskPhase = "idle" | "running" | "paused" | "complete" | "error";
|
|
133
|
-
|
|
134
|
-
interface TaskState {
|
|
135
|
-
phase: TaskPhase;
|
|
136
|
-
task: ParsedTask | null;
|
|
137
|
-
config: TaskConfig | null;
|
|
138
|
-
currentStep: number;
|
|
139
|
-
workerIteration: number;
|
|
140
|
-
workerStatus: "idle" | "running" | "done" | "error" | "killed";
|
|
141
|
-
workerElapsed: number;
|
|
142
|
-
workerContextPct: number;
|
|
143
|
-
workerLastTool: string;
|
|
144
|
-
workerToolCount: number;
|
|
145
|
-
workerInputTokens: number;
|
|
146
|
-
workerOutputTokens: number;
|
|
147
|
-
workerCacheReadTokens: number;
|
|
148
|
-
workerCacheWriteTokens: number;
|
|
149
|
-
workerCostUsd: number;
|
|
150
|
-
workerProc: any;
|
|
151
|
-
workerTimer: any;
|
|
152
|
-
workerRetryActive: boolean;
|
|
153
|
-
workerRetryCount: number;
|
|
154
|
-
workerLastRetryError: string;
|
|
155
|
-
/** Structured exit diagnostic from the most recent worker iteration (reserved for compatibility). */
|
|
156
|
-
workerExitDiagnostic: TaskExitDiagnostic | null;
|
|
157
|
-
reviewerStatus: "idle" | "running" | "done" | "error";
|
|
158
|
-
reviewerType: string;
|
|
159
|
-
reviewerStep: number;
|
|
160
|
-
reviewerSessionName: string;
|
|
161
|
-
reviewerElapsed: number;
|
|
162
|
-
reviewerLastTool: string;
|
|
163
|
-
reviewerToolCount: number;
|
|
164
|
-
reviewerInputTokens: number;
|
|
165
|
-
reviewerOutputTokens: number;
|
|
166
|
-
reviewerCacheReadTokens: number;
|
|
167
|
-
reviewerCacheWriteTokens: number;
|
|
168
|
-
reviewerCostUsd: number;
|
|
169
|
-
reviewerContextPct: number;
|
|
170
|
-
reviewerProc: any;
|
|
171
|
-
reviewerTimer: any;
|
|
172
|
-
reviewCounter: number;
|
|
173
|
-
/** Reserved for compatibility with legacy lane-state payloads. */
|
|
174
|
-
persistentReviewerSession: string | null;
|
|
175
|
-
/** Reserved for compatibility with legacy lane-state payloads. */
|
|
176
|
-
persistentReviewerKill: (() => void) | null;
|
|
177
|
-
/** Reserved for compatibility with legacy lane-state payloads. */
|
|
178
|
-
persistentReviewerSignalNum: number;
|
|
179
|
-
/** Reserved for compatibility with legacy lane-state payloads. */
|
|
180
|
-
reviewerRespawnCount: number;
|
|
181
|
-
totalIterations: number;
|
|
182
|
-
stepStatuses: Map<number, StepInfo>;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function freshState(): TaskState {
|
|
186
|
-
return {
|
|
187
|
-
phase: "idle", task: null, config: null, currentStep: 0,
|
|
188
|
-
workerIteration: 0, workerStatus: "idle", workerElapsed: 0,
|
|
189
|
-
workerContextPct: 0, workerLastTool: "", workerToolCount: 0,
|
|
190
|
-
workerInputTokens: 0, workerOutputTokens: 0, workerCacheReadTokens: 0, workerCacheWriteTokens: 0, workerCostUsd: 0,
|
|
191
|
-
workerProc: null, workerTimer: null,
|
|
192
|
-
workerRetryActive: false, workerRetryCount: 0, workerLastRetryError: "",
|
|
193
|
-
workerExitDiagnostic: null,
|
|
194
|
-
reviewerStatus: "idle", reviewerType: "", reviewerStep: 0, reviewerSessionName: "",
|
|
195
|
-
reviewerElapsed: 0, reviewerLastTool: "", reviewerToolCount: 0,
|
|
196
|
-
reviewerInputTokens: 0, reviewerOutputTokens: 0, reviewerCacheReadTokens: 0, reviewerCacheWriteTokens: 0,
|
|
197
|
-
reviewerCostUsd: 0, reviewerContextPct: 0, reviewerProc: null, reviewerTimer: null,
|
|
198
|
-
reviewCounter: 0,
|
|
199
|
-
persistentReviewerSession: null, persistentReviewerKill: null, persistentReviewerSignalNum: 0, reviewerRespawnCount: 0,
|
|
200
|
-
totalIterations: 0, stepStatuses: new Map(),
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// ── Config ───────────────────────────────────────────────────────────
|
|
205
|
-
|
|
206
|
-
const DEFAULT_CONFIG: TaskConfig = {
|
|
207
|
-
project: { name: "Project", description: "" },
|
|
208
|
-
paths: { tasks: "docs/task-management" },
|
|
209
|
-
testing: { commands: {} },
|
|
210
|
-
standards: { docs: [], rules: [] },
|
|
211
|
-
standards_overrides: {},
|
|
212
|
-
task_areas: {},
|
|
213
|
-
worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "" },
|
|
214
|
-
reviewer: { model: "", tools: "read,bash,grep,find,ls", thinking: "on" },
|
|
215
|
-
context: {
|
|
216
|
-
worker_context_window: 0, warn_percent: 85, kill_percent: 95,
|
|
217
|
-
max_worker_iterations: 20, max_review_cycles: 2, no_progress_limit: 3,
|
|
218
|
-
},
|
|
219
|
-
quality_gate: {
|
|
220
|
-
enabled: false,
|
|
221
|
-
review_model: "",
|
|
222
|
-
max_review_cycles: 2,
|
|
223
|
-
max_fix_cycles: 1,
|
|
224
|
-
pass_threshold: "no_critical",
|
|
225
|
-
},
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
// ── Pointer Resolution (Workspace Mode) ──────────────────────────────
|
|
229
|
-
|
|
230
|
-
/** Track whether a pointer warning has been logged this session (log once). */
|
|
231
|
-
let _pointerWarningLogged = false;
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Resolve the workspace pointer for config and agent path redirection.
|
|
235
|
-
*
|
|
236
|
-
* In workspace mode (TASKPLANE_WORKSPACE_ROOT set), reads the pointer
|
|
237
|
-
* file and resolves config/agent roots to the config repo. In repo mode,
|
|
238
|
-
* returns null (no pointer resolution needed).
|
|
239
|
-
*
|
|
240
|
-
* All pointer failures are non-fatal: missing, malformed, or invalid
|
|
241
|
-
* pointer files produce a warning and fall back to existing paths.
|
|
242
|
-
* Warning is logged to stderr once per session for operator visibility.
|
|
243
|
-
*
|
|
244
|
-
* @returns PointerResolution with resolved paths, or null in repo mode
|
|
245
|
-
*/
|
|
246
|
-
function resolveTaskRunnerPointer(): PointerResolution | null {
|
|
247
|
-
const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT;
|
|
248
|
-
if (!wsRoot) return null; // repo mode — no pointer needed
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
const wsConfig = loadWorkspaceConfig(wsRoot);
|
|
252
|
-
const result = resolvePointer(wsRoot, wsConfig);
|
|
253
|
-
|
|
254
|
-
// Surface pointer warnings once per session for operator visibility
|
|
255
|
-
if (result?.warning && !_pointerWarningLogged) {
|
|
256
|
-
_pointerWarningLogged = true;
|
|
257
|
-
console.error(`[task-runner] pointer: ${result.warning}`);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
return result;
|
|
261
|
-
} catch {
|
|
262
|
-
// Workspace config load failure — fall back gracefully
|
|
263
|
-
return null;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** Reset pointer warning state (for testing only). */
|
|
268
|
-
export function _resetPointerWarning(): void {
|
|
269
|
-
_pointerWarningLogged = false;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** Expose loadAgentDef for testing (not part of public API). */
|
|
273
|
-
export const _loadAgentDef = (cwd: string, name: string) => loadAgentDef(cwd, name);
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Load task-runner config via the unified config loader.
|
|
277
|
-
*
|
|
278
|
-
* Reads `.pi/taskplane-config.json` first; falls back to YAML files;
|
|
279
|
-
* then defaults. Returns the legacy snake_case TaskConfig shape so all
|
|
280
|
-
* downstream consumers remain unchanged.
|
|
281
|
-
*
|
|
282
|
-
* Config root resolution order (workspace mode with pointer):
|
|
283
|
-
* 1. cwd has config files → use cwd (local override)
|
|
284
|
-
* 2. Pointer-resolved config root has config files → use it
|
|
285
|
-
* 3. TASKPLANE_WORKSPACE_ROOT has config files → use it (legacy fallback)
|
|
286
|
-
* 4. Fall back to cwd (loaders will return defaults)
|
|
287
|
-
*
|
|
288
|
-
* Repo mode: pointer is ignored, existing behavior unchanged.
|
|
289
|
-
*/
|
|
290
|
-
export function loadConfig(cwd: string): TaskConfig {
|
|
291
|
-
try {
|
|
292
|
-
const pointer = resolveTaskRunnerPointer();
|
|
293
|
-
const unified = loadProjectConfig(cwd, pointer?.configRoot);
|
|
294
|
-
return toTaskConfig(unified);
|
|
295
|
-
} catch (err: unknown) {
|
|
296
|
-
if (err instanceof ConfigLoadError && err.code === "CONFIG_LEGACY_FIELD") {
|
|
297
|
-
// Hard-fail deprecated TMUX-era config/prefs with migration guidance.
|
|
298
|
-
throw err;
|
|
299
|
-
}
|
|
300
|
-
// For malformed/unreadable config, preserve historical fallback behavior.
|
|
301
|
-
return { ...DEFAULT_CONFIG };
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// ── Runtime Mode Helpers ─────────────────────────────────────────────
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Detect whether this runner is executing under /orch orchestration.
|
|
309
|
-
*
|
|
310
|
-
* Runtime V2 exposes ORCH_BATCH_ID for lane workers. We also keep
|
|
311
|
-
* TASK_RUNNER_TMUX_PREFIX as a legacy signal so older launchers are still
|
|
312
|
-
* treated as orchestrated mode during migration.
|
|
313
|
-
*/
|
|
314
|
-
function isOrchestratedMode(): boolean {
|
|
315
|
-
return !!process.env.ORCH_BATCH_ID || !!process.env.TASK_RUNNER_TMUX_PREFIX;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Returns the lane/session prefix used for sidecar filenames.
|
|
320
|
-
*/
|
|
321
|
-
function getLanePrefix(): string {
|
|
322
|
-
return process.env.TASKPLANE_LANE_PREFIX
|
|
323
|
-
|| process.env.TASK_RUNNER_TMUX_PREFIX
|
|
324
|
-
|| "task";
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Returns worker wall-clock timeout in minutes.
|
|
329
|
-
*
|
|
330
|
-
* Resolution order: env var → config → default 30 minutes.
|
|
331
|
-
*/
|
|
332
|
-
function getMaxWorkerMinutes(config: TaskConfig): number {
|
|
333
|
-
const envVal = process.env.TASK_RUNNER_MAX_WORKER_MINUTES;
|
|
334
|
-
if (envVal) {
|
|
335
|
-
const parsed = parseInt(envVal, 10);
|
|
336
|
-
if (!isNaN(parsed) && parsed > 0) return parsed;
|
|
337
|
-
}
|
|
338
|
-
const configVal = config.context.max_worker_minutes;
|
|
339
|
-
if (typeof configVal === "number" && configVal > 0) return configVal;
|
|
340
|
-
return 30;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// ── Context Window Resolution ─────────────────────────────────────────
|
|
344
|
-
|
|
345
|
-
/** Default fallback context window when neither config nor model provides a value. */
|
|
346
|
-
const FALLBACK_CONTEXT_WINDOW = 200_000;
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Resolve the effective context window size for worker spawning.
|
|
350
|
-
*
|
|
351
|
-
* Resolution order (first non-zero value wins):
|
|
352
|
-
* 1. Explicit user config (worker_context_window > 0 in config)
|
|
353
|
-
* 2. Auto-detect from pi model registry (ctx.model.contextWindow)
|
|
354
|
-
* 3. Fallback to 200K tokens
|
|
355
|
-
*
|
|
356
|
-
* A config value of 0 signals "auto-detect" — the default when no explicit
|
|
357
|
-
* value is configured. This allows pi's model registry to provide the real
|
|
358
|
-
* context window for the active model.
|
|
359
|
-
*
|
|
360
|
-
* @returns Object with `contextWindow` (resolved size) and `source` (diagnostic label)
|
|
361
|
-
*/
|
|
362
|
-
function resolveContextWindow(
|
|
363
|
-
config: TaskConfig,
|
|
364
|
-
ctx: ExtensionContext,
|
|
365
|
-
): { contextWindow: number; source: string } {
|
|
366
|
-
// 1. Explicit user config — non-zero means the user set it deliberately
|
|
367
|
-
const configVal = config.context.worker_context_window;
|
|
368
|
-
if (configVal > 0) {
|
|
369
|
-
return { contextWindow: configVal, source: "explicit config" };
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// 2. Auto-detect from pi model registry
|
|
373
|
-
const modelWindow = ctx.model?.contextWindow;
|
|
374
|
-
if (modelWindow && modelWindow > 0) {
|
|
375
|
-
const modelId = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unknown";
|
|
376
|
-
return { contextWindow: modelWindow, source: `auto-detected from ${modelId}` };
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// 3. Fallback
|
|
380
|
-
return { contextWindow: FALLBACK_CONTEXT_WINDOW, source: `fallback ${FALLBACK_CONTEXT_WINDOW}` };
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// ── Orchestrator Sidecar Files ────────────────────────────────────────
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Returns the .pi directory path for sidecar files (lane state, conversation logs).
|
|
387
|
-
* In orchestrated mode, the orchestrator passes ORCH_SIDECAR_DIR pointing to the
|
|
388
|
-
* MAIN repo's .pi/ directory (not the worktree's).
|
|
389
|
-
*/
|
|
390
|
-
function getSidecarDir(): string {
|
|
391
|
-
// Orchestrator provides the main repo .pi path
|
|
392
|
-
const orchDir = process.env.ORCH_SIDECAR_DIR;
|
|
393
|
-
if (orchDir) {
|
|
394
|
-
if (!existsSync(orchDir)) mkdirSync(orchDir, { recursive: true });
|
|
395
|
-
return orchDir;
|
|
396
|
-
}
|
|
397
|
-
// Fallback: walk up from cwd
|
|
398
|
-
let dir = process.cwd();
|
|
399
|
-
for (let i = 0; i < 10; i++) {
|
|
400
|
-
const piDir = join(dir, ".pi");
|
|
401
|
-
if (existsSync(piDir)) return piDir;
|
|
402
|
-
const parent = dirname(dir);
|
|
403
|
-
if (parent === dir) break;
|
|
404
|
-
dir = parent;
|
|
405
|
-
}
|
|
406
|
-
const piDir = join(process.cwd(), ".pi");
|
|
407
|
-
if (!existsSync(piDir)) mkdirSync(piDir, { recursive: true });
|
|
408
|
-
return piDir;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* Write lane state sidecar JSON for the web dashboard.
|
|
413
|
-
* Written every second when in orchestrated mode.
|
|
414
|
-
*/
|
|
415
|
-
function writeLaneState(state: TaskState): void {
|
|
416
|
-
if (!isOrchestratedMode()) return;
|
|
417
|
-
const prefix = getLanePrefix(); // e.g., "orch-lane-1"
|
|
418
|
-
const filePath = join(getSidecarDir(), `lane-state-${prefix}.json`);
|
|
419
|
-
try {
|
|
420
|
-
const data = {
|
|
421
|
-
prefix,
|
|
422
|
-
taskId: state.task?.taskId || null,
|
|
423
|
-
phase: state.phase,
|
|
424
|
-
currentStep: state.currentStep,
|
|
425
|
-
totalIterations: state.totalIterations,
|
|
426
|
-
workerIteration: state.workerIteration,
|
|
427
|
-
workerStatus: state.workerStatus,
|
|
428
|
-
workerElapsed: state.workerElapsed,
|
|
429
|
-
workerContextPct: state.workerContextPct,
|
|
430
|
-
workerLastTool: state.workerLastTool,
|
|
431
|
-
workerToolCount: state.workerToolCount,
|
|
432
|
-
workerInputTokens: state.workerInputTokens,
|
|
433
|
-
workerOutputTokens: state.workerOutputTokens,
|
|
434
|
-
workerCacheReadTokens: state.workerCacheReadTokens,
|
|
435
|
-
workerCacheWriteTokens: state.workerCacheWriteTokens,
|
|
436
|
-
workerCostUsd: state.workerCostUsd,
|
|
437
|
-
workerRetryActive: state.workerRetryActive,
|
|
438
|
-
workerRetryCount: state.workerRetryCount,
|
|
439
|
-
workerLastRetryError: state.workerLastRetryError,
|
|
440
|
-
workerExitDiagnostic: state.workerExitDiagnostic || undefined,
|
|
441
|
-
reviewerStatus: state.reviewerStatus || "idle",
|
|
442
|
-
reviewerSessionName: state.reviewerSessionName || "",
|
|
443
|
-
reviewerType: state.reviewerType || "",
|
|
444
|
-
reviewerStep: state.reviewerStep || 0,
|
|
445
|
-
reviewerElapsed: state.reviewerElapsed || 0,
|
|
446
|
-
reviewerContextPct: state.reviewerContextPct || 0,
|
|
447
|
-
reviewerLastTool: state.reviewerLastTool || "",
|
|
448
|
-
reviewerToolCount: state.reviewerToolCount || 0,
|
|
449
|
-
reviewerCostUsd: state.reviewerCostUsd || 0,
|
|
450
|
-
reviewerInputTokens: state.reviewerInputTokens || 0,
|
|
451
|
-
reviewerOutputTokens: state.reviewerOutputTokens || 0,
|
|
452
|
-
reviewerCacheReadTokens: state.reviewerCacheReadTokens || 0,
|
|
453
|
-
reviewerCacheWriteTokens: state.reviewerCacheWriteTokens || 0,
|
|
454
|
-
batchId: process.env.ORCH_BATCH_ID || null,
|
|
455
|
-
timestamp: Date.now(),
|
|
456
|
-
};
|
|
457
|
-
writeFileSync(filePath, JSON.stringify(data) + "\n");
|
|
458
|
-
} catch {
|
|
459
|
-
// Best effort — don't crash the runner
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* Write a context % snapshot at worker iteration boundary (TP-094).
|
|
465
|
-
* Best-effort JSONL append to `.pi/context-snapshots/{batchId}/{sessionName}.jsonl`.
|
|
466
|
-
* Non-fatal on any failure — never blocks execution.
|
|
467
|
-
*/
|
|
468
|
-
function writeContextSnapshot(state: TaskState, contextWindow: number): void {
|
|
469
|
-
const batchId = process.env.ORCH_BATCH_ID || "standalone";
|
|
470
|
-
const sessionName = isOrchestratedMode() ? `${getLanePrefix()}-worker` : "task-worker";
|
|
471
|
-
try {
|
|
472
|
-
const dir = join(getSidecarDir(), "context-snapshots", batchId);
|
|
473
|
-
mkdirSync(dir, { recursive: true });
|
|
474
|
-
const filePath = join(dir, `${sessionName}.jsonl`);
|
|
475
|
-
const snapshot = {
|
|
476
|
-
iteration: state.totalIterations,
|
|
477
|
-
contextPct: state.workerContextPct,
|
|
478
|
-
tokens: state.workerInputTokens + state.workerOutputTokens + state.workerCacheReadTokens + state.workerCacheWriteTokens,
|
|
479
|
-
contextWindow,
|
|
480
|
-
cost: state.workerCostUsd,
|
|
481
|
-
toolCalls: state.workerToolCount,
|
|
482
|
-
exitReason: state.workerExitDiagnostic?.classification || null,
|
|
483
|
-
timestamp: Date.now(),
|
|
484
|
-
};
|
|
485
|
-
appendFileSync(filePath, JSON.stringify(snapshot) + "\n");
|
|
486
|
-
} catch {
|
|
487
|
-
// Best effort — don't crash the runner
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* Append a JSON event to the conversation JSONL log file.
|
|
493
|
-
* Used in orchestrated mode to capture the full worker conversation for the web dashboard.
|
|
494
|
-
*/
|
|
495
|
-
function appendConversationEvent(prefix: string, event: Record<string, unknown>): void {
|
|
496
|
-
const filePath = join(getSidecarDir(), `worker-conversation-${prefix}.jsonl`);
|
|
497
|
-
try {
|
|
498
|
-
appendFileSync(filePath, JSON.stringify(event) + "\n");
|
|
499
|
-
} catch {
|
|
500
|
-
// Best effort
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Clear the conversation log at the start of a new worker iteration.
|
|
506
|
-
*/
|
|
507
|
-
function clearConversationLog(prefix: string): void {
|
|
508
|
-
const filePath = join(getSidecarDir(), `worker-conversation-${prefix}.jsonl`);
|
|
509
|
-
try {
|
|
510
|
-
writeFileSync(filePath, "");
|
|
511
|
-
} catch {
|
|
512
|
-
// Best effort
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// ── Agent Loader ─────────────────────────────────────────────────────
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Parse a markdown agent file into frontmatter key-value pairs and body content.
|
|
520
|
-
* Returns null if the file doesn't exist or has no frontmatter block.
|
|
521
|
-
*/
|
|
522
|
-
function parseAgentFile(filePath: string): { fm: Record<string, string>; body: string } | null {
|
|
523
|
-
if (!existsSync(filePath)) return null;
|
|
524
|
-
const raw = readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n");
|
|
525
|
-
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
526
|
-
if (!match) return null;
|
|
527
|
-
const fm: Record<string, string> = {};
|
|
528
|
-
for (const line of match[1].split("\n")) {
|
|
529
|
-
const idx = line.indexOf(":");
|
|
530
|
-
if (idx > 0) fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
531
|
-
}
|
|
532
|
-
return { fm, body: match[2].trim() };
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/** Cached package root — resolved once, reused for all agent file lookups. */
|
|
536
|
-
let _packageRoot: string | null = null;
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* Find the taskplane package root directory.
|
|
540
|
-
*
|
|
541
|
-
* Strategy: this file lives at <package-root>/extensions/task-runner.ts.
|
|
542
|
-
* When pi loads it via `-e`, it resolves the full path. We can find the
|
|
543
|
-
* package root by searching for package.json with name "taskplane"
|
|
544
|
-
* starting from known candidate locations.
|
|
545
|
-
*/
|
|
546
|
-
function findPackageRoot(): string {
|
|
547
|
-
if (_packageRoot !== null) return _packageRoot;
|
|
548
|
-
|
|
549
|
-
// Strategy 1: Walk up from this file's location via require.resolve or npm paths
|
|
550
|
-
const candidates: string[] = [];
|
|
551
|
-
|
|
552
|
-
// The extension is loaded by pi from the installed package location.
|
|
553
|
-
// Check well-known npm global paths.
|
|
554
|
-
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
555
|
-
if (home) {
|
|
556
|
-
candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", "taskplane"));
|
|
557
|
-
candidates.push(join(home, ".npm-global", "lib", "node_modules", "taskplane"));
|
|
558
|
-
}
|
|
559
|
-
candidates.push(join("/usr", "local", "lib", "node_modules", "taskplane"));
|
|
560
|
-
|
|
561
|
-
// Strategy 2: resolve from pi's node_modules peer
|
|
562
|
-
try {
|
|
563
|
-
const piPath = process.argv[1] || "";
|
|
564
|
-
const piPkgDir = resolve(piPath, "..", "..");
|
|
565
|
-
candidates.push(join(piPkgDir, "..", "taskplane"));
|
|
566
|
-
} catch { /* ignore */ }
|
|
567
|
-
|
|
568
|
-
// Strategy 3: Check TASKPLANE_WORKSPACE_ROOT project-local install
|
|
569
|
-
const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT;
|
|
570
|
-
if (wsRoot) {
|
|
571
|
-
candidates.push(join(wsRoot, ".pi", "npm", "node_modules", "taskplane"));
|
|
572
|
-
candidates.push(join(wsRoot, "node_modules", "taskplane"));
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
for (const dir of candidates) {
|
|
576
|
-
try {
|
|
577
|
-
const pkgPath = join(dir, "package.json");
|
|
578
|
-
if (existsSync(pkgPath)) {
|
|
579
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
580
|
-
if (pkg.name === "taskplane") {
|
|
581
|
-
_packageRoot = dir;
|
|
582
|
-
return dir;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
} catch { /* ignore */ }
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
_packageRoot = "";
|
|
589
|
-
return "";
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/**
|
|
593
|
-
* Resolve the package-shipped base agent file path.
|
|
594
|
-
* Base files live in the package's templates/agents/ directory.
|
|
595
|
-
*/
|
|
596
|
-
function resolveBaseAgentPath(name: string): string {
|
|
597
|
-
const root = findPackageRoot();
|
|
598
|
-
if (!root) return "";
|
|
599
|
-
return join(root, "templates", "agents", `${name}.md`);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
/**
|
|
603
|
-
* Resolve the path to rpc-wrapper.mjs from the installed taskplane package.
|
|
604
|
-
*
|
|
605
|
-
* Resolution strategy (first match wins):
|
|
606
|
-
* 1. Package root via findPackageRoot() (covers global npm, workspace, pi peer)
|
|
607
|
-
* 2. Project-local node_modules/taskplane (for non-workspace local installs)
|
|
608
|
-
* 3. Global npm paths (explicit fallback for layouts findPackageRoot may miss)
|
|
609
|
-
* 4. Extension-file-relative: derive package root from the `-e` arg that loaded
|
|
610
|
-
* this extension (handles dev scenarios where cwd differs from checkout)
|
|
611
|
-
* 5. Development fallback: cwd/bin/rpc-wrapper.mjs (running from taskplane repo)
|
|
612
|
-
*
|
|
613
|
-
* @returns Absolute path to rpc-wrapper.mjs
|
|
614
|
-
* @throws Error if rpc-wrapper.mjs cannot be found
|
|
615
|
-
*/
|
|
616
|
-
function resolveRpcWrapperPath(): string {
|
|
617
|
-
const wrapperRelPath = join("bin", "rpc-wrapper.mjs");
|
|
618
|
-
const searched: string[] = [];
|
|
619
|
-
|
|
620
|
-
const tryPath = (dir: string): string | null => {
|
|
621
|
-
const p = join(dir, wrapperRelPath);
|
|
622
|
-
searched.push(p);
|
|
623
|
-
return existsSync(p) ? p : null;
|
|
624
|
-
};
|
|
625
|
-
|
|
626
|
-
// 1. Package root (installed npm package — covers global, workspace, peer)
|
|
627
|
-
const root = findPackageRoot();
|
|
628
|
-
if (root) {
|
|
629
|
-
const found = tryPath(root);
|
|
630
|
-
if (found) return found;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
// 2. Project-local node_modules (non-workspace local installs)
|
|
634
|
-
const cwdLocal = join(process.cwd(), "node_modules", "taskplane");
|
|
635
|
-
if (existsSync(cwdLocal)) {
|
|
636
|
-
const found = tryPath(cwdLocal);
|
|
637
|
-
if (found) return found;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// 3. Global npm paths (explicit check for layouts findPackageRoot may miss)
|
|
641
|
-
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
642
|
-
const globalCandidates: string[] = [];
|
|
643
|
-
if (process.env.APPDATA) {
|
|
644
|
-
globalCandidates.push(join(process.env.APPDATA, "npm", "node_modules", "taskplane"));
|
|
645
|
-
}
|
|
646
|
-
if (home) {
|
|
647
|
-
globalCandidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", "taskplane"));
|
|
648
|
-
globalCandidates.push(join(home, ".npm-global", "lib", "node_modules", "taskplane"));
|
|
649
|
-
}
|
|
650
|
-
globalCandidates.push(join("/usr", "local", "lib", "node_modules", "taskplane"));
|
|
651
|
-
for (const dir of globalCandidates) {
|
|
652
|
-
const found = tryPath(dir);
|
|
653
|
-
if (found) return found;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// 4. Extension-file-relative: derive package root from the -e argument
|
|
657
|
-
// that loaded this file. This covers dev scenarios where the extension
|
|
658
|
-
// is loaded from a local checkout but cwd is a different directory
|
|
659
|
-
// (e.g., a worktree or integration test working directory).
|
|
660
|
-
// This file lives at <package-root>/extensions/task-runner.ts, so walk up two levels.
|
|
661
|
-
try {
|
|
662
|
-
const args = process.argv;
|
|
663
|
-
for (let i = 0; i < args.length - 1; i++) {
|
|
664
|
-
if (args[i] === "-e" && args[i + 1]?.includes("task-runner")) {
|
|
665
|
-
const extPath = resolve(args[i + 1]);
|
|
666
|
-
const derivedRoot = resolve(extPath, "..", "..");
|
|
667
|
-
const found = tryPath(derivedRoot);
|
|
668
|
-
if (found) return found;
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
} catch { /* ignore argv parsing errors */ }
|
|
672
|
-
|
|
673
|
-
// 5. Development fallback: running from the taskplane repo directly
|
|
674
|
-
const cwdDev = process.cwd();
|
|
675
|
-
const devFound = tryPath(cwdDev);
|
|
676
|
-
if (devFound) return devFound;
|
|
677
|
-
|
|
678
|
-
throw new Error(
|
|
679
|
-
"Cannot find rpc-wrapper.mjs. Ensure taskplane is installed correctly. " +
|
|
680
|
-
`Searched: ${searched.join(", ")}`
|
|
681
|
-
);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/**
|
|
685
|
-
* Resolve the path to reviewer-extension.ts from the installed taskplane package.
|
|
686
|
-
* Mirrors resolveRpcWrapperPath() resolution strategy.
|
|
687
|
-
*
|
|
688
|
-
* @returns Absolute path to reviewer-extension.ts, or null if not found
|
|
689
|
-
* @since TP-057
|
|
690
|
-
*/
|
|
691
|
-
function resolveReviewerExtensionPath(): string | null {
|
|
692
|
-
const extRelPath = join("extensions", "reviewer-extension.ts");
|
|
693
|
-
|
|
694
|
-
// 1. Package root
|
|
695
|
-
const root = findPackageRoot();
|
|
696
|
-
if (root) {
|
|
697
|
-
const p = join(root, extRelPath);
|
|
698
|
-
if (existsSync(p)) return p;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
// 2. Extension-file-relative (dev scenario: task-runner.ts is sibling)
|
|
702
|
-
try {
|
|
703
|
-
const args = process.argv;
|
|
704
|
-
for (let i = 0; i < args.length - 1; i++) {
|
|
705
|
-
if (args[i] === "-e" && args[i + 1]?.includes("task-runner")) {
|
|
706
|
-
const extPath = resolve(args[i + 1]);
|
|
707
|
-
const derivedRoot = resolve(extPath, "..", "..");
|
|
708
|
-
const p = join(derivedRoot, extRelPath);
|
|
709
|
-
if (existsSync(p)) return p;
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
} catch { /* ignore */ }
|
|
713
|
-
|
|
714
|
-
// 3. Development fallback: cwd
|
|
715
|
-
const cwdDev = join(process.cwd(), extRelPath);
|
|
716
|
-
if (existsSync(cwdDev)) return cwdDev;
|
|
717
|
-
|
|
718
|
-
return null;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* Load an agent definition with prompt inheritance.
|
|
723
|
-
*
|
|
724
|
-
* Inheritance model (default: compose base + local):
|
|
725
|
-
* 1. Load base agent from the shipped package (templates/agents/{name}.md)
|
|
726
|
-
* 2. Load local agent from .pi/agents/{name}.md (if it exists) — or from
|
|
727
|
-
* the pointer-resolved agent root in workspace mode
|
|
728
|
-
* 3. If local file has `standalone: true` in frontmatter, use it as-is (no base)
|
|
729
|
-
* 4. Otherwise, compose: base prompt + separator + local content
|
|
730
|
-
* 5. Local frontmatter values (tools, model) override base values
|
|
731
|
-
*
|
|
732
|
-
* Local override resolution order:
|
|
733
|
-
* 1. `<cwd>/.pi/agents/{name}.md` — worktree/repo local override (always first)
|
|
734
|
-
* 2. `<cwd>/agents/{name}.md` — worktree/repo local override (legacy location)
|
|
735
|
-
* 3. `<pointerAgentRoot>/{name}.md` — pointer-resolved config repo agents (workspace mode)
|
|
736
|
-
* First found wins. If none found, base file is used directly.
|
|
737
|
-
*
|
|
738
|
-
* If no base file exists (e.g., custom agent), local file is used as-is.
|
|
739
|
-
*/
|
|
740
|
-
function loadAgentDef(cwd: string, name: string): { systemPrompt: string; tools: string; model: string } | null {
|
|
741
|
-
const basePath = resolveBaseAgentPath(name);
|
|
742
|
-
const localPaths = [join(cwd, ".pi", "agents", `${name}.md`), join(cwd, "agents", `${name}.md`)];
|
|
743
|
-
|
|
744
|
-
// In workspace mode, add pointer-resolved agent root as fallback
|
|
745
|
-
const pointer = resolveTaskRunnerPointer();
|
|
746
|
-
if (pointer?.agentRoot) {
|
|
747
|
-
localPaths.push(join(pointer.agentRoot, `${name}.md`));
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// Load base from package
|
|
751
|
-
const baseDef = parseAgentFile(basePath);
|
|
752
|
-
|
|
753
|
-
// Load local override (first found wins)
|
|
754
|
-
let localDef: { fm: Record<string, string>; body: string } | null = null;
|
|
755
|
-
for (const p of localPaths) {
|
|
756
|
-
localDef = parseAgentFile(p);
|
|
757
|
-
if (localDef) break;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
// No base and no local → null
|
|
761
|
-
if (!baseDef && !localDef) return null;
|
|
762
|
-
|
|
763
|
-
// Local with standalone: true → use local as-is, ignore base
|
|
764
|
-
if (localDef?.fm.standalone === "true") {
|
|
765
|
-
return {
|
|
766
|
-
systemPrompt: localDef.body,
|
|
767
|
-
tools: localDef.fm.tools || "read,grep,find,ls",
|
|
768
|
-
model: localDef.fm.model || "",
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// Compose base + local
|
|
773
|
-
const basePrompt = baseDef?.body || "";
|
|
774
|
-
const localPrompt = localDef?.body || "";
|
|
775
|
-
const composedPrompt = localPrompt
|
|
776
|
-
? basePrompt + "\n\n---\n\n## Project-Specific Guidance\n\n" + localPrompt
|
|
777
|
-
: basePrompt;
|
|
778
|
-
|
|
779
|
-
// Local frontmatter overrides base (tools, model)
|
|
780
|
-
const tools = localDef?.fm.tools || baseDef?.fm.tools || "read,grep,find,ls";
|
|
781
|
-
const model = localDef?.fm.model || baseDef?.fm.model || "";
|
|
782
|
-
|
|
783
|
-
return { systemPrompt: composedPrompt.trim(), tools, model };
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
// ── PROMPT.md Parser ─────────────────────────────────────────────────
|
|
787
|
-
|
|
788
|
-
function parsePromptMd(content: string, promptPath: string): ParsedTask {
|
|
789
|
-
const core = coreParsePromptMd(content, promptPath);
|
|
790
|
-
return { ...core };
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// ── STATUS.md Parser ─────────────────────────────────────────────────
|
|
794
|
-
|
|
795
|
-
function parseStatusMd(content: string): { steps: StepInfo[]; reviewCounter: number; iteration: number } {
|
|
796
|
-
return coreParseStatusMd(content);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// ── STATUS.md Generator ──────────────────────────────────────────────
|
|
800
|
-
|
|
801
|
-
function generateStatusMd(task: ParsedTask): string {
|
|
802
|
-
return coreGenerateStatusMd(task);
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// ── STATUS.md Updaters ───────────────────────────────────────────────
|
|
806
|
-
|
|
807
|
-
function updateStatusField(statusPath: string, field: string, value: string): void {
|
|
808
|
-
coreUpdateStatusField(statusPath, field, value);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
function updateStepStatus(statusPath: string, stepNum: number, status: "not-started" | "in-progress" | "complete"): void {
|
|
812
|
-
coreUpdateStepStatus(statusPath, stepNum, status);
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
function appendTableRow(statusPath: string, sectionName: string, row: string): void {
|
|
816
|
-
coreAppendTableRow(statusPath, sectionName, row);
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
function logExecution(statusPath: string, action: string, outcome: string): void {
|
|
820
|
-
coreLogExecution(statusPath, action, outcome);
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
/**
|
|
824
|
-
* TP-090: Sanitize steering message content for safe injection into a markdown table row.
|
|
825
|
-
* Collapses newlines to " / ", escapes pipe characters, and truncates to 200 chars.
|
|
826
|
-
*/
|
|
827
|
-
function sanitizeSteeringContent(content: string): string {
|
|
828
|
-
return coreSanitizeSteeringContent(content);
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
function logReview(statusPath: string, num: string, type: string, stepNum: number, verdict: string, file: string): void {
|
|
832
|
-
coreLogReview(statusPath, num, type, stepNum, verdict, file);
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
// ── Project Context Builder ──────────────────────────────────────────
|
|
836
|
-
|
|
837
|
-
function buildProjectContext(config: TaskConfig, taskFolder: string): string {
|
|
838
|
-
const resolved = resolveStandards(config, taskFolder);
|
|
839
|
-
const lines: string[] = [`## Project: ${config.project.name}`];
|
|
840
|
-
if (config.project.description) lines.push(config.project.description);
|
|
841
|
-
lines.push("");
|
|
842
|
-
if (resolved.rules.length > 0) {
|
|
843
|
-
lines.push("## Code Standards");
|
|
844
|
-
for (const r of resolved.rules) lines.push(`- ${r}`);
|
|
845
|
-
lines.push("");
|
|
846
|
-
}
|
|
847
|
-
if (resolved.docs.length > 0) {
|
|
848
|
-
lines.push("## Reference Documentation");
|
|
849
|
-
for (const d of resolved.docs) lines.push(`- ${d}`);
|
|
850
|
-
lines.push("");
|
|
851
|
-
}
|
|
852
|
-
if (Object.keys(config.testing.commands).length > 0) {
|
|
853
|
-
lines.push("## Testing Commands");
|
|
854
|
-
for (const [name, cmd] of Object.entries(config.testing.commands)) lines.push(`- **${name}:** \`${cmd}\``);
|
|
855
|
-
lines.push("");
|
|
856
|
-
}
|
|
857
|
-
lines.push(`## Task Folder\n${taskFolder}`);
|
|
858
|
-
return lines.join("\n");
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
// ── Git Helpers ──────────────────────────────────────────────────────
|
|
862
|
-
|
|
863
|
-
/**
|
|
864
|
-
* Returns the current HEAD commit SHA (short form).
|
|
865
|
-
* Used to capture baseline before a step starts so code reviews
|
|
866
|
-
* can diff against the correct range instead of just uncommitted changes.
|
|
867
|
-
*/
|
|
868
|
-
function getHeadCommitSha(): string {
|
|
869
|
-
return coreGetHeadCommitSha();
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
/**
|
|
873
|
-
* Find the git commit SHA where a specific step was completed.
|
|
874
|
-
* Workers commit at step boundaries with messages like:
|
|
875
|
-
* feat(TP-048): complete Step N — description
|
|
876
|
-
* Returns the commit SHA if found, or empty string.
|
|
877
|
-
*/
|
|
878
|
-
function findStepBoundaryCommit(stepNumber: number, taskId: string, since?: string): string {
|
|
879
|
-
return coreFindStepBoundaryCommit(stepNumber, taskId, since);
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
// ── Standards Resolution ─────────────────────────────────────────────
|
|
883
|
-
|
|
884
|
-
/**
|
|
885
|
-
* Resolve which standards apply to a task based on its area.
|
|
886
|
-
*
|
|
887
|
-
* Matches the task's folder path against `task_areas` paths to find the
|
|
888
|
-
* area name, then checks `standards_overrides` for area-specific standards.
|
|
889
|
-
* Falls back to global `standards` if no override exists.
|
|
890
|
-
*
|
|
891
|
-
* This allows TypeScript extension tasks (e.g., task-system area) to use
|
|
892
|
-
* different review standards than Go backend service tasks.
|
|
893
|
-
*/
|
|
894
|
-
function resolveStandards(config: TaskConfig, taskFolder: string): { docs: string[]; rules: string[] } {
|
|
895
|
-
return coreResolveStandards(config.standards, config.standards_overrides, config.task_areas, taskFolder);
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// ── Review Request Generator ─────────────────────────────────────────
|
|
899
|
-
|
|
900
|
-
function generateReviewRequest(
|
|
901
|
-
type: "plan" | "code", stepNum: number, stepName: string,
|
|
902
|
-
task: ParsedTask, config: TaskConfig, outputPath: string,
|
|
903
|
-
stepBaselineCommit?: string,
|
|
904
|
-
): string {
|
|
905
|
-
const standards = resolveStandards(config, task.taskFolder);
|
|
906
|
-
return coreGenerateReviewRequest(type, stepNum, stepName, task.promptPath, task.taskFolder, config.project.name, standards, outputPath, stepBaselineCommit);
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
function extractVerdict(reviewContent: string): string {
|
|
910
|
-
return coreExtractVerdict(reviewContent);
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
/**
|
|
914
|
-
* Process a review verdict: extract the verdict from review content, log it,
|
|
915
|
-
* update the status file, and build the result text for the worker.
|
|
916
|
-
*
|
|
917
|
-
* Shared by the persistent reviewer path and the fallback fresh-spawn path
|
|
918
|
-
* in the review_step tool handler.
|
|
919
|
-
*/
|
|
920
|
-
function processReviewVerdict(
|
|
921
|
-
reviewContent: string | null,
|
|
922
|
-
statusPath: string,
|
|
923
|
-
num: string,
|
|
924
|
-
reviewType: string,
|
|
925
|
-
stepNum: number,
|
|
926
|
-
reviewCounter: number,
|
|
927
|
-
suffix?: string,
|
|
928
|
-
): { verdict: string; resultText: string } {
|
|
929
|
-
let verdict = "UNKNOWN";
|
|
930
|
-
let reviseDetails = "";
|
|
931
|
-
if (reviewContent) {
|
|
932
|
-
verdict = extractVerdict(reviewContent);
|
|
933
|
-
if (verdict === "REVISE") {
|
|
934
|
-
const summaryMatch = reviewContent.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
|
|
935
|
-
reviseDetails = summaryMatch
|
|
936
|
-
? summaryMatch[1].trim().slice(0, 500)
|
|
937
|
-
: "See review file for details.";
|
|
938
|
-
}
|
|
939
|
-
} else {
|
|
940
|
-
verdict = "UNAVAILABLE";
|
|
941
|
-
const label = suffix ? `${suffix} reviewer` : "reviewer";
|
|
942
|
-
logExecution(statusPath, `Reviewer R${num}`,
|
|
943
|
-
`${reviewType} review — ${label} did not produce output`);
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
const reviewFile = `.reviews/R${num}-${reviewType}-step${stepNum}.md`;
|
|
947
|
-
logReview(statusPath, `R${num}`, reviewType, stepNum, verdict, reviewFile);
|
|
948
|
-
const logSuffix = suffix ? ` (${suffix})` : "";
|
|
949
|
-
logExecution(statusPath, `Review R${num}`,
|
|
950
|
-
`${reviewType} Step ${stepNum}: ${verdict}${logSuffix}`);
|
|
951
|
-
updateStatusField(statusPath, "Review Counter", `${reviewCounter}`);
|
|
952
|
-
|
|
953
|
-
let resultText: string;
|
|
954
|
-
if (verdict === "APPROVE") {
|
|
955
|
-
resultText = "APPROVE";
|
|
956
|
-
} else if (verdict === "REVISE") {
|
|
957
|
-
resultText = `REVISE: ${reviseDetails}\n\nFull review: ${reviewFile}`;
|
|
958
|
-
} else if (verdict === "RETHINK") {
|
|
959
|
-
resultText = `RETHINK — reconsider your approach. See ${reviewFile}`;
|
|
960
|
-
} else {
|
|
961
|
-
resultText = `UNAVAILABLE — reviewer did not produce a usable verdict.`;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
return { verdict, resultText };
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
// ── Subagent Spawner ─────────────────────────────────────────────────
|
|
968
|
-
|
|
969
|
-
function spawnAgent(opts: {
|
|
970
|
-
model?: string; tools: string; thinking?: string;
|
|
971
|
-
systemPrompt: string; prompt: string;
|
|
972
|
-
contextWindow?: number; warnPct?: number; killPct?: number;
|
|
973
|
-
wrapUpFile?: string;
|
|
974
|
-
onToolCall?: (toolName: string, args: any) => void;
|
|
975
|
-
onContextPct?: (pct: number) => void;
|
|
976
|
-
onTokenUpdate?: (tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }) => void;
|
|
977
|
-
onJsonEvent?: (event: Record<string, unknown>) => void;
|
|
978
|
-
}): { promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>; kill: () => void } {
|
|
979
|
-
let killFn: () => void = () => {};
|
|
980
|
-
|
|
981
|
-
const promise = new Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>((resolve) => {
|
|
982
|
-
// Write system prompt and user prompt to temp files to avoid
|
|
983
|
-
// shell escaping issues (backticks, quotes, etc. in markdown)
|
|
984
|
-
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
985
|
-
const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
|
|
986
|
-
const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
|
|
987
|
-
writeFileSync(sysTmpFile, opts.systemPrompt);
|
|
988
|
-
writeFileSync(promptTmpFile, opts.prompt);
|
|
989
|
-
|
|
990
|
-
const args = [
|
|
991
|
-
"-p", "--mode", "json",
|
|
992
|
-
"--no-session", "--no-extensions", "--no-skills",
|
|
993
|
-
"--tools", opts.tools,
|
|
994
|
-
];
|
|
995
|
-
if (opts.model) args.push("--model", opts.model);
|
|
996
|
-
if (opts.thinking) args.push("--thinking", opts.thinking);
|
|
997
|
-
args.push(
|
|
998
|
-
"--append-system-prompt", sysTmpFile,
|
|
999
|
-
`@${promptTmpFile}`,
|
|
1000
|
-
);
|
|
1001
|
-
|
|
1002
|
-
const proc = spawn("pi", args, {
|
|
1003
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1004
|
-
env: { ...process.env },
|
|
1005
|
-
shell: true,
|
|
1006
|
-
});
|
|
1007
|
-
|
|
1008
|
-
// Clean up temp files after process finishes
|
|
1009
|
-
const cleanupTmp = () => {
|
|
1010
|
-
setTimeout(() => {
|
|
1011
|
-
try { unlinkSync(sysTmpFile); } catch {}
|
|
1012
|
-
try { unlinkSync(promptTmpFile); } catch {}
|
|
1013
|
-
}, 1000);
|
|
1014
|
-
};
|
|
1015
|
-
|
|
1016
|
-
let killed = false;
|
|
1017
|
-
const startTime = Date.now();
|
|
1018
|
-
const textChunks: string[] = [];
|
|
1019
|
-
let buffer = "";
|
|
1020
|
-
|
|
1021
|
-
killFn = () => { killed = true; proc.kill("SIGTERM"); };
|
|
1022
|
-
|
|
1023
|
-
proc.stdout!.setEncoding("utf-8");
|
|
1024
|
-
proc.stdout!.on("data", (chunk: string) => {
|
|
1025
|
-
buffer += chunk;
|
|
1026
|
-
const lines = buffer.split("\n");
|
|
1027
|
-
buffer = lines.pop() || "";
|
|
1028
|
-
for (const line of lines) {
|
|
1029
|
-
if (!line.trim()) continue;
|
|
1030
|
-
try {
|
|
1031
|
-
const event = JSON.parse(line);
|
|
1032
|
-
// Tee all events to JSONL log if callback provided
|
|
1033
|
-
opts.onJsonEvent?.(event);
|
|
1034
|
-
if (event.type === "message_update") {
|
|
1035
|
-
const delta = event.assistantMessageEvent;
|
|
1036
|
-
if (delta?.type === "text_delta" && delta.delta) {
|
|
1037
|
-
textChunks.push(delta.delta);
|
|
1038
|
-
}
|
|
1039
|
-
} else if (event.type === "tool_execution_start") {
|
|
1040
|
-
opts.onToolCall?.(event.toolName, event.args);
|
|
1041
|
-
} else if (event.type === "message_end") {
|
|
1042
|
-
const usage = event.message?.usage;
|
|
1043
|
-
if (usage) {
|
|
1044
|
-
// Report per-turn token counts to caller (caller accumulates).
|
|
1045
|
-
// Anthropic `input` = uncached new tokens only; `cacheRead`
|
|
1046
|
-
// holds bulk of input. `cost.total` = exact dollar cost for turn.
|
|
1047
|
-
opts.onTokenUpdate?.({
|
|
1048
|
-
input: (usage as any).input || 0,
|
|
1049
|
-
output: (usage as any).output || 0,
|
|
1050
|
-
cacheRead: (usage as any).cacheRead || 0,
|
|
1051
|
-
cacheWrite: (usage as any).cacheWrite || 0,
|
|
1052
|
-
cost: (usage as any).cost?.total || 0,
|
|
1053
|
-
});
|
|
1054
|
-
if (opts.contextWindow) {
|
|
1055
|
-
// Use totalTokens (cumulative) — works across providers.
|
|
1056
|
-
// Anthropic reports small `input` per-turn but growing `totalTokens`.
|
|
1057
|
-
// OpenAI reports growing `input` but also growing `totalTokens`.
|
|
1058
|
-
// Include cacheRead: pi's totalTokens excludes cache reads,
|
|
1059
|
-
// but cached tokens still consume context window capacity.
|
|
1060
|
-
const rawTokens = (usage as any).totalTokens || ((usage as any).input + (usage as any).output) || 0;
|
|
1061
|
-
const tokens = rawTokens + ((usage as any).cacheRead || 0);
|
|
1062
|
-
if (tokens > 0) {
|
|
1063
|
-
const pct = (tokens / opts.contextWindow) * 100;
|
|
1064
|
-
opts.onContextPct?.(pct);
|
|
1065
|
-
if (opts.warnPct && pct >= opts.warnPct && opts.wrapUpFile && !existsSync(opts.wrapUpFile)) {
|
|
1066
|
-
writeFileSync(opts.wrapUpFile, `Wrap up at ${new Date().toISOString()}`);
|
|
1067
|
-
}
|
|
1068
|
-
if (opts.killPct && pct >= opts.killPct && !killed) {
|
|
1069
|
-
killed = true;
|
|
1070
|
-
proc.kill("SIGTERM");
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
} catch {}
|
|
1077
|
-
}
|
|
1078
|
-
});
|
|
1079
|
-
|
|
1080
|
-
proc.stderr?.setEncoding("utf-8");
|
|
1081
|
-
proc.stderr?.on("data", () => {});
|
|
1082
|
-
|
|
1083
|
-
proc.on("close", (code) => {
|
|
1084
|
-
cleanupTmp();
|
|
1085
|
-
if (buffer.trim()) {
|
|
1086
|
-
try {
|
|
1087
|
-
const event = JSON.parse(buffer);
|
|
1088
|
-
if (event.type === "message_update") {
|
|
1089
|
-
const delta = event.assistantMessageEvent;
|
|
1090
|
-
if (delta?.type === "text_delta") textChunks.push(delta.delta || "");
|
|
1091
|
-
}
|
|
1092
|
-
} catch {}
|
|
1093
|
-
}
|
|
1094
|
-
resolve({ output: textChunks.join(""), exitCode: code ?? 1, elapsed: Date.now() - startTime, killed });
|
|
1095
|
-
});
|
|
1096
|
-
|
|
1097
|
-
proc.on("error", (err) => {
|
|
1098
|
-
cleanupTmp();
|
|
1099
|
-
resolve({ output: `Error: ${err.message}`, exitCode: 1, elapsed: Date.now() - startTime, killed: false });
|
|
1100
|
-
});
|
|
1101
|
-
});
|
|
1102
|
-
|
|
1103
|
-
return { promise, kill: () => killFn() };
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
// ── Sidecar JSONL Tailing ────────────────────────────────────────────
|
|
1107
|
-
|
|
1108
|
-
/**
|
|
1109
|
-
* Mutable state for incremental byte-offset sidecar JSONL reading.
|
|
1110
|
-
* One instance per sidecar file, persists across poll ticks within a session.
|
|
1111
|
-
*/
|
|
1112
|
-
interface SidecarTailState {
|
|
1113
|
-
/** Byte offset of the next unread position in the sidecar file */
|
|
1114
|
-
offset: number;
|
|
1115
|
-
/** Partial trailing line from the last read (incomplete JSONL line) */
|
|
1116
|
-
partial: string;
|
|
1117
|
-
/** Whether a retry is currently active (persisted across ticks) */
|
|
1118
|
-
retryActive: boolean;
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
function createSidecarTailState(): SidecarTailState {
|
|
1122
|
-
return { offset: 0, partial: "", retryActive: false };
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
/**
|
|
1126
|
-
* Parsed telemetry accumulated from sidecar JSONL events.
|
|
1127
|
-
* Returned by tailSidecarJsonl() on each tick.
|
|
1128
|
-
*/
|
|
1129
|
-
interface SidecarTelemetryDelta {
|
|
1130
|
-
/** Per-turn input tokens (sum of new message_end events in this tick) */
|
|
1131
|
-
inputTokens: number;
|
|
1132
|
-
outputTokens: number;
|
|
1133
|
-
cacheReadTokens: number;
|
|
1134
|
-
cacheWriteTokens: number;
|
|
1135
|
-
/** Incremental cost from new message_end events */
|
|
1136
|
-
cost: number;
|
|
1137
|
-
/** Most recent totalTokens from message_end usage (cumulative, for context %) */
|
|
1138
|
-
latestTotalTokens: number;
|
|
1139
|
-
/** Tool calls observed in this tick */
|
|
1140
|
-
toolCalls: number;
|
|
1141
|
-
/** Last tool description from tool_execution_start */
|
|
1142
|
-
lastTool: string;
|
|
1143
|
-
/** Whether a retry is currently active (persisted across ticks via SidecarTailState) */
|
|
1144
|
-
retryActive: boolean;
|
|
1145
|
-
/** Total retries started in this tick */
|
|
1146
|
-
retriesStarted: number;
|
|
1147
|
-
/** Error message from the most recent auto_retry_start */
|
|
1148
|
-
lastRetryError: string;
|
|
1149
|
-
/** Whether any sidecar events were parsed in this tick (used for callback gating) */
|
|
1150
|
-
hadEvents: boolean;
|
|
1151
|
-
/** Authoritative context usage from pi get_session_stats (pi ≥ 0.63.0, null if unavailable) */
|
|
1152
|
-
contextUsage: { percent: number; totalTokens: number; maxTokens: number } | null;
|
|
1153
|
-
/** True when a get_session_stats response was seen but lacked contextUsage (older pi) */
|
|
1154
|
-
sawStatsResponseWithoutContextUsage: boolean;
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
/**
|
|
1158
|
-
* Incrementally read new lines from a sidecar JSONL file and parse telemetry events.
|
|
1159
|
-
*
|
|
1160
|
-
* O(new) per call — only reads bytes after the previous offset. Handles:
|
|
1161
|
-
* - File not yet created (returns zero delta)
|
|
1162
|
-
* - Empty reads (no new data since last tick)
|
|
1163
|
-
* - Partial trailing lines (buffered for next call)
|
|
1164
|
-
* - Malformed JSON lines (skipped with stderr warning, does not break iteration)
|
|
1165
|
-
*
|
|
1166
|
-
* The caller (poll loop) accumulates the returned deltas into TaskState.
|
|
1167
|
-
*/
|
|
1168
|
-
function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): SidecarTelemetryDelta {
|
|
1169
|
-
const delta: SidecarTelemetryDelta = {
|
|
1170
|
-
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
|
1171
|
-
cost: 0, latestTotalTokens: 0, toolCalls: 0, lastTool: "",
|
|
1172
|
-
retryActive: tailState.retryActive, retriesStarted: 0, lastRetryError: "",
|
|
1173
|
-
hadEvents: false, contextUsage: null, sawStatsResponseWithoutContextUsage: false,
|
|
1174
|
-
};
|
|
1175
|
-
|
|
1176
|
-
// Gracefully handle missing file (wrapper hasn't written yet)
|
|
1177
|
-
let fileSize: number;
|
|
1178
|
-
try {
|
|
1179
|
-
fileSize = statSync(filePath).size;
|
|
1180
|
-
} catch {
|
|
1181
|
-
return delta; // File doesn't exist yet — no-op
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
if (fileSize <= tailState.offset) {
|
|
1185
|
-
return delta; // No new data
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
// Read new bytes from offset to end of file
|
|
1189
|
-
const bytesToRead = fileSize - tailState.offset;
|
|
1190
|
-
const buf = Buffer.alloc(bytesToRead);
|
|
1191
|
-
let fd: number;
|
|
1192
|
-
try {
|
|
1193
|
-
fd = openSync(filePath, "r");
|
|
1194
|
-
} catch {
|
|
1195
|
-
return delta; // File became inaccessible between stat and open
|
|
1196
|
-
}
|
|
1197
|
-
try {
|
|
1198
|
-
readSync(fd, buf, 0, bytesToRead, tailState.offset);
|
|
1199
|
-
} catch {
|
|
1200
|
-
closeSync(fd);
|
|
1201
|
-
return delta; // Read error — try again next tick
|
|
1202
|
-
}
|
|
1203
|
-
closeSync(fd);
|
|
1204
|
-
tailState.offset = fileSize;
|
|
1205
|
-
|
|
1206
|
-
// Split into lines, preserving any partial trailing line
|
|
1207
|
-
const chunk = tailState.partial + buf.toString("utf-8");
|
|
1208
|
-
const lines = chunk.split("\n");
|
|
1209
|
-
// Last element is either "" (if chunk ended with \n) or a partial line
|
|
1210
|
-
tailState.partial = lines.pop() || "";
|
|
1211
|
-
|
|
1212
|
-
for (const line of lines) {
|
|
1213
|
-
const trimmed = line.trim();
|
|
1214
|
-
if (!trimmed) continue;
|
|
1215
|
-
|
|
1216
|
-
let event: any;
|
|
1217
|
-
try {
|
|
1218
|
-
event = JSON.parse(trimmed);
|
|
1219
|
-
} catch {
|
|
1220
|
-
// Malformed JSON — skip silently (concurrent write race, truncated line)
|
|
1221
|
-
continue;
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
if (!event || !event.type) continue;
|
|
1225
|
-
|
|
1226
|
-
delta.hadEvents = true;
|
|
1227
|
-
|
|
1228
|
-
switch (event.type) {
|
|
1229
|
-
case "message_end": {
|
|
1230
|
-
const usage = event.message?.usage;
|
|
1231
|
-
if (usage) {
|
|
1232
|
-
delta.inputTokens += usage.input || 0;
|
|
1233
|
-
delta.outputTokens += usage.output || 0;
|
|
1234
|
-
delta.cacheReadTokens += usage.cacheRead || 0;
|
|
1235
|
-
delta.cacheWriteTokens += usage.cacheWrite || 0;
|
|
1236
|
-
if (usage.cost) {
|
|
1237
|
-
delta.cost += typeof usage.cost === "object"
|
|
1238
|
-
? (usage.cost.total || 0)
|
|
1239
|
-
: (typeof usage.cost === "number" ? usage.cost : 0);
|
|
1240
|
-
}
|
|
1241
|
-
// totalTokens is cumulative (grows each turn) — use latest value.
|
|
1242
|
-
// Include cacheRead tokens: pi's totalTokens and the
|
|
1243
|
-
// input+output fallback both exclude cache reads, but cached
|
|
1244
|
-
// tokens still consume context window capacity.
|
|
1245
|
-
const rawTotal = usage.totalTokens
|
|
1246
|
-
|| ((usage.input || 0) + (usage.output || 0));
|
|
1247
|
-
const totalTokens = rawTotal + (usage.cacheRead || 0);
|
|
1248
|
-
if (totalTokens > delta.latestTotalTokens) {
|
|
1249
|
-
delta.latestTotalTokens = totalTokens;
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
break;
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
case "tool_execution_start": {
|
|
1256
|
-
delta.toolCalls++;
|
|
1257
|
-
const toolDesc = event.toolName || "unknown";
|
|
1258
|
-
let argPreview = "";
|
|
1259
|
-
if (event.args) {
|
|
1260
|
-
if (typeof event.args === "string") {
|
|
1261
|
-
argPreview = event.args.slice(0, 80);
|
|
1262
|
-
} else if (typeof event.args === "object") {
|
|
1263
|
-
const firstVal = Object.values(event.args)[0];
|
|
1264
|
-
if (typeof firstVal === "string") {
|
|
1265
|
-
argPreview = (firstVal as string).slice(0, 80);
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
delta.lastTool = argPreview ? `${toolDesc} ${argPreview}` : toolDesc;
|
|
1270
|
-
break;
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
case "auto_retry_start": {
|
|
1274
|
-
delta.retriesStarted++;
|
|
1275
|
-
delta.lastRetryError = event.errorMessage || event.error || "unknown";
|
|
1276
|
-
tailState.retryActive = true;
|
|
1277
|
-
break;
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
case "auto_retry_end": {
|
|
1281
|
-
tailState.retryActive = false;
|
|
1282
|
-
break;
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
case "response": {
|
|
1286
|
-
// get_session_stats response from pi ≥ 0.63.0 — authoritative context usage
|
|
1287
|
-
if (event.success === true && event.data?.contextUsage) {
|
|
1288
|
-
const cu = event.data.contextUsage;
|
|
1289
|
-
// pi sends `percent` (pi ≥ 0.63.0); accept `percentUsed` as legacy fallback
|
|
1290
|
-
const pctValue = cu.percent ?? cu.percentUsed;
|
|
1291
|
-
if (typeof pctValue === "number") {
|
|
1292
|
-
delta.contextUsage = {
|
|
1293
|
-
percent: pctValue,
|
|
1294
|
-
totalTokens: cu.totalTokens || 0,
|
|
1295
|
-
maxTokens: cu.maxTokens || 0,
|
|
1296
|
-
};
|
|
1297
|
-
}
|
|
1298
|
-
} else if (event.success === true && event.data && !event.data.contextUsage) {
|
|
1299
|
-
// Successful get_session_stats response but no contextUsage — older pi
|
|
1300
|
-
delta.sawStatsResponseWithoutContextUsage = true;
|
|
1301
|
-
}
|
|
1302
|
-
break;
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
// Reflect persisted retry state into the delta for the caller
|
|
1308
|
-
delta.retryActive = tailState.retryActive;
|
|
1309
|
-
return delta;
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
/** Expose sidecar tailing internals for testing (not part of public API). */
|
|
1313
|
-
export const _tailSidecarJsonl = tailSidecarJsonl;
|
|
1314
|
-
export const _createSidecarTailState = createSidecarTailState;
|
|
1315
|
-
export const _getSidecarDir = getSidecarDir;
|
|
1316
|
-
export const _resolveContextWindow = resolveContextWindow;
|
|
1317
|
-
export const _FALLBACK_CONTEXT_WINDOW = FALLBACK_CONTEXT_WINDOW;
|
|
1318
|
-
export type { SidecarTailState, SidecarTelemetryDelta };
|
|
1319
|
-
|
|
1320
|
-
/**
|
|
1321
|
-
* Determine whether a step is "low-risk" and should skip reviews.
|
|
1322
|
-
* Low-risk steps: Step 0 (Preflight) and the final step (Delivery/Docs).
|
|
1323
|
-
*/
|
|
1324
|
-
export function isLowRiskStep(stepNumber: number, totalSteps: number): boolean {
|
|
1325
|
-
return coreIsLowRiskStep(stepNumber, totalSteps);
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// ── Display Helpers ──────────────────────────────────────────────────
|
|
1329
|
-
|
|
1330
|
-
function displayName(name: string): string {
|
|
1331
|
-
return coreDisplayName(name);
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
// ── Extension ────────────────────────────────────────────────────────
|
|
1335
|
-
|
|
1336
|
-
export default function (pi: ExtensionAPI) {
|
|
1337
|
-
let state = freshState();
|
|
1338
|
-
let widgetCtx: ExtensionContext | undefined;
|
|
1339
|
-
|
|
1340
|
-
// ── Widget Rendering ─────────────────────────────────────────────
|
|
1341
|
-
|
|
1342
|
-
function renderStepCard(step: StepInfo, colWidth: number, theme: any): string[] {
|
|
1343
|
-
const w = colWidth - 2;
|
|
1344
|
-
const trunc = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
|
|
1345
|
-
|
|
1346
|
-
const isRunning = state.currentStep === step.number && state.phase === "running";
|
|
1347
|
-
const statusColor = step.status === "complete" ? "success"
|
|
1348
|
-
: step.status === "in-progress" ? "accent" : "dim";
|
|
1349
|
-
const statusIcon = step.status === "complete" ? "✓"
|
|
1350
|
-
: step.status === "in-progress" ? "●" : "○";
|
|
1351
|
-
|
|
1352
|
-
const nameStr = theme.fg("accent", theme.bold(trunc(`Step ${step.number}`, w)));
|
|
1353
|
-
const nameVis = Math.min(`Step ${step.number}`.length, w);
|
|
1354
|
-
|
|
1355
|
-
const statusStr = `${statusIcon} ${trunc(step.name, w - 4)}`;
|
|
1356
|
-
const statusLine = theme.fg(statusColor, statusStr);
|
|
1357
|
-
const statusVis = Math.min(statusStr.length, w);
|
|
1358
|
-
|
|
1359
|
-
const progress = `${step.totalChecked}/${step.totalItems} ✓`;
|
|
1360
|
-
const progressLine = theme.fg(step.totalChecked === step.totalItems && step.totalItems > 0 ? "success" : "muted", progress);
|
|
1361
|
-
const progressVis = progress.length;
|
|
1362
|
-
|
|
1363
|
-
let extraStr = "";
|
|
1364
|
-
let extraVis = 0;
|
|
1365
|
-
if (isRunning && state.workerStatus === "running") {
|
|
1366
|
-
extraStr = theme.fg("accent", `iter ${state.workerIteration}`) + theme.fg("dim", ` ctx:${Math.round(state.workerContextPct)}%`);
|
|
1367
|
-
extraVis = `iter ${state.workerIteration} ctx:${Math.round(state.workerContextPct)}%`.length;
|
|
1368
|
-
} else if (isRunning && state.reviewerStatus === "running") {
|
|
1369
|
-
extraStr = theme.fg("warning", `reviewing...`);
|
|
1370
|
-
extraVis = "reviewing...".length;
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
const top = "┌" + "─".repeat(w) + "┐";
|
|
1374
|
-
const bot = "└" + "─".repeat(w) + "┘";
|
|
1375
|
-
const border = (content: string, vis: number) =>
|
|
1376
|
-
theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - vis)) + theme.fg("dim", "│");
|
|
1377
|
-
|
|
1378
|
-
return [
|
|
1379
|
-
theme.fg("dim", top),
|
|
1380
|
-
border(" " + nameStr, 1 + nameVis),
|
|
1381
|
-
border(" " + statusLine, 1 + statusVis),
|
|
1382
|
-
border(" " + progressLine, 1 + progressVis),
|
|
1383
|
-
border(extraStr ? " " + extraStr : "", extraVis ? 1 + extraVis : 0),
|
|
1384
|
-
theme.fg("dim", bot),
|
|
1385
|
-
];
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
function updateWidgets() {
|
|
1389
|
-
// Write sidecar state for web dashboard (orchestrated mode)
|
|
1390
|
-
writeLaneState(state);
|
|
1391
|
-
|
|
1392
|
-
if (!widgetCtx) return;
|
|
1393
|
-
const ctx = widgetCtx;
|
|
1394
|
-
|
|
1395
|
-
// Refresh step statuses from STATUS.md if task is active
|
|
1396
|
-
if (state.task) {
|
|
1397
|
-
const statusPath = join(state.task.taskFolder, "STATUS.md");
|
|
1398
|
-
if (existsSync(statusPath)) {
|
|
1399
|
-
try {
|
|
1400
|
-
const parsed = parseStatusMd(readFileSync(statusPath, "utf-8"));
|
|
1401
|
-
for (const s of parsed.steps) state.stepStatuses.set(s.number, s);
|
|
1402
|
-
} catch {}
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
ctx.ui.setWidget("task-runner", (_tui: any, theme: any) => {
|
|
1407
|
-
return {
|
|
1408
|
-
render(width: number): string[] {
|
|
1409
|
-
if (!state.task) {
|
|
1410
|
-
return [];
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
const task = state.task;
|
|
1414
|
-
const lines: string[] = [""];
|
|
1415
|
-
|
|
1416
|
-
// Header
|
|
1417
|
-
const phaseIcon = state.phase === "running" ? "●"
|
|
1418
|
-
: state.phase === "paused" ? "⏸"
|
|
1419
|
-
: state.phase === "complete" ? "✓"
|
|
1420
|
-
: state.phase === "error" ? "✗" : "○";
|
|
1421
|
-
const phaseColor = state.phase === "running" ? "accent"
|
|
1422
|
-
: state.phase === "complete" ? "success"
|
|
1423
|
-
: state.phase === "error" ? "error" : "dim";
|
|
1424
|
-
|
|
1425
|
-
const header =
|
|
1426
|
-
theme.fg(phaseColor, ` ${phaseIcon} `) +
|
|
1427
|
-
theme.fg("accent", theme.bold(task.taskId)) +
|
|
1428
|
-
theme.fg("dim", ": ") +
|
|
1429
|
-
theme.fg("muted", task.taskName) +
|
|
1430
|
-
theme.fg("dim", " ") +
|
|
1431
|
-
theme.fg("warning", `L${task.reviewLevel}`) +
|
|
1432
|
-
theme.fg("dim", " · ") +
|
|
1433
|
-
theme.fg("muted", task.size) +
|
|
1434
|
-
theme.fg("dim", " · ") +
|
|
1435
|
-
theme.fg("success", `iter ${state.totalIterations}`);
|
|
1436
|
-
lines.push(truncateToWidth(header, width));
|
|
1437
|
-
|
|
1438
|
-
// Progress bar
|
|
1439
|
-
const allSteps = task.steps.map(s => state.stepStatuses.get(s.number) || s);
|
|
1440
|
-
const totalCb = allSteps.reduce((a, s) => a + s.totalItems, 0);
|
|
1441
|
-
const doneCb = allSteps.reduce((a, s) => a + s.totalChecked, 0);
|
|
1442
|
-
const pct = totalCb > 0 ? Math.round((doneCb / totalCb) * 100) : 0;
|
|
1443
|
-
const barWidth = Math.min(30, width - 20);
|
|
1444
|
-
const filled = Math.round((pct / 100) * barWidth);
|
|
1445
|
-
const progressBar =
|
|
1446
|
-
theme.fg("dim", " ") +
|
|
1447
|
-
theme.fg("warning", "[") +
|
|
1448
|
-
theme.fg("success", "█".repeat(filled)) +
|
|
1449
|
-
theme.fg("dim", "░".repeat(barWidth - filled)) +
|
|
1450
|
-
theme.fg("warning", "]") +
|
|
1451
|
-
theme.fg("dim", " ") +
|
|
1452
|
-
theme.fg("accent", `${doneCb}/${totalCb}`) +
|
|
1453
|
-
theme.fg("dim", ` (${pct}%)`);
|
|
1454
|
-
lines.push(truncateToWidth(progressBar, width));
|
|
1455
|
-
lines.push("");
|
|
1456
|
-
|
|
1457
|
-
// Step cards — fit as many as the terminal allows, wrap to rows
|
|
1458
|
-
const steps = allSteps;
|
|
1459
|
-
const arrowWidth = 3;
|
|
1460
|
-
// Calculate how many cards fit in one row
|
|
1461
|
-
const minCardWidth = 16;
|
|
1462
|
-
const maxCols = Math.max(1, Math.floor((width + arrowWidth) / (minCardWidth + arrowWidth)));
|
|
1463
|
-
const cols = Math.min(steps.length, maxCols);
|
|
1464
|
-
const colWidth = Math.max(minCardWidth, Math.floor((width - arrowWidth * (cols - 1)) / cols));
|
|
1465
|
-
|
|
1466
|
-
// Render in rows of `cols` cards
|
|
1467
|
-
for (let rowStart = 0; rowStart < steps.length; rowStart += cols) {
|
|
1468
|
-
const rowSteps = steps.slice(rowStart, rowStart + cols);
|
|
1469
|
-
const cards = rowSteps.map(s => renderStepCard(s, colWidth, theme));
|
|
1470
|
-
|
|
1471
|
-
if (cards.length > 0) {
|
|
1472
|
-
const cardHeight = cards[0].length;
|
|
1473
|
-
const arrowRow = 2;
|
|
1474
|
-
for (let line = 0; line < cardHeight; line++) {
|
|
1475
|
-
let row = cards[0][line];
|
|
1476
|
-
for (let c = 1; c < cards.length; c++) {
|
|
1477
|
-
row += line === arrowRow ? theme.fg("dim", " → ") : " ";
|
|
1478
|
-
row += cards[c][line];
|
|
1479
|
-
}
|
|
1480
|
-
lines.push(truncateToWidth(row, width));
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
// Worker status line
|
|
1486
|
-
if (state.workerStatus === "running") {
|
|
1487
|
-
lines.push("");
|
|
1488
|
-
lines.push(truncateToWidth(
|
|
1489
|
-
theme.fg("accent", " ● Worker: ") +
|
|
1490
|
-
theme.fg("dim", `${Math.round(state.workerElapsed / 1000)}s · `) +
|
|
1491
|
-
theme.fg("dim", `🔧${state.workerToolCount}`) +
|
|
1492
|
-
(state.workerLastTool
|
|
1493
|
-
? theme.fg("dim", " · ") + theme.fg("muted", state.workerLastTool)
|
|
1494
|
-
: ""),
|
|
1495
|
-
width,
|
|
1496
|
-
));
|
|
1497
|
-
} else if (state.reviewerStatus === "running") {
|
|
1498
|
-
lines.push("");
|
|
1499
|
-
lines.push(truncateToWidth(
|
|
1500
|
-
theme.fg("warning", " ◉ Reviewer: ") +
|
|
1501
|
-
theme.fg("dim", `${state.reviewerType} · ${Math.round(state.reviewerElapsed / 1000)}s`) +
|
|
1502
|
-
(state.reviewerLastTool
|
|
1503
|
-
? theme.fg("dim", " · ") + theme.fg("muted", state.reviewerLastTool)
|
|
1504
|
-
: ""),
|
|
1505
|
-
width,
|
|
1506
|
-
));
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
return lines;
|
|
1510
|
-
},
|
|
1511
|
-
invalidate() {},
|
|
1512
|
-
};
|
|
1513
|
-
});
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
// ── review_step Tool (orchestrated mode only) ───────────────────
|
|
1517
|
-
|
|
1518
|
-
/** Per-step code review cycle counter. Reset after code review completion. */
|
|
1519
|
-
const stepCodeReviewCounts = new Map<number, number>();
|
|
1520
|
-
|
|
1521
|
-
function clearReviewerState(): void {
|
|
1522
|
-
state.reviewerStatus = "idle";
|
|
1523
|
-
state.reviewerType = "";
|
|
1524
|
-
state.reviewerStep = 0;
|
|
1525
|
-
state.reviewerSessionName = "";
|
|
1526
|
-
state.reviewerElapsed = 0;
|
|
1527
|
-
state.reviewerLastTool = "";
|
|
1528
|
-
state.reviewerToolCount = 0;
|
|
1529
|
-
state.reviewerInputTokens = 0;
|
|
1530
|
-
state.reviewerOutputTokens = 0;
|
|
1531
|
-
state.reviewerCacheReadTokens = 0;
|
|
1532
|
-
state.reviewerCacheWriteTokens = 0;
|
|
1533
|
-
state.reviewerCostUsd = 0;
|
|
1534
|
-
state.reviewerContextPct = 0;
|
|
1535
|
-
state.reviewerProc = null;
|
|
1536
|
-
if (state.reviewerTimer) clearInterval(state.reviewerTimer);
|
|
1537
|
-
state.reviewerTimer = null;
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
async function shutdownPersistentReviewer(reason: string): Promise<void> {
|
|
1541
|
-
state.persistentReviewerSession = null;
|
|
1542
|
-
state.persistentReviewerKill = null;
|
|
1543
|
-
state.persistentReviewerSignalNum = 0;
|
|
1544
|
-
state.reviewerRespawnCount = 0;
|
|
1545
|
-
clearReviewerState();
|
|
1546
|
-
writeLaneState(state);
|
|
1547
|
-
if (state.task) {
|
|
1548
|
-
const statusPath = join(state.task.taskFolder, "STATUS.md");
|
|
1549
|
-
logExecution(statusPath, "Reviewer cleanup", `No persistent reviewer active (${reason})`);
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
if (isOrchestratedMode()) {
|
|
1554
|
-
pi.registerTool({
|
|
1555
|
-
name: "review_step",
|
|
1556
|
-
label: "Review Step",
|
|
1557
|
-
description:
|
|
1558
|
-
"Spawn a reviewer agent to evaluate your work on a step. " +
|
|
1559
|
-
"Returns APPROVE, REVISE, RETHINK, or UNAVAILABLE. " +
|
|
1560
|
-
"Use at step boundaries based on the task's review level.",
|
|
1561
|
-
promptSnippet: "review_step(step, type) — spawn reviewer for a step (plan/code review)",
|
|
1562
|
-
promptGuidelines: [
|
|
1563
|
-
"Call review_step at step boundaries based on the task's Review Level (from STATUS.md header).",
|
|
1564
|
-
"Review Level 0: skip all reviews. Level 1: plan review before implementing. Level 2: plan + code review. Level 3: plan + code + test review.",
|
|
1565
|
-
"Skip reviews for Step 0 (Preflight) and the final documentation/delivery step.",
|
|
1566
|
-
"For code reviews: before starting a step, capture the current HEAD commit with `git rev-parse HEAD` and pass it as the `baseline` parameter.",
|
|
1567
|
-
"On REVISE: read the review file in .reviews/ for detailed feedback, address the issues, commit fixes, then proceed.",
|
|
1568
|
-
"On RETHINK: reconsider your plan approach, adjust, then implement.",
|
|
1569
|
-
"On UNAVAILABLE: reviewer failed — proceed with caution.",
|
|
1570
|
-
],
|
|
1571
|
-
parameters: Type.Object({
|
|
1572
|
-
step: Type.Number({ description: "Step number to review" }),
|
|
1573
|
-
type: Type.Union(
|
|
1574
|
-
[Type.Literal("plan"), Type.Literal("code")],
|
|
1575
|
-
{ description: 'Review type: "plan" or "code"' },
|
|
1576
|
-
),
|
|
1577
|
-
baseline: Type.Optional(Type.String({
|
|
1578
|
-
description: "Git commit SHA to use as the diff baseline for code reviews.",
|
|
1579
|
-
})),
|
|
1580
|
-
}),
|
|
1581
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1582
|
-
const { step: stepNum, type: reviewType, baseline } = params;
|
|
1583
|
-
if (!state.task || !state.config) {
|
|
1584
|
-
return { content: [{ type: "text" as const, text: "UNAVAILABLE — no task loaded" }], details: undefined };
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
const task = state.task;
|
|
1588
|
-
const config = state.config;
|
|
1589
|
-
const statusPath = join(task.taskFolder, "STATUS.md");
|
|
1590
|
-
const reviewsDir = join(task.taskFolder, ".reviews");
|
|
1591
|
-
if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
|
|
1592
|
-
|
|
1593
|
-
if (reviewType === "code") {
|
|
1594
|
-
const codeCount = (stepCodeReviewCounts.get(stepNum) || 0) + 1;
|
|
1595
|
-
stepCodeReviewCounts.set(stepNum, codeCount);
|
|
1596
|
-
const maxCycles = config.context.max_review_cycles || 2;
|
|
1597
|
-
if (codeCount > maxCycles) {
|
|
1598
|
-
logExecution(statusPath, "Skip code review", `Step ${stepNum} code review cycle limit reached (${codeCount}/${maxCycles}) — auto-approving`);
|
|
1599
|
-
stepCodeReviewCounts.delete(stepNum);
|
|
1600
|
-
return {
|
|
1601
|
-
content: [{ type: "text" as const, text: `APPROVE — Code review cycle limit reached (${maxCycles}). Auto-approved to prevent context exhaustion.` }],
|
|
1602
|
-
details: undefined,
|
|
1603
|
-
};
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
if (isLowRiskStep(stepNum, task.steps.length)) {
|
|
1608
|
-
const label = stepNum === 0 ? "Preflight" : "final step";
|
|
1609
|
-
logExecution(statusPath, `Skip ${reviewType} review`, `Step ${stepNum} (${label}) — low-risk`);
|
|
1610
|
-
return {
|
|
1611
|
-
content: [{ type: "text" as const, text: `APPROVE — Step ${stepNum} is low-risk (${label}), review skipped` }],
|
|
1612
|
-
details: undefined,
|
|
1613
|
-
};
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
state.reviewCounter++;
|
|
1617
|
-
const num = String(state.reviewCounter).padStart(3, "0");
|
|
1618
|
-
const requestPath = join(reviewsDir, `request-R${num}.md`);
|
|
1619
|
-
const outputPath = join(reviewsDir, `R${num}-${reviewType}-step${stepNum}.md`);
|
|
1620
|
-
const stepBaselineCommit: string | undefined = reviewType === "code" ? (baseline || undefined) : undefined;
|
|
1621
|
-
const stepInfo = task.steps.find(s => s.number === stepNum);
|
|
1622
|
-
const stepName = stepInfo?.name || `Step ${stepNum}`;
|
|
1623
|
-
const request = generateReviewRequest(reviewType, stepNum, stepName, task, config, outputPath, stepBaselineCommit);
|
|
1624
|
-
writeFileSync(requestPath, request);
|
|
1625
|
-
|
|
1626
|
-
const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
|
|
1627
|
-
const reviewerModelFallback = process.env.TASKPLANE_MODEL_FALLBACK === "1";
|
|
1628
|
-
const reviewerModel = reviewerModelFallback
|
|
1629
|
-
? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
|
|
1630
|
-
: (config.reviewer.model || reviewerDef?.model || "");
|
|
1631
|
-
const reviewerPrompt = reviewerDef?.systemPrompt || "You are a code reviewer. Read the request and write your review to the specified output file.";
|
|
1632
|
-
const systemPrompt = reviewerPrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
|
|
1633
|
-
const promptContent = readFileSync(requestPath, "utf-8");
|
|
1634
|
-
|
|
1635
|
-
state.reviewerStatus = "running";
|
|
1636
|
-
state.reviewerType = `${reviewType} review`;
|
|
1637
|
-
state.reviewerStep = stepNum;
|
|
1638
|
-
state.reviewerSessionName = "reviewer-subprocess";
|
|
1639
|
-
state.reviewerElapsed = 0;
|
|
1640
|
-
state.reviewerLastTool = "";
|
|
1641
|
-
state.reviewerToolCount = 0;
|
|
1642
|
-
updateWidgets();
|
|
1643
|
-
|
|
1644
|
-
const startTime = Date.now();
|
|
1645
|
-
state.reviewerTimer = setInterval(() => {
|
|
1646
|
-
state.reviewerElapsed = Date.now() - startTime;
|
|
1647
|
-
updateWidgets();
|
|
1648
|
-
}, 1000);
|
|
1649
|
-
|
|
1650
|
-
const spawned = spawnAgent({
|
|
1651
|
-
model: reviewerModel,
|
|
1652
|
-
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
1653
|
-
thinking: config.reviewer.thinking || undefined,
|
|
1654
|
-
systemPrompt,
|
|
1655
|
-
prompt: promptContent,
|
|
1656
|
-
onToolCall: (toolName, args) => {
|
|
1657
|
-
state.reviewerToolCount++;
|
|
1658
|
-
const path = args?.path || args?.command || "";
|
|
1659
|
-
const shortPath = typeof path === "string" && path.length > 60 ? "..." + path.slice(-57) : path;
|
|
1660
|
-
state.reviewerLastTool = `${toolName} ${shortPath}`.trim();
|
|
1661
|
-
updateWidgets();
|
|
1662
|
-
},
|
|
1663
|
-
onTokenUpdate: (tokens) => {
|
|
1664
|
-
state.reviewerInputTokens += tokens.input;
|
|
1665
|
-
state.reviewerOutputTokens += tokens.output;
|
|
1666
|
-
state.reviewerCacheReadTokens += tokens.cacheRead;
|
|
1667
|
-
state.reviewerCacheWriteTokens += tokens.cacheWrite;
|
|
1668
|
-
state.reviewerCostUsd += tokens.cost;
|
|
1669
|
-
updateWidgets();
|
|
1670
|
-
},
|
|
1671
|
-
onContextPct: (pct) => {
|
|
1672
|
-
state.reviewerContextPct = pct;
|
|
1673
|
-
updateWidgets();
|
|
1674
|
-
},
|
|
1675
|
-
});
|
|
1676
|
-
state.reviewerProc = { kill: spawned.kill };
|
|
1677
|
-
|
|
1678
|
-
try {
|
|
1679
|
-
await spawned.promise;
|
|
1680
|
-
const reviewContent = existsSync(outputPath) ? readFileSync(outputPath, "utf-8") : null;
|
|
1681
|
-
const { resultText, verdict } = processReviewVerdict(
|
|
1682
|
-
reviewContent, statusPath, num, reviewType, stepNum, state.reviewCounter,
|
|
1683
|
-
);
|
|
1684
|
-
if (reviewType === "code" && (verdict === "APPROVE" || verdict === "UNAVAILABLE")) {
|
|
1685
|
-
stepCodeReviewCounts.delete(stepNum);
|
|
1686
|
-
}
|
|
1687
|
-
clearReviewerState();
|
|
1688
|
-
writeLaneState(state);
|
|
1689
|
-
updateWidgets();
|
|
1690
|
-
return { content: [{ type: "text" as const, text: resultText }], details: undefined };
|
|
1691
|
-
} catch (err: any) {
|
|
1692
|
-
clearReviewerState();
|
|
1693
|
-
state.reviewerStatus = "error";
|
|
1694
|
-
writeLaneState(state);
|
|
1695
|
-
updateWidgets();
|
|
1696
|
-
const msg = `UNAVAILABLE — reviewer failed: ${err?.message || err}`;
|
|
1697
|
-
logExecution(statusPath, `Reviewer R${num}`, msg);
|
|
1698
|
-
return { content: [{ type: "text" as const, text: msg }], details: undefined };
|
|
1699
|
-
}
|
|
1700
|
-
},
|
|
1701
|
-
});
|
|
1702
|
-
}
|
|
1703
|
-
|
|
1704
|
-
// ── Execution Engine ─────────────────────────────────────────────
|
|
1705
|
-
|
|
1706
|
-
async function executeTask(ctx: ExtensionContext): Promise<void> {
|
|
1707
|
-
if (!state.task || !state.config) return;
|
|
1708
|
-
|
|
1709
|
-
const task = state.task;
|
|
1710
|
-
const config = state.config;
|
|
1711
|
-
const statusPath = join(task.taskFolder, "STATUS.md");
|
|
1712
|
-
|
|
1713
|
-
updateStatusField(statusPath, "Status", "🟡 In Progress");
|
|
1714
|
-
updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
|
|
1715
|
-
|
|
1716
|
-
// TP-098: Distinguish first start from restart/resume to prevent
|
|
1717
|
-
// duplicate "Task started" entries in the execution log (#348).
|
|
1718
|
-
if (state.totalIterations === 0) {
|
|
1719
|
-
logExecution(statusPath, "Task started", "Extension-driven execution");
|
|
1720
|
-
} else {
|
|
1721
|
-
logExecution(statusPath, "Task resumed", `Resuming from iteration ${state.totalIterations}`);
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
// ── Per-task worker loop ─────────────────────────────────────
|
|
1725
|
-
// Spawn one worker per iteration; each worker handles ALL remaining
|
|
1726
|
-
// steps. The worker drives reviews inline via the review_step tool
|
|
1727
|
-
// (in orchestrated mode) — no deferred reviews after worker exit.
|
|
1728
|
-
// If context limit is hit mid-task, the next iteration picks up from
|
|
1729
|
-
// the first incomplete step via STATUS.md — same recovery mechanism.
|
|
1730
|
-
|
|
1731
|
-
// Mark only the first incomplete step as in-progress
|
|
1732
|
-
{
|
|
1733
|
-
const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
|
|
1734
|
-
let foundFirstIncomplete = false;
|
|
1735
|
-
for (const step of task.steps) {
|
|
1736
|
-
const ss = currentStatus.steps.find(s => s.number === step.number);
|
|
1737
|
-
if (ss?.status === "complete") continue;
|
|
1738
|
-
|
|
1739
|
-
if (!foundFirstIncomplete) {
|
|
1740
|
-
// Mark the first incomplete step as in-progress
|
|
1741
|
-
// TP-098: Only log "Step N started" if the step was not already
|
|
1742
|
-
// in-progress, preventing duplicate entries on restart (#348).
|
|
1743
|
-
if (ss?.status !== "in-progress") {
|
|
1744
|
-
updateStepStatus(statusPath, step.number, "in-progress");
|
|
1745
|
-
logExecution(statusPath, `Step ${step.number} started`, step.name);
|
|
1746
|
-
}
|
|
1747
|
-
foundFirstIncomplete = true;
|
|
1748
|
-
} else {
|
|
1749
|
-
// Ensure future steps show as not-started, not in-progress
|
|
1750
|
-
if (ss?.status === "in-progress") {
|
|
1751
|
-
updateStepStatus(statusPath, step.number, "not-started");
|
|
1752
|
-
}
|
|
1753
|
-
}
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
// Helper: determine if a parsed step is complete.
|
|
1758
|
-
function isStepComplete(ss: StepInfo | undefined): boolean {
|
|
1759
|
-
if (!ss) return false;
|
|
1760
|
-
if (ss.status === "complete") return true;
|
|
1761
|
-
// Fallback: infer from checkboxes (covers "in-progress" and "not-started")
|
|
1762
|
-
return ss.totalChecked === ss.totalItems && ss.totalItems > 0;
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
let noProgressCount = 0;
|
|
1767
|
-
for (let iter = 0; iter < config.context.max_worker_iterations; iter++) {
|
|
1768
|
-
if (state.phase === "paused") {
|
|
1769
|
-
logExecution(statusPath, "Paused", `User paused at iteration ${state.totalIterations}`);
|
|
1770
|
-
ctx.ui.notify(`Task paused at iteration ${state.totalIterations}`, "info");
|
|
1771
|
-
await shutdownPersistentReviewer("task paused");
|
|
1772
|
-
return;
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
// Determine remaining (incomplete) steps
|
|
1776
|
-
const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
|
|
1777
|
-
const remainingSteps: StepInfo[] = [];
|
|
1778
|
-
for (const step of task.steps) {
|
|
1779
|
-
const ss = currentStatus.steps.find(s => s.number === step.number);
|
|
1780
|
-
if (!isStepComplete(ss)) remainingSteps.push(step);
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
|
-
if (remainingSteps.length === 0) break; // All steps done
|
|
1784
|
-
|
|
1785
|
-
state.currentStep = remainingSteps[0].number;
|
|
1786
|
-
updateStatusField(statusPath, "Current Step", `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`);
|
|
1787
|
-
state.workerIteration = iter + 1;
|
|
1788
|
-
state.totalIterations++;
|
|
1789
|
-
updateStatusField(statusPath, "Iteration", `${state.totalIterations}`);
|
|
1790
|
-
updateWidgets();
|
|
1791
|
-
|
|
1792
|
-
// Count total checked checkboxes across all steps BEFORE worker runs
|
|
1793
|
-
const prevTotalChecked = currentStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
|
|
1794
|
-
|
|
1795
|
-
// Track which steps are complete before the worker runs
|
|
1796
|
-
const completedBefore = new Set<number>();
|
|
1797
|
-
for (const ss of currentStatus.steps) {
|
|
1798
|
-
if (isStepComplete(ss)) completedBefore.add(ss.number);
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
// ── TP-095: Reset stale lane-state fields before new worker spawn (#333) ──
|
|
1802
|
-
// When a worker crashes and restarts, the lane-state JSON retains stale
|
|
1803
|
-
// values (workerStatus: "done", phase: "error", workerExitDiagnostic from
|
|
1804
|
-
// the crash). Reset STATUS fields BEFORE the new worker spawns so the
|
|
1805
|
-
// dashboard immediately reflects the new running state.
|
|
1806
|
-
// IMPORTANT: Do NOT reset telemetry counters (tokens, cost) here — they
|
|
1807
|
-
// accumulate across worker iterations via += in onTelemetry (#334).
|
|
1808
|
-
if (state.totalIterations > 1) {
|
|
1809
|
-
state.phase = "running";
|
|
1810
|
-
state.workerStatus = "idle"; // Will be set to "running" by runWorker()
|
|
1811
|
-
state.workerExitDiagnostic = null;
|
|
1812
|
-
state.workerElapsed = 0;
|
|
1813
|
-
state.workerContextPct = 0;
|
|
1814
|
-
state.workerLastTool = "";
|
|
1815
|
-
state.workerRetryActive = false;
|
|
1816
|
-
state.workerRetryCount = 0;
|
|
1817
|
-
state.workerLastRetryError = "";
|
|
1818
|
-
// Note: workerToolCount, workerInputTokens, workerOutputTokens,
|
|
1819
|
-
// workerCacheReadTokens, workerCacheWriteTokens, workerCostUsd
|
|
1820
|
-
// are intentionally NOT reset — they persist across iterations.
|
|
1821
|
-
writeLaneState(state);
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
await runWorker(remainingSteps, ctx);
|
|
1825
|
-
|
|
1826
|
-
// Write context % snapshot at iteration boundary (TP-094)
|
|
1827
|
-
const { contextWindow: snapshotContextWindow } = resolveContextWindow(config, ctx);
|
|
1828
|
-
writeContextSnapshot(state, snapshotContextWindow);
|
|
1829
|
-
|
|
1830
|
-
// ── TP-090: Annotate STATUS.md with delivered steering messages ──
|
|
1831
|
-
// Check for .steering-pending JSONL flag written by rpc-wrapper.
|
|
1832
|
-
// Must happen BEFORE the error-return so messages are not dropped.
|
|
1833
|
-
const steeringFlagPath = join(task.taskFolder, ".steering-pending");
|
|
1834
|
-
try {
|
|
1835
|
-
if (existsSync(steeringFlagPath)) {
|
|
1836
|
-
const raw = readFileSync(steeringFlagPath, "utf-8");
|
|
1837
|
-
const lines = raw.split("\n").filter(l => l.trim());
|
|
1838
|
-
for (const line of lines) {
|
|
1839
|
-
try {
|
|
1840
|
-
const entry = JSON.parse(line) as { ts: number; content: string; id: string };
|
|
1841
|
-
const sanitized = sanitizeSteeringContent(entry.content);
|
|
1842
|
-
// Use the delivered message timestamp, not current time
|
|
1843
|
-
const ts = new Date(entry.ts).toISOString().slice(0, 16).replace("T", " ");
|
|
1844
|
-
appendTableRow(statusPath, "Execution Log", `| ${ts} | \u26a0\ufe0f Steering | ${sanitized} |`);
|
|
1845
|
-
console.error(`[task-runner] steering message annotated: ${entry.id}`);
|
|
1846
|
-
} catch {
|
|
1847
|
-
// Skip malformed JSONL lines
|
|
1848
|
-
}
|
|
1849
|
-
}
|
|
1850
|
-
unlinkSync(steeringFlagPath);
|
|
1851
|
-
}
|
|
1852
|
-
} catch (err: any) {
|
|
1853
|
-
// Non-fatal: steering annotation is supplementary
|
|
1854
|
-
console.error(`[task-runner] steering-pending annotation error: ${err?.message || err}`);
|
|
1855
|
-
}
|
|
1856
|
-
|
|
1857
|
-
if (state.phase === "error") {
|
|
1858
|
-
await shutdownPersistentReviewer("worker error");
|
|
1859
|
-
return;
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
// ── Post-worker: determine which steps were newly completed ──
|
|
1863
|
-
const afterStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
|
|
1864
|
-
const afterTotalChecked = afterStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0);
|
|
1865
|
-
|
|
1866
|
-
// Progress tracking: compare total checked across ALL steps
|
|
1867
|
-
const progressDelta = afterTotalChecked - prevTotalChecked;
|
|
1868
|
-
if (progressDelta <= 0) {
|
|
1869
|
-
noProgressCount++;
|
|
1870
|
-
// TP-098: Use state.totalIterations (global) instead of iter+1
|
|
1871
|
-
// (loop-local) to avoid label collision across restarts (#348).
|
|
1872
|
-
logExecution(statusPath, "No progress", `Iteration ${state.totalIterations}: 0 new checkboxes (${noProgressCount}/${config.context.no_progress_limit} stall limit)`);
|
|
1873
|
-
ctx.ui.notify(`⚠️ No progress in iteration ${state.totalIterations} (${noProgressCount}/${config.context.no_progress_limit})`, "warning");
|
|
1874
|
-
if (noProgressCount >= config.context.no_progress_limit) {
|
|
1875
|
-
logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
|
|
1876
|
-
ctx.ui.notify(`⚠️ Task blocked — no progress after ${noProgressCount} iterations`, "error");
|
|
1877
|
-
state.phase = "error";
|
|
1878
|
-
await shutdownPersistentReviewer("task stalled");
|
|
1879
|
-
return;
|
|
1880
|
-
}
|
|
1881
|
-
} else {
|
|
1882
|
-
noProgressCount = 0;
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
// Find newly completed steps.
|
|
1886
|
-
const newlyCompleted: StepInfo[] = [];
|
|
1887
|
-
for (const step of task.steps) {
|
|
1888
|
-
if (completedBefore.has(step.number)) continue;
|
|
1889
|
-
const ss = afterStatus.steps.find(s => s.number === step.number);
|
|
1890
|
-
if (isStepComplete(ss)) {
|
|
1891
|
-
updateStepStatus(statusPath, step.number, "complete");
|
|
1892
|
-
logExecution(statusPath, `Step ${step.number} complete`, step.name);
|
|
1893
|
-
newlyCompleted.push(step);
|
|
1894
|
-
}
|
|
1895
|
-
}
|
|
1896
|
-
|
|
1897
|
-
// ── Step transition: kill persistent reviewer for fresh context ──
|
|
1898
|
-
// When a step completes, the reviewer's context from that step is stale.
|
|
1899
|
-
// Kill it so the next step gets a clean reviewer session.
|
|
1900
|
-
if (newlyCompleted.length > 0 && state.persistentReviewerSession) {
|
|
1901
|
-
console.error(`[task-runner] step(s) completed — killing reviewer for fresh context`);
|
|
1902
|
-
logExecution(statusPath, "Reviewer cleanup",
|
|
1903
|
-
`killing persistent reviewer on step transition (${newlyCompleted.map(s => `Step ${s.number}`).join(", ")} completed)`);
|
|
1904
|
-
if (state.persistentReviewerKill) {
|
|
1905
|
-
try { state.persistentReviewerKill(); } catch {}
|
|
1906
|
-
}
|
|
1907
|
-
state.persistentReviewerSession = null;
|
|
1908
|
-
state.persistentReviewerKill = null;
|
|
1909
|
-
state.persistentReviewerSignalNum = 0;
|
|
1910
|
-
state.reviewerRespawnCount = 0;
|
|
1911
|
-
// Reset per-step code review counters for completed steps
|
|
1912
|
-
for (const step of newlyCompleted) {
|
|
1913
|
-
stepCodeReviewCounts.delete(step.number);
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
// Log iteration summary with progress delta and completed steps
|
|
1918
|
-
const completedNames = newlyCompleted.map(s => `Step ${s.number}`).join(", ");
|
|
1919
|
-
if (newlyCompleted.length > 0) {
|
|
1920
|
-
// TP-098: Use state.totalIterations (global) instead of iter+1
|
|
1921
|
-
// (loop-local) to avoid label collision across restarts (#348).
|
|
1922
|
-
logExecution(statusPath, `Iteration ${state.totalIterations} summary`, `+${progressDelta} checkboxes, completed: ${completedNames}`);
|
|
1923
|
-
ctx.ui.notify(`Iteration ${state.totalIterations}: completed ${completedNames} (+${progressDelta} checkboxes)`, "info");
|
|
1924
|
-
} else if (progressDelta > 0) {
|
|
1925
|
-
logExecution(statusPath, `Iteration ${state.totalIterations} summary`, `+${progressDelta} checkboxes, no steps fully completed`);
|
|
1926
|
-
ctx.ui.notify(`Iteration ${state.totalIterations}: +${progressDelta} checkboxes (no steps fully completed)`, "info");
|
|
1927
|
-
}
|
|
1928
|
-
|
|
1929
|
-
// Reviews are now driven inline by the worker via the review_step
|
|
1930
|
-
// tool (orchestrated mode). No deferred review logic here.
|
|
1931
|
-
|
|
1932
|
-
// Update local cache
|
|
1933
|
-
const refreshed = parseStatusMd(readFileSync(statusPath, "utf-8"));
|
|
1934
|
-
for (const s of refreshed.steps) state.stepStatuses.set(s.number, s);
|
|
1935
|
-
updateWidgets();
|
|
1936
|
-
|
|
1937
|
-
// Check if all steps are now complete
|
|
1938
|
-
const allComplete = task.steps.every(step => {
|
|
1939
|
-
const ss = refreshed.steps.find(s => s.number === step.number);
|
|
1940
|
-
return isStepComplete(ss);
|
|
1941
|
-
});
|
|
1942
|
-
if (allComplete) break;
|
|
1943
|
-
}
|
|
1944
|
-
|
|
1945
|
-
// ── Post-loop safety check: ensure all steps are actually complete ──
|
|
1946
|
-
// If the iteration cap was hit without completing all steps, fail explicitly
|
|
1947
|
-
// rather than falling through to quality gate / .DONE creation.
|
|
1948
|
-
if (state.phase === "running") {
|
|
1949
|
-
const finalStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
|
|
1950
|
-
const allStepsComplete = task.steps.every(step => {
|
|
1951
|
-
const ss = finalStatus.steps.find(s => s.number === step.number);
|
|
1952
|
-
return isStepComplete(ss);
|
|
1953
|
-
});
|
|
1954
|
-
if (!allStepsComplete) {
|
|
1955
|
-
const incomplete = task.steps
|
|
1956
|
-
.filter(step => {
|
|
1957
|
-
const ss = finalStatus.steps.find(s => s.number === step.number);
|
|
1958
|
-
return !isStepComplete(ss);
|
|
1959
|
-
})
|
|
1960
|
-
.map(s => `Step ${s.number}`)
|
|
1961
|
-
.join(", ");
|
|
1962
|
-
logExecution(statusPath, "Task incomplete", `Max iterations (${config.context.max_worker_iterations}) reached with incomplete steps: ${incomplete}`);
|
|
1963
|
-
ctx.ui.notify(`⚠️ Task incomplete — max iterations reached. Incomplete: ${incomplete}`, "error");
|
|
1964
|
-
state.phase = "error";
|
|
1965
|
-
await shutdownPersistentReviewer("max iterations reached");
|
|
1966
|
-
return;
|
|
1967
|
-
}
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
// ── TP-057: Shutdown persistent reviewer ────────────────────────
|
|
1971
|
-
await shutdownPersistentReviewer("task complete");
|
|
1972
|
-
|
|
1973
|
-
// All steps done — run quality gate if enabled, then create .DONE
|
|
1974
|
-
if (config.quality_gate.enabled) {
|
|
1975
|
-
// ── Quality Gate Enabled ─────────────────────────────────
|
|
1976
|
-
// Run structured review cycles with remediation. .DONE only
|
|
1977
|
-
// created after PASS verdict — never delete/recreate.
|
|
1978
|
-
const maxReviewCycles = config.quality_gate.max_review_cycles;
|
|
1979
|
-
const maxFixCycles = config.quality_gate.max_fix_cycles;
|
|
1980
|
-
let reviewCycle = 0;
|
|
1981
|
-
let fixCyclesUsed = 0;
|
|
1982
|
-
let gatePassed = false;
|
|
1983
|
-
let lastVerdict: ReviewVerdict | null = null;
|
|
1984
|
-
|
|
1985
|
-
const gateContext: QualityGateContext = {
|
|
1986
|
-
taskFolder: task.taskFolder,
|
|
1987
|
-
promptPath: task.promptPath,
|
|
1988
|
-
taskId: task.taskId,
|
|
1989
|
-
projectName: config.project.name,
|
|
1990
|
-
passThreshold: config.quality_gate.pass_threshold,
|
|
1991
|
-
};
|
|
1992
|
-
|
|
1993
|
-
logExecution(statusPath, "Quality gate", `Enabled (threshold: ${config.quality_gate.pass_threshold}, max reviews: ${maxReviewCycles}, max fixes: ${maxFixCycles})`);
|
|
1994
|
-
|
|
1995
|
-
while (reviewCycle < maxReviewCycles) {
|
|
1996
|
-
reviewCycle++;
|
|
1997
|
-
const result = await doQualityGateReview(ctx, reviewCycle);
|
|
1998
|
-
lastVerdict = result.verdict;
|
|
1999
|
-
|
|
2000
|
-
if (result.passed) {
|
|
2001
|
-
gatePassed = true;
|
|
2002
|
-
break;
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
// NEEDS_FIXES — check if we can still do a fix cycle
|
|
2006
|
-
if (reviewCycle >= maxReviewCycles) {
|
|
2007
|
-
// No more review cycles left — terminal failure
|
|
2008
|
-
logExecution(statusPath, "Quality gate", `Max review cycles (${maxReviewCycles}) exhausted — no more reviews allowed`);
|
|
2009
|
-
break;
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
if (fixCyclesUsed >= maxFixCycles) {
|
|
2013
|
-
// No more fix cycles allowed
|
|
2014
|
-
logExecution(statusPath, "Quality gate", `Max fix cycles (${maxFixCycles}) exhausted — cannot remediate`);
|
|
2015
|
-
break;
|
|
2016
|
-
}
|
|
2017
|
-
|
|
2018
|
-
// ── Remediation: write feedback, spawn fix agent ─────
|
|
2019
|
-
fixCyclesUsed++;
|
|
2020
|
-
|
|
2021
|
-
// Write REVIEW_FEEDBACK.md with blocking findings
|
|
2022
|
-
const feedbackContent = generateFeedbackMd(result.verdict, reviewCycle, maxReviewCycles, config.quality_gate.pass_threshold);
|
|
2023
|
-
const feedbackPath = join(task.taskFolder, FEEDBACK_FILENAME);
|
|
2024
|
-
try {
|
|
2025
|
-
writeFileSync(feedbackPath, feedbackContent);
|
|
2026
|
-
logExecution(statusPath, "Quality gate", `Wrote ${FEEDBACK_FILENAME} (fix cycle ${fixCyclesUsed}/${maxFixCycles})`);
|
|
2027
|
-
} catch (err: any) {
|
|
2028
|
-
logExecution(statusPath, "Quality gate", `Failed to write ${FEEDBACK_FILENAME}: ${err?.message} — skipping remediation`);
|
|
2029
|
-
break;
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
// Build fix agent prompt
|
|
2033
|
-
const fixPrompt = buildFixAgentPrompt(gateContext, feedbackContent, fixCyclesUsed);
|
|
2034
|
-
|
|
2035
|
-
// Spawn fix agent (reuses worker spawn pattern)
|
|
2036
|
-
const fixResult = await doQualityGateFixAgent(ctx, fixPrompt, fixCyclesUsed);
|
|
2037
|
-
|
|
2038
|
-
if (fixResult.timedOut) {
|
|
2039
|
-
// Fix agent hit wall-clock timeout — budget consumed deterministically
|
|
2040
|
-
logExecution(statusPath, "Quality gate", `Fix agent timed out (cycle ${fixCyclesUsed}, ${Math.round(fixResult.elapsed / 1000)}s) — budget consumed, proceeding to re-review`);
|
|
2041
|
-
} else if (fixResult.exitCode !== 0) {
|
|
2042
|
-
// Fix agent abnormal exit — consumes fix budget, log and continue to re-review
|
|
2043
|
-
logExecution(statusPath, "Quality gate", `Fix agent exited with code ${fixResult.exitCode} (cycle ${fixCyclesUsed}) — budget consumed, proceeding to re-review`);
|
|
2044
|
-
} else {
|
|
2045
|
-
logExecution(statusPath, "Quality gate", `Fix agent completed (cycle ${fixCyclesUsed}, ${Math.round(fixResult.elapsed / 1000)}s) — proceeding to re-review`);
|
|
2046
|
-
}
|
|
2047
|
-
|
|
2048
|
-
// Loop back to the top for re-review
|
|
2049
|
-
}
|
|
2050
|
-
|
|
2051
|
-
if (gatePassed) {
|
|
2052
|
-
// PASS → create .DONE
|
|
2053
|
-
const donePath = join(task.taskFolder, ".DONE");
|
|
2054
|
-
writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${task.taskId}\nQuality gate: PASS (cycle ${reviewCycle})\n`);
|
|
2055
|
-
updateStatusField(statusPath, "Status", "✅ Complete");
|
|
2056
|
-
logExecution(statusPath, "Task complete", `.DONE created (quality gate PASS, cycle ${reviewCycle})`);
|
|
2057
|
-
} else {
|
|
2058
|
-
// Gate failed — do NOT create .DONE
|
|
2059
|
-
// Persist blocking findings summary for operator visibility
|
|
2060
|
-
if (lastVerdict) {
|
|
2061
|
-
const criticals = lastVerdict.findings.filter(f => f.severity === "critical");
|
|
2062
|
-
const importants = lastVerdict.findings.filter(f => f.severity === "important");
|
|
2063
|
-
const suggestions = lastVerdict.findings.filter(f => f.severity === "suggestion");
|
|
2064
|
-
const summaryParts = [
|
|
2065
|
-
criticals.length > 0 ? `${criticals.length} critical` : "",
|
|
2066
|
-
importants.length > 0 ? `${importants.length} important` : "",
|
|
2067
|
-
// Include suggestion counts when they are blocking (all_clear threshold)
|
|
2068
|
-
(config.quality_gate.pass_threshold === "all_clear" && suggestions.length > 0)
|
|
2069
|
-
? `${suggestions.length} suggestion` : "",
|
|
2070
|
-
].filter(Boolean);
|
|
2071
|
-
const findingsSummary = summaryParts.join(", ");
|
|
2072
|
-
logExecution(statusPath, "Quality gate failed",
|
|
2073
|
-
`${reviewCycle} review cycle(s), ${fixCyclesUsed} fix cycle(s). ` +
|
|
2074
|
-
`Blocking findings: ${findingsSummary || "none extracted"}. ` +
|
|
2075
|
-
`Summary: ${lastVerdict.summary}`);
|
|
2076
|
-
} else {
|
|
2077
|
-
logExecution(statusPath, "Quality gate failed", `Task did not pass after ${reviewCycle} review cycle(s)`);
|
|
2078
|
-
}
|
|
2079
|
-
|
|
2080
|
-
state.phase = "error";
|
|
2081
|
-
updateStatusField(statusPath, "Status", "❌ Quality gate failed");
|
|
2082
|
-
ctx.ui.notify(`❌ Quality gate failed after ${reviewCycle} review cycle(s), ${fixCyclesUsed} fix cycle(s). .DONE not created.`, "error");
|
|
2083
|
-
updateWidgets();
|
|
2084
|
-
return;
|
|
2085
|
-
}
|
|
2086
|
-
} else {
|
|
2087
|
-
// ── Empty completion guard ────────────────────────────────
|
|
2088
|
-
// Detect tasks where the worker checked off STATUS.md without
|
|
2089
|
-
// modifying any source files. This catches "shortcut" completions
|
|
2090
|
-
// where the worker concludes work is "already done" without
|
|
2091
|
-
// implementing anything.
|
|
2092
|
-
if (isOrchestratedMode()) {
|
|
2093
|
-
try {
|
|
2094
|
-
const diffResult = spawnSync("git", ["diff", "--name-only", "HEAD"], {
|
|
2095
|
-
cwd: task.taskFolder, encoding: "utf-8", timeout: 10_000,
|
|
2096
|
-
});
|
|
2097
|
-
const changedFiles = (diffResult.stdout || "").split("\n").filter(Boolean);
|
|
2098
|
-
const sourceChanges = changedFiles.filter(f =>
|
|
2099
|
-
!f.endsWith("STATUS.md") && !f.endsWith(".DONE") &&
|
|
2100
|
-
!f.includes(".reviews/") && !f.endsWith("dependencies.json")
|
|
2101
|
-
);
|
|
2102
|
-
if (sourceChanges.length === 0) {
|
|
2103
|
-
logExecution(statusPath, "⚠️ Empty completion",
|
|
2104
|
-
"Worker marked all steps complete but no source files were modified. " +
|
|
2105
|
-
"Only STATUS.md changes detected. This may indicate the worker shortcut " +
|
|
2106
|
-
"the task without implementing. .DONE will still be created, but this " +
|
|
2107
|
-
"should be investigated.");
|
|
2108
|
-
console.error(`[task-runner] WARNING: Task ${task.taskId} completed with zero source file changes`);
|
|
2109
|
-
}
|
|
2110
|
-
} catch {
|
|
2111
|
-
// Best effort — don't block .DONE creation on git check failure
|
|
2112
|
-
}
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
|
-
// Create .DONE
|
|
2116
|
-
const donePath = join(task.taskFolder, ".DONE");
|
|
2117
|
-
writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${task.taskId}\n`);
|
|
2118
|
-
updateStatusField(statusPath, "Status", "✅ Complete");
|
|
2119
|
-
logExecution(statusPath, "Task complete", ".DONE created");
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
// Auto-archive: move task folder to tasks/archive/.
|
|
2123
|
-
// In orchestrated runs, do NOT archive here — the orchestrator polls
|
|
2124
|
-
// .DONE at the original path and handles post-merge archival itself.
|
|
2125
|
-
if (!isOrchestratedMode()) {
|
|
2126
|
-
const tasksDir = dirname(task.taskFolder);
|
|
2127
|
-
const archiveDir = join(tasksDir, "archive");
|
|
2128
|
-
const archiveDest = join(archiveDir, basename(task.taskFolder));
|
|
2129
|
-
try {
|
|
2130
|
-
if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
|
|
2131
|
-
const { renameSync } = require("fs");
|
|
2132
|
-
renameSync(task.taskFolder, archiveDest);
|
|
2133
|
-
logExecution(join(archiveDest, "STATUS.md"), "Archived", `Moved to ${archiveDest}`);
|
|
2134
|
-
ctx.ui.notify(`📦 Archived to ${archiveDest}`, "info");
|
|
2135
|
-
} catch (err: any) {
|
|
2136
|
-
ctx.ui.notify(`Archive failed (move manually): ${err?.message}`, "warning");
|
|
2137
|
-
}
|
|
2138
|
-
} else {
|
|
2139
|
-
ctx.ui.notify("ℹ️ Orchestrated run: skipping auto-archive (orchestrator handles archival)", "info");
|
|
2140
|
-
}
|
|
2141
|
-
|
|
2142
|
-
state.phase = "complete";
|
|
2143
|
-
updateWidgets();
|
|
2144
|
-
ctx.ui.notify(`✅ Task ${task.taskId} complete!`, "success");
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
// ── Worker ───────────────────────────────────────────────────────
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
async function runWorker(
|
|
2151
|
-
remainingSteps: StepInfo[],
|
|
2152
|
-
ctx: ExtensionContext,
|
|
2153
|
-
): Promise<void> {
|
|
2154
|
-
if (!state.task || !state.config) return;
|
|
2155
|
-
|
|
2156
|
-
const task = state.task;
|
|
2157
|
-
const config = state.config;
|
|
2158
|
-
const statusPath = join(task.taskFolder, "STATUS.md");
|
|
2159
|
-
const wrapUpFile = join(task.taskFolder, ".task-wrap-up");
|
|
2160
|
-
|
|
2161
|
-
const clearWrapUpSignals = () => {
|
|
2162
|
-
if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch {}
|
|
2163
|
-
};
|
|
2164
|
-
|
|
2165
|
-
const writeWrapUpSignal = (reason: string) => {
|
|
2166
|
-
const msg = `${reason} at ${new Date().toISOString()}`;
|
|
2167
|
-
if (!existsSync(wrapUpFile)) writeFileSync(wrapUpFile, msg);
|
|
2168
|
-
};
|
|
2169
|
-
|
|
2170
|
-
clearWrapUpSignals();
|
|
2171
|
-
|
|
2172
|
-
const workerDef = loadAgentDef(ctx.cwd, "task-worker");
|
|
2173
|
-
const basePrompt = workerDef?.systemPrompt || "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each.";
|
|
2174
|
-
const systemPrompt = basePrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
|
|
2175
|
-
|
|
2176
|
-
const modelFallbackActive = process.env.TASKPLANE_MODEL_FALLBACK === "1";
|
|
2177
|
-
const model = modelFallbackActive
|
|
2178
|
-
? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
|
|
2179
|
-
: (config.worker.model || workerDef?.model || "");
|
|
2180
|
-
|
|
2181
|
-
const promptLines = [
|
|
2182
|
-
`Read your task instructions at: ${task.promptPath}`,
|
|
2183
|
-
`Read your execution state at: ${statusPath}`,
|
|
2184
|
-
``,
|
|
2185
|
-
`Task: ${task.taskId}`,
|
|
2186
|
-
`Task folder: ${task.taskFolder}/`,
|
|
2187
|
-
`Iteration: ${state.totalIterations}`,
|
|
2188
|
-
`Wrap-up signal file: ${wrapUpFile}`,
|
|
2189
|
-
];
|
|
2190
|
-
|
|
2191
|
-
if (isOrchestratedMode()) {
|
|
2192
|
-
promptLines.push(``, `⚠️ ORCHESTRATED RUN: Do NOT archive or move the task folder. The orchestrator handles post-merge archival.`);
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
if (state.totalIterations > 1 && remainingSteps.length > 0) {
|
|
2196
|
-
const remainingSet = new Set(remainingSteps.map(s => s.number));
|
|
2197
|
-
const completedSteps = task.steps.filter(s => !remainingSet.has(s.number));
|
|
2198
|
-
const completedList = completedSteps.length > 0
|
|
2199
|
-
? completedSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ")
|
|
2200
|
-
: "(none)";
|
|
2201
|
-
const remainingList = remainingSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ");
|
|
2202
|
-
promptLines.push(
|
|
2203
|
-
``,
|
|
2204
|
-
`IMPORTANT: You exited previously without completing all steps.`,
|
|
2205
|
-
`Completed (do not redo): ${completedList}`,
|
|
2206
|
-
`Remaining (focus here): ${remainingList}`,
|
|
2207
|
-
);
|
|
2208
|
-
}
|
|
2209
|
-
|
|
2210
|
-
const prompt = promptLines.join("\n");
|
|
2211
|
-
|
|
2212
|
-
state.workerStatus = "running";
|
|
2213
|
-
state.workerElapsed = 0;
|
|
2214
|
-
state.workerContextPct = 0;
|
|
2215
|
-
state.workerLastTool = "";
|
|
2216
|
-
state.workerRetryActive = false;
|
|
2217
|
-
state.workerRetryCount = 0;
|
|
2218
|
-
state.workerLastRetryError = "";
|
|
2219
|
-
updateWidgets();
|
|
2220
|
-
|
|
2221
|
-
const startTime = Date.now();
|
|
2222
|
-
state.workerTimer = setInterval(() => {
|
|
2223
|
-
state.workerElapsed = Date.now() - startTime;
|
|
2224
|
-
updateWidgets();
|
|
2225
|
-
}, 1000);
|
|
2226
|
-
|
|
2227
|
-
const { contextWindow, source: contextWindowSource } = resolveContextWindow(config, ctx);
|
|
2228
|
-
const warnPct = config.context.warn_percent;
|
|
2229
|
-
const killPct = config.context.kill_percent;
|
|
2230
|
-
console.error(`[task-runner] worker context window: ${contextWindow} (${contextWindowSource})`);
|
|
2231
|
-
|
|
2232
|
-
const conversationPrefix = isOrchestratedMode() ? getLanePrefix() : null;
|
|
2233
|
-
if (conversationPrefix) clearConversationLog(conversationPrefix);
|
|
2234
|
-
|
|
2235
|
-
const spawned = spawnAgent({
|
|
2236
|
-
model,
|
|
2237
|
-
tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
|
|
2238
|
-
thinking: config.worker.thinking || undefined,
|
|
2239
|
-
systemPrompt,
|
|
2240
|
-
prompt,
|
|
2241
|
-
contextWindow,
|
|
2242
|
-
warnPct,
|
|
2243
|
-
killPct,
|
|
2244
|
-
wrapUpFile,
|
|
2245
|
-
onToolCall: (toolName, args) => {
|
|
2246
|
-
state.workerToolCount++;
|
|
2247
|
-
const path = args?.path || args?.command || "";
|
|
2248
|
-
const shortPath = typeof path === "string" && path.length > 80
|
|
2249
|
-
? "..." + path.slice(-77) : path;
|
|
2250
|
-
state.workerLastTool = `${toolName} ${shortPath}`.trim();
|
|
2251
|
-
if (conversationPrefix) {
|
|
2252
|
-
appendConversationEvent(conversationPrefix, {
|
|
2253
|
-
type: "tool_call", toolName, args, timestamp: Date.now(),
|
|
2254
|
-
});
|
|
2255
|
-
}
|
|
2256
|
-
updateWidgets();
|
|
2257
|
-
},
|
|
2258
|
-
onTokenUpdate: (tokens) => {
|
|
2259
|
-
state.workerInputTokens += tokens.input;
|
|
2260
|
-
state.workerOutputTokens += tokens.output;
|
|
2261
|
-
state.workerCacheReadTokens += tokens.cacheRead;
|
|
2262
|
-
state.workerCacheWriteTokens += tokens.cacheWrite;
|
|
2263
|
-
state.workerCostUsd += tokens.cost;
|
|
2264
|
-
updateWidgets();
|
|
2265
|
-
},
|
|
2266
|
-
onContextPct: (pct) => {
|
|
2267
|
-
state.workerContextPct = pct;
|
|
2268
|
-
if (pct >= warnPct) {
|
|
2269
|
-
writeWrapUpSignal(`Wrap up (context ${Math.round(pct)}%)`);
|
|
2270
|
-
}
|
|
2271
|
-
updateWidgets();
|
|
2272
|
-
},
|
|
2273
|
-
onJsonEvent: conversationPrefix
|
|
2274
|
-
? (event: Record<string, unknown>) => appendConversationEvent(conversationPrefix, event)
|
|
2275
|
-
: undefined,
|
|
2276
|
-
});
|
|
2277
|
-
|
|
2278
|
-
state.workerProc = { kill: spawned.kill };
|
|
2279
|
-
const result = await spawned.promise;
|
|
2280
|
-
|
|
2281
|
-
clearInterval(state.workerTimer);
|
|
2282
|
-
state.workerElapsed = Date.now() - startTime;
|
|
2283
|
-
state.workerStatus = result.killed ? "killed" : (result.exitCode === 0 ? "done" : "error");
|
|
2284
|
-
state.workerProc = null;
|
|
2285
|
-
clearWrapUpSignals();
|
|
2286
|
-
|
|
2287
|
-
const statusMsg = result.killed
|
|
2288
|
-
? "killed (context limit)"
|
|
2289
|
-
: (result.exitCode === 0 ? "done" : `error (code ${result.exitCode})`);
|
|
2290
|
-
logExecution(statusPath, `Worker iter ${state.totalIterations}`,
|
|
2291
|
-
`${statusMsg} in ${Math.round(state.workerElapsed / 1000)}s, ctx: ${Math.round(state.workerContextPct)}%, tools: ${state.workerToolCount}`);
|
|
2292
|
-
|
|
2293
|
-
updateWidgets();
|
|
2294
|
-
}
|
|
2295
|
-
|
|
2296
|
-
// ── Reviewer ─────────────────────────────────────────────────────
|
|
2297
|
-
|
|
2298
|
-
async function doReview(type: "plan" | "code", step: StepInfo, ctx: ExtensionContext, stepBaselineCommit?: string): Promise<string> {
|
|
2299
|
-
if (!state.task || !state.config) return "UNKNOWN";
|
|
2300
|
-
|
|
2301
|
-
const task = state.task;
|
|
2302
|
-
const config = state.config;
|
|
2303
|
-
const statusPath = join(task.taskFolder, "STATUS.md");
|
|
2304
|
-
const reviewsDir = join(task.taskFolder, ".reviews");
|
|
2305
|
-
if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
|
|
2306
|
-
|
|
2307
|
-
state.reviewCounter++;
|
|
2308
|
-
const num = String(state.reviewCounter).padStart(3, "0");
|
|
2309
|
-
const requestPath = join(reviewsDir, `request-R${num}.md`);
|
|
2310
|
-
const outputPath = join(reviewsDir, `R${num}-${type}-step${step.number}.md`);
|
|
2311
|
-
|
|
2312
|
-
const request = generateReviewRequest(type, step.number, step.name, task, config, outputPath, stepBaselineCommit);
|
|
2313
|
-
writeFileSync(requestPath, request);
|
|
2314
|
-
|
|
2315
|
-
const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
|
|
2316
|
-
const reviewerModelFallback2 = process.env.TASKPLANE_MODEL_FALLBACK === "1";
|
|
2317
|
-
const reviewerModel = reviewerModelFallback2
|
|
2318
|
-
? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
|
|
2319
|
-
: (config.reviewer.model || reviewerDef?.model || "");
|
|
2320
|
-
const reviewerPrompt = reviewerDef?.systemPrompt || "You are a code reviewer. Read the request and write your review to the specified output file.";
|
|
2321
|
-
const systemPrompt = reviewerPrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
|
|
2322
|
-
|
|
2323
|
-
state.reviewerStatus = "running";
|
|
2324
|
-
state.reviewerType = `${type} review`;
|
|
2325
|
-
state.reviewerElapsed = 0;
|
|
2326
|
-
state.reviewerLastTool = "";
|
|
2327
|
-
updateWidgets();
|
|
2328
|
-
|
|
2329
|
-
const startTime = Date.now();
|
|
2330
|
-
state.reviewerTimer = setInterval(() => {
|
|
2331
|
-
state.reviewerElapsed = Date.now() - startTime;
|
|
2332
|
-
updateWidgets();
|
|
2333
|
-
}, 1000);
|
|
2334
|
-
|
|
2335
|
-
const promptContent = readFileSync(requestPath, "utf-8");
|
|
2336
|
-
const spawned = spawnAgent({
|
|
2337
|
-
model: reviewerModel,
|
|
2338
|
-
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
2339
|
-
thinking: config.reviewer.thinking || undefined,
|
|
2340
|
-
systemPrompt,
|
|
2341
|
-
prompt: promptContent,
|
|
2342
|
-
onToolCall: (toolName, args) => {
|
|
2343
|
-
const path = args?.path || args?.command || "";
|
|
2344
|
-
const shortPath = typeof path === "string" && path.length > 40
|
|
2345
|
-
? "..." + path.slice(-37) : path;
|
|
2346
|
-
state.reviewerLastTool = `${toolName} ${shortPath}`.trim();
|
|
2347
|
-
updateWidgets();
|
|
2348
|
-
},
|
|
2349
|
-
});
|
|
2350
|
-
state.reviewerProc = { kill: spawned.kill };
|
|
2351
|
-
|
|
2352
|
-
const result = await spawned.promise;
|
|
2353
|
-
|
|
2354
|
-
clearInterval(state.reviewerTimer);
|
|
2355
|
-
state.reviewerElapsed = Date.now() - startTime;
|
|
2356
|
-
state.reviewerStatus = result.exitCode === 0 ? "done" : "error";
|
|
2357
|
-
state.reviewerProc = null;
|
|
2358
|
-
updateWidgets();
|
|
2359
|
-
|
|
2360
|
-
let verdict = "UNKNOWN";
|
|
2361
|
-
if (existsSync(outputPath)) {
|
|
2362
|
-
const review = readFileSync(outputPath, "utf-8");
|
|
2363
|
-
verdict = extractVerdict(review);
|
|
2364
|
-
} else {
|
|
2365
|
-
verdict = "UNAVAILABLE";
|
|
2366
|
-
logExecution(statusPath, `Reviewer R${num}`, `${type} review — reviewer did not produce output`);
|
|
2367
|
-
}
|
|
2368
|
-
|
|
2369
|
-
logReview(statusPath, `R${num}`, type, step.number, verdict, `.reviews/R${num}-${type}-step${step.number}.md`);
|
|
2370
|
-
logExecution(statusPath, `Review R${num}`, `${type} Step ${step.number}: ${verdict}`);
|
|
2371
|
-
updateStatusField(statusPath, "Review Counter", `${state.reviewCounter}`);
|
|
2372
|
-
|
|
2373
|
-
ctx.ui.notify(`Review R${num} (${type} Step ${step.number}): ${verdict}`, verdict === "APPROVE" ? "success" : "warning");
|
|
2374
|
-
return verdict;
|
|
2375
|
-
}
|
|
2376
|
-
|
|
2377
|
-
// ── Quality Gate ─────────────────────────────────────────────────
|
|
2378
|
-
|
|
2379
|
-
/**
|
|
2380
|
-
* Run a single quality gate review cycle.
|
|
2381
|
-
*
|
|
2382
|
-
* Spawns a review agent with a structured prompt that includes task evidence
|
|
2383
|
-
* (PROMPT.md, STATUS.md, git diff, file list). The agent writes a JSON verdict
|
|
2384
|
-
* to REVIEW_VERDICT.json in the task folder. This function reads/parses that
|
|
2385
|
-
* file and applies verdict rules.
|
|
2386
|
-
*
|
|
2387
|
-
* Fail-open on all error paths:
|
|
2388
|
-
* - Agent crash / non-zero exit → synthetic PASS
|
|
2389
|
-
* - Missing verdict file → synthetic PASS
|
|
2390
|
-
* - Malformed JSON → synthetic PASS
|
|
2391
|
-
*
|
|
2392
|
-
* @param ctx - Extension context
|
|
2393
|
-
* @param cycleNum - Current review cycle number (1-based)
|
|
2394
|
-
* @returns Quality gate result with pass/fail, verdict, and evaluation
|
|
2395
|
-
*/
|
|
2396
|
-
async function doQualityGateReview(ctx: ExtensionContext, cycleNum: number): Promise<QualityGateResult> {
|
|
2397
|
-
if (!state.task || !state.config) {
|
|
2398
|
-
return {
|
|
2399
|
-
passed: true, skipped: true, cyclesUsed: cycleNum,
|
|
2400
|
-
verdict: { verdict: "PASS", confidence: "low", summary: "No task/config — skipped", findings: [], statusReconciliation: [] },
|
|
2401
|
-
evaluation: { pass: true, failReasons: [] },
|
|
2402
|
-
};
|
|
2403
|
-
}
|
|
2404
|
-
|
|
2405
|
-
const task = state.task;
|
|
2406
|
-
const config = state.config;
|
|
2407
|
-
const statusPath = join(task.taskFolder, "STATUS.md");
|
|
2408
|
-
|
|
2409
|
-
const verdictPath = join(task.taskFolder, VERDICT_FILENAME);
|
|
2410
|
-
try { if (existsSync(verdictPath)) unlinkSync(verdictPath); } catch {}
|
|
2411
|
-
|
|
2412
|
-
const gateContext: QualityGateContext = {
|
|
2413
|
-
taskFolder: task.taskFolder,
|
|
2414
|
-
promptPath: task.promptPath,
|
|
2415
|
-
taskId: task.taskId,
|
|
2416
|
-
projectName: config.project.name,
|
|
2417
|
-
passThreshold: config.quality_gate.pass_threshold,
|
|
2418
|
-
};
|
|
2419
|
-
|
|
2420
|
-
const prompt = generateQualityGatePrompt(gateContext, ctx.cwd);
|
|
2421
|
-
const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
|
|
2422
|
-
const qgModelFallback = process.env.TASKPLANE_MODEL_FALLBACK === "1";
|
|
2423
|
-
const reviewModel = qgModelFallback
|
|
2424
|
-
? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
|
|
2425
|
-
: (config.quality_gate.review_model
|
|
2426
|
-
|| config.reviewer.model
|
|
2427
|
-
|| reviewerDef?.model
|
|
2428
|
-
|| "");
|
|
2429
|
-
|
|
2430
|
-
const reviewerPrompt = reviewerDef?.systemPrompt
|
|
2431
|
-
|| "You are a quality gate reviewer. Read the review request and write your JSON verdict to the specified file.";
|
|
2432
|
-
const systemPrompt = reviewerPrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
|
|
2433
|
-
|
|
2434
|
-
state.reviewerStatus = "running";
|
|
2435
|
-
state.reviewerType = `quality-gate cycle ${cycleNum}`;
|
|
2436
|
-
state.reviewerElapsed = 0;
|
|
2437
|
-
state.reviewerLastTool = "";
|
|
2438
|
-
updateWidgets();
|
|
2439
|
-
|
|
2440
|
-
const startTime = Date.now();
|
|
2441
|
-
state.reviewerTimer = setInterval(() => {
|
|
2442
|
-
state.reviewerElapsed = Date.now() - startTime;
|
|
2443
|
-
updateWidgets();
|
|
2444
|
-
}, 1000);
|
|
2445
|
-
|
|
2446
|
-
logExecution(statusPath, `Quality gate`, `Starting review cycle ${cycleNum}`);
|
|
2447
|
-
|
|
2448
|
-
try {
|
|
2449
|
-
const spawned = spawnAgent({
|
|
2450
|
-
model: reviewModel,
|
|
2451
|
-
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
2452
|
-
thinking: config.reviewer.thinking || undefined,
|
|
2453
|
-
systemPrompt,
|
|
2454
|
-
prompt,
|
|
2455
|
-
onToolCall: (toolName, args) => {
|
|
2456
|
-
const path = args?.path || args?.command || "";
|
|
2457
|
-
const shortPath = typeof path === "string" && path.length > 40
|
|
2458
|
-
? "..." + path.slice(-37) : path;
|
|
2459
|
-
state.reviewerLastTool = `${toolName} ${shortPath}`.trim();
|
|
2460
|
-
updateWidgets();
|
|
2461
|
-
},
|
|
2462
|
-
});
|
|
2463
|
-
state.reviewerProc = { kill: spawned.kill };
|
|
2464
|
-
|
|
2465
|
-
const result = await spawned.promise;
|
|
2466
|
-
|
|
2467
|
-
clearInterval(state.reviewerTimer);
|
|
2468
|
-
state.reviewerElapsed = Date.now() - startTime;
|
|
2469
|
-
state.reviewerStatus = result.exitCode === 0 ? "done" : "error";
|
|
2470
|
-
state.reviewerProc = null;
|
|
2471
|
-
updateWidgets();
|
|
2472
|
-
|
|
2473
|
-
if (result.exitCode !== 0) {
|
|
2474
|
-
logExecution(statusPath, `Quality gate`, `Review agent exited with code ${result.exitCode} — fail-open → PASS`);
|
|
2475
|
-
ctx.ui.notify(`Quality gate: review agent error (exit ${result.exitCode}) — fail-open PASS`, "warning");
|
|
2476
|
-
return {
|
|
2477
|
-
passed: true, skipped: false, cyclesUsed: cycleNum,
|
|
2478
|
-
verdict: { verdict: "PASS", confidence: "low", summary: `Review agent exited with code ${result.exitCode} — fail-open`, findings: [], statusReconciliation: [] },
|
|
2479
|
-
evaluation: { pass: true, failReasons: [] },
|
|
2480
|
-
};
|
|
2481
|
-
}
|
|
2482
|
-
} catch (err: any) {
|
|
2483
|
-
clearInterval(state.reviewerTimer);
|
|
2484
|
-
state.reviewerStatus = "error";
|
|
2485
|
-
state.reviewerProc = null;
|
|
2486
|
-
updateWidgets();
|
|
2487
|
-
logExecution(statusPath, `Quality gate`, `Review agent crashed: ${err?.message || err} — fail-open → PASS`);
|
|
2488
|
-
ctx.ui.notify(`Quality gate: review agent crashed — fail-open PASS`, "warning");
|
|
2489
|
-
return {
|
|
2490
|
-
passed: true, skipped: false, cyclesUsed: cycleNum,
|
|
2491
|
-
verdict: { verdict: "PASS", confidence: "low", summary: `Review agent crashed — fail-open`, findings: [], statusReconciliation: [] },
|
|
2492
|
-
evaluation: { pass: true, failReasons: [] },
|
|
2493
|
-
};
|
|
2494
|
-
}
|
|
2495
|
-
|
|
2496
|
-
const { verdict, evaluation } = readAndEvaluateVerdict(
|
|
2497
|
-
task.taskFolder,
|
|
2498
|
-
config.quality_gate.pass_threshold,
|
|
2499
|
-
);
|
|
2500
|
-
|
|
2501
|
-
if (verdict.statusReconciliation.length > 0) {
|
|
2502
|
-
const reconResult = applyStatusReconciliation(statusPath, verdict.statusReconciliation);
|
|
2503
|
-
if (reconResult.changed > 0 || reconResult.unmatched > 0) {
|
|
2504
|
-
logExecution(statusPath, `Reconciliation`,
|
|
2505
|
-
`${reconResult.changed} changed, ${reconResult.alreadyCorrect} already correct, ${reconResult.unmatched} unmatched`);
|
|
2506
|
-
}
|
|
2507
|
-
}
|
|
2508
|
-
|
|
2509
|
-
const passed = evaluation.pass;
|
|
2510
|
-
const verdictLabel = passed ? "PASS" : "NEEDS_FIXES";
|
|
2511
|
-
const findingsSummary = verdict.findings.length > 0
|
|
2512
|
-
? ` (${verdict.findings.length} findings: ${verdict.findings.filter(f => f.severity === "critical").length}C/${verdict.findings.filter(f => f.severity === "important").length}I/${verdict.findings.filter(f => f.severity === "suggestion").length}S)`
|
|
2513
|
-
: "";
|
|
2514
|
-
|
|
2515
|
-
logExecution(statusPath, `Quality gate`, `Cycle ${cycleNum}: ${verdictLabel}${findingsSummary}`);
|
|
2516
|
-
ctx.ui.notify(
|
|
2517
|
-
`Quality gate cycle ${cycleNum}: ${verdictLabel}${findingsSummary}`,
|
|
2518
|
-
passed ? "success" : "warning",
|
|
2519
|
-
);
|
|
2520
|
-
|
|
2521
|
-
return {
|
|
2522
|
-
passed,
|
|
2523
|
-
skipped: false,
|
|
2524
|
-
cyclesUsed: cycleNum,
|
|
2525
|
-
verdict,
|
|
2526
|
-
evaluation,
|
|
2527
|
-
};
|
|
2528
|
-
}
|
|
2529
|
-
|
|
2530
|
-
// ── Quality Gate Fix Agent ───────────────────────────────────────
|
|
2531
|
-
|
|
2532
|
-
/** Default wall-clock timeout for fix agents (15 minutes). */
|
|
2533
|
-
const FIX_AGENT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
2534
|
-
|
|
2535
|
-
/**
|
|
2536
|
-
* Spawn a fix agent to address quality gate findings.
|
|
2537
|
-
*
|
|
2538
|
-
* Reuses the standard worker subprocess spawn pattern. The fix agent
|
|
2539
|
-
* receives REVIEW_FEEDBACK.md content and makes targeted code fixes.
|
|
2540
|
-
*
|
|
2541
|
-
* Handles abnormal exits deterministically:
|
|
2542
|
-
* - Agent crash → returns non-zero exit code (caller consumes fix budget)
|
|
2543
|
-
* - Agent timeout → kills agent, returns non-zero (caller consumes fix budget)
|
|
2544
|
-
* - Agent exits normally but makes no changes → still returns 0 (re-review will catch)
|
|
2545
|
-
*
|
|
2546
|
-
* Wall-clock timeout: 15 minutes (or getMaxWorkerMinutes if configured).
|
|
2547
|
-
* This prevents a hung fix agent from stalling the task permanently.
|
|
2548
|
-
*
|
|
2549
|
-
* @param ctx - Extension context
|
|
2550
|
-
* @param fixPrompt - Prompt for the fix agent (includes REVIEW_FEEDBACK.md)
|
|
2551
|
-
* @param fixCycleNum - Current fix cycle number (1-based)
|
|
2552
|
-
* @returns Exit code, elapsed time, and whether timeout was hit
|
|
2553
|
-
*/
|
|
2554
|
-
async function doQualityGateFixAgent(
|
|
2555
|
-
ctx: ExtensionContext,
|
|
2556
|
-
fixPrompt: string,
|
|
2557
|
-
fixCycleNum: number,
|
|
2558
|
-
): Promise<{ exitCode: number; elapsed: number; timedOut: boolean }> {
|
|
2559
|
-
if (!state.task || !state.config) {
|
|
2560
|
-
return { exitCode: 1, elapsed: 0, timedOut: false };
|
|
2561
|
-
}
|
|
2562
|
-
|
|
2563
|
-
const task = state.task;
|
|
2564
|
-
const config = state.config;
|
|
2565
|
-
const statusPath = join(task.taskFolder, "STATUS.md");
|
|
2566
|
-
|
|
2567
|
-
const workerDef = loadAgentDef(ctx.cwd, "task-worker");
|
|
2568
|
-
const fixModelFallback = process.env.TASKPLANE_MODEL_FALLBACK === "1";
|
|
2569
|
-
const fixModel = fixModelFallback
|
|
2570
|
-
? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
|
|
2571
|
-
: (config.worker.model || workerDef?.model || "");
|
|
2572
|
-
|
|
2573
|
-
const basePrompt = workerDef?.systemPrompt
|
|
2574
|
-
|| "You are a fix agent addressing quality gate findings. Read the feedback and make targeted code fixes.";
|
|
2575
|
-
const systemPrompt = basePrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
|
|
2576
|
-
|
|
2577
|
-
const workerMinutes = getMaxWorkerMinutes(config);
|
|
2578
|
-
const timeoutMs = Math.max(FIX_AGENT_TIMEOUT_MS, Math.floor(workerMinutes / 2) * 60 * 1000);
|
|
2579
|
-
|
|
2580
|
-
state.workerStatus = "running";
|
|
2581
|
-
state.workerElapsed = 0;
|
|
2582
|
-
state.workerContextPct = 0;
|
|
2583
|
-
state.workerLastTool = "";
|
|
2584
|
-
state.workerToolCount = 0;
|
|
2585
|
-
state.workerRetryActive = false;
|
|
2586
|
-
state.workerRetryCount = 0;
|
|
2587
|
-
state.workerLastRetryError = "";
|
|
2588
|
-
updateWidgets();
|
|
2589
|
-
|
|
2590
|
-
const startTime = Date.now();
|
|
2591
|
-
state.workerTimer = setInterval(() => {
|
|
2592
|
-
state.workerElapsed = Date.now() - startTime;
|
|
2593
|
-
updateWidgets();
|
|
2594
|
-
}, 1000);
|
|
2595
|
-
|
|
2596
|
-
logExecution(statusPath, "Quality gate", `Starting fix agent (cycle ${fixCycleNum}, timeout: ${Math.round(timeoutMs / 60000)}min)`);
|
|
2597
|
-
|
|
2598
|
-
let killFn: (() => void) | null = null;
|
|
2599
|
-
|
|
2600
|
-
try {
|
|
2601
|
-
const spawned = spawnAgent({
|
|
2602
|
-
model: fixModel,
|
|
2603
|
-
tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
|
|
2604
|
-
thinking: config.worker.thinking || undefined,
|
|
2605
|
-
systemPrompt,
|
|
2606
|
-
prompt: fixPrompt,
|
|
2607
|
-
onToolCall: (toolName, args) => {
|
|
2608
|
-
state.workerToolCount++;
|
|
2609
|
-
const path = args?.path || args?.command || "";
|
|
2610
|
-
const shortPath = typeof path === "string" && path.length > 80
|
|
2611
|
-
? "..." + path.slice(-77) : path;
|
|
2612
|
-
state.workerLastTool = `${toolName} ${shortPath}`.trim();
|
|
2613
|
-
updateWidgets();
|
|
2614
|
-
},
|
|
2615
|
-
});
|
|
2616
|
-
killFn = spawned.kill;
|
|
2617
|
-
state.workerProc = { kill: spawned.kill };
|
|
2618
|
-
|
|
2619
|
-
let timedOut = false;
|
|
2620
|
-
const timeoutPromise = new Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>((resolve) => {
|
|
2621
|
-
const timer = setTimeout(() => {
|
|
2622
|
-
timedOut = true;
|
|
2623
|
-
logExecution(statusPath, "Quality gate", `Fix agent wall-clock timeout (${Math.round(timeoutMs / 60000)}min) — killing agent`);
|
|
2624
|
-
if (killFn) killFn();
|
|
2625
|
-
setTimeout(() => {
|
|
2626
|
-
resolve({ output: "timeout", exitCode: 1, elapsed: Date.now() - startTime, killed: true });
|
|
2627
|
-
}, 5000);
|
|
2628
|
-
}, timeoutMs);
|
|
2629
|
-
spawned.promise.then(() => clearTimeout(timer)).catch(() => clearTimeout(timer));
|
|
2630
|
-
});
|
|
2631
|
-
|
|
2632
|
-
const result = await Promise.race([spawned.promise, timeoutPromise]);
|
|
2633
|
-
|
|
2634
|
-
clearInterval(state.workerTimer);
|
|
2635
|
-
state.workerElapsed = Date.now() - startTime;
|
|
2636
|
-
state.workerStatus = (result.exitCode === 0 && !timedOut) ? "done" : "error";
|
|
2637
|
-
state.workerProc = null;
|
|
2638
|
-
updateWidgets();
|
|
2639
|
-
|
|
2640
|
-
return { exitCode: timedOut ? 1 : result.exitCode, elapsed: Date.now() - startTime, timedOut };
|
|
2641
|
-
} catch (err: any) {
|
|
2642
|
-
clearInterval(state.workerTimer);
|
|
2643
|
-
state.workerStatus = "error";
|
|
2644
|
-
state.workerProc = null;
|
|
2645
|
-
updateWidgets();
|
|
2646
|
-
logExecution(statusPath, "Quality gate", `Fix agent crashed: ${err?.message || err} — fix cycle ${fixCycleNum} consumed`);
|
|
2647
|
-
return { exitCode: 1, elapsed: Date.now() - startTime, timedOut: false };
|
|
2648
|
-
}
|
|
2649
|
-
}
|
|
2650
|
-
|
|
2651
|
-
// ── Commands ─────────────────────────────────────────────────────
|
|
2652
|
-
|
|
2653
|
-
// ── Shared Task Initialization ───────────────────────────────────
|
|
2654
|
-
//
|
|
2655
|
-
// Extracts the core init logic used by both the `/task` command and
|
|
2656
|
-
// TASK_AUTOSTART so that they share a single code path. Returns true
|
|
2657
|
-
// if the task was started successfully.
|
|
2658
|
-
|
|
2659
|
-
function startTaskFromPath(ctx: ExtensionContext, fullPath: string): boolean {
|
|
2660
|
-
if (state.phase === "running") {
|
|
2661
|
-
ctx.ui.notify("A task is already running. Use /task-pause first.", "warning");
|
|
2662
|
-
return false;
|
|
2663
|
-
}
|
|
2664
|
-
|
|
2665
|
-
// Parse PROMPT.md
|
|
2666
|
-
let parsed: ParsedTask;
|
|
2667
|
-
try {
|
|
2668
|
-
const content = readFileSync(fullPath, "utf-8");
|
|
2669
|
-
parsed = parsePromptMd(content, fullPath);
|
|
2670
|
-
} catch (err: any) {
|
|
2671
|
-
ctx.ui.notify(`Failed to parse PROMPT.md: ${err?.message || err}`, "error");
|
|
2672
|
-
return false;
|
|
2673
|
-
}
|
|
2674
|
-
|
|
2675
|
-
state = freshState();
|
|
2676
|
-
state.task = parsed;
|
|
2677
|
-
state.config = loadConfig(ctx.cwd);
|
|
2678
|
-
state.phase = "running";
|
|
2679
|
-
widgetCtx = ctx;
|
|
2680
|
-
|
|
2681
|
-
// Generate STATUS.md if missing
|
|
2682
|
-
const statusPath = join(state.task.taskFolder, "STATUS.md");
|
|
2683
|
-
if (!existsSync(statusPath)) {
|
|
2684
|
-
writeFileSync(statusPath, generateStatusMd(state.task));
|
|
2685
|
-
ctx.ui.notify("Generated STATUS.md from PROMPT.md", "info");
|
|
2686
|
-
} else {
|
|
2687
|
-
// Sync review counter and iteration from existing STATUS
|
|
2688
|
-
const existing = parseStatusMd(readFileSync(statusPath, "utf-8"));
|
|
2689
|
-
state.reviewCounter = existing.reviewCounter;
|
|
2690
|
-
state.totalIterations = existing.iteration;
|
|
2691
|
-
for (const s of existing.steps) state.stepStatuses.set(s.number, s);
|
|
2692
|
-
}
|
|
2693
|
-
|
|
2694
|
-
// Create .reviews/ if missing
|
|
2695
|
-
const reviewsDir = join(state.task.taskFolder, ".reviews");
|
|
2696
|
-
if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
|
|
2697
|
-
|
|
2698
|
-
updateWidgets();
|
|
2699
|
-
ctx.ui.notify(
|
|
2700
|
-
`Starting: ${state.task.taskId} — ${state.task.taskName}\n` +
|
|
2701
|
-
`Review Level: ${state.task.reviewLevel} · Size: ${state.task.size} · Steps: ${state.task.steps.length}\n` +
|
|
2702
|
-
`Worker model: ${state.config.worker.model || "inherit"} · Reviewer: ${state.config.reviewer.model || "inherit"}`,
|
|
2703
|
-
"info",
|
|
2704
|
-
);
|
|
2705
|
-
|
|
2706
|
-
// Fire-and-forget
|
|
2707
|
-
executeTask(ctx).catch(err => {
|
|
2708
|
-
state.phase = "error";
|
|
2709
|
-
ctx.ui.notify(`Task error: ${err?.message || err}`, "error");
|
|
2710
|
-
updateWidgets();
|
|
2711
|
-
});
|
|
2712
|
-
|
|
2713
|
-
return true;
|
|
2714
|
-
}
|
|
2715
|
-
|
|
2716
|
-
// /task, /task-status, /task-pause, /task-resume removed.
|
|
2717
|
-
// These were deprecated in favor of /orch. Runtime V2 is the only execution path.
|
|
2718
|
-
|
|
2719
|
-
// ── Session Lifecycle ────────────────────────────────────────────
|
|
2720
|
-
|
|
2721
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
2722
|
-
widgetCtx = ctx;
|
|
2723
|
-
|
|
2724
|
-
// Kill any running subprocesses
|
|
2725
|
-
if (state.workerProc) try { state.workerProc.kill(); } catch {}
|
|
2726
|
-
if (state.reviewerProc) try { state.reviewerProc.kill(); } catch {}
|
|
2727
|
-
// TP-057: Kill persistent reviewer session if alive
|
|
2728
|
-
if (state.persistentReviewerKill) try { state.persistentReviewerKill(); } catch {}
|
|
2729
|
-
state.persistentReviewerSession = null;
|
|
2730
|
-
state.persistentReviewerKill = null;
|
|
2731
|
-
state.persistentReviewerSignalNum = 0;
|
|
2732
|
-
if (state.workerTimer) clearInterval(state.workerTimer);
|
|
2733
|
-
if (state.reviewerTimer) clearInterval(state.reviewerTimer);
|
|
2734
|
-
|
|
2735
|
-
// Keep task state if resuming, but reset runtime state
|
|
2736
|
-
const hadTask = state.task;
|
|
2737
|
-
if (hadTask) {
|
|
2738
|
-
state.phase = "paused";
|
|
2739
|
-
state.workerStatus = "idle";
|
|
2740
|
-
state.reviewerStatus = "idle";
|
|
2741
|
-
state.workerProc = null;
|
|
2742
|
-
state.reviewerProc = null;
|
|
2743
|
-
// Refresh from STATUS.md
|
|
2744
|
-
const statusPath = join(hadTask.taskFolder, "STATUS.md");
|
|
2745
|
-
if (existsSync(statusPath)) {
|
|
2746
|
-
const parsed = parseStatusMd(readFileSync(statusPath, "utf-8"));
|
|
2747
|
-
state.reviewCounter = parsed.reviewCounter;
|
|
2748
|
-
state.totalIterations = parsed.iteration;
|
|
2749
|
-
for (const s of parsed.steps) state.stepStatuses.set(s.number, s);
|
|
2750
|
-
}
|
|
2751
|
-
}
|
|
2752
|
-
|
|
2753
|
-
updateWidgets();
|
|
2754
|
-
|
|
2755
|
-
const config = loadConfig(ctx.cwd);
|
|
2756
|
-
ctx.ui.setStatus("task-runner", `📋 ${config.project.name}`);
|
|
2757
|
-
|
|
2758
|
-
if (hadTask) {
|
|
2759
|
-
ctx.ui.notify(`Task ${hadTask.taskId} loaded (paused). Use /task-resume to continue.`, "info");
|
|
2760
|
-
} else if (process.env.TASK_AUTOSTART) {
|
|
2761
|
-
// ── TASK_AUTOSTART ────────────────────────────────────────
|
|
2762
|
-
// When set, automatically start a task as if the user typed
|
|
2763
|
-
// `/task <path>`. Used by the orchestrator to launch
|
|
2764
|
-
// workers automatically without manual command entry timing issues.
|
|
2765
|
-
const autoPath = process.env.TASK_AUTOSTART;
|
|
2766
|
-
const fullPath = resolve(ctx.cwd, autoPath);
|
|
2767
|
-
if (!existsSync(fullPath)) {
|
|
2768
|
-
ctx.ui.notify(`TASK_AUTOSTART: file not found — ${fullPath}`, "error");
|
|
2769
|
-
} else {
|
|
2770
|
-
ctx.ui.notify(`TASK_AUTOSTART: ${fullPath}`, "info");
|
|
2771
|
-
startTaskFromPath(ctx, fullPath);
|
|
2772
|
-
}
|
|
2773
|
-
} else {
|
|
2774
|
-
ctx.ui.notify(
|
|
2775
|
-
`Task Runner ready — ${config.project.name}\n\n` +
|
|
2776
|
-
`/task <path/to/PROMPT.md> Start a task\n` +
|
|
2777
|
-
`/task-status Show progress\n` +
|
|
2778
|
-
`/task-pause Pause execution\n` +
|
|
2779
|
-
`/task-resume Resume execution`,
|
|
2780
|
-
"info",
|
|
2781
|
-
);
|
|
2782
|
-
}
|
|
2783
|
-
});
|
|
2784
|
-
}
|