taskplane 0.8.1 β 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/public/app.js +33 -2
- package/dashboard/public/style.css +26 -0
- package/dashboard/server.cjs +4 -1
- package/extensions/task-runner.ts +347 -127
- package/extensions/taskplane/execution.ts +45 -25
- package/extensions/taskplane/merge.ts +32 -26
- package/package.json +1 -1
- package/templates/agents/local/task-worker.md +1 -0
- package/templates/agents/task-worker.md +38 -0
package/dashboard/public/app.js
CHANGED
|
@@ -530,18 +530,19 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
530
530
|
// Worker stats from lane state sidecar + telemetry badges
|
|
531
531
|
let workerHtml = "";
|
|
532
532
|
const telemBadges = task.status !== "pending" ? telemetryBadgesHtml(tel) : "";
|
|
533
|
+
const reviewerActive = ls && ls.reviewerStatus === "running";
|
|
533
534
|
if (ls && ls.workerStatus === "running" && task.status === "running") {
|
|
534
535
|
const elapsed = ls.workerElapsed ? `${Math.round(ls.workerElapsed / 1000)}s` : "";
|
|
535
536
|
const tools = ls.workerToolCount || 0;
|
|
536
537
|
const ctx = ls.workerContextPct ? `${Math.round(ls.workerContextPct)}%` : "";
|
|
537
|
-
const lastTool = ls.workerLastTool || "";
|
|
538
|
+
const lastTool = reviewerActive ? "[awaiting review]" : (ls.workerLastTool || "");
|
|
538
539
|
const tokenStr = tokenSummaryFromLaneState(ls);
|
|
539
540
|
workerHtml = `<div class="worker-stats">`;
|
|
540
541
|
workerHtml += `<span class="worker-stat" title="Worker elapsed">β± ${elapsed}</span>`;
|
|
541
542
|
workerHtml += `<span class="worker-stat" title="Tool calls">π§ ${tools}</span>`;
|
|
542
543
|
if (ctx) workerHtml += `<span class="worker-stat" title="Context window used">π ${ctx}</span>`;
|
|
543
544
|
if (tokenStr) workerHtml += `<span class="worker-stat" title="Tokens: inputβ outputβ cacheRead(R) cacheWrite(W)">πͺ ${tokenStr}</span>`;
|
|
544
|
-
if (lastTool) workerHtml += `<span class="worker-stat worker-last-tool" title="Last tool call">${escapeHtml(lastTool)}</span>`;
|
|
545
|
+
if (lastTool) workerHtml += `<span class="worker-stat worker-last-tool" title="${reviewerActive ? 'Waiting for reviewer' : 'Last tool call'}">${reviewerActive ? '<span style="color:var(--yellow)">' + escapeHtml(lastTool) + '</span>' : escapeHtml(lastTool)}</span>`;
|
|
545
546
|
workerHtml += telemBadges;
|
|
546
547
|
workerHtml += `</div>`;
|
|
547
548
|
} else if (!ls && tel && task.status === "running") {
|
|
@@ -560,6 +561,35 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
560
561
|
workerHtml = `<div class="worker-stats">${telemBadges}</div>`;
|
|
561
562
|
}
|
|
562
563
|
|
|
564
|
+
// Reviewer sub-row: shown when reviewer is actively running
|
|
565
|
+
let reviewerRowHtml = "";
|
|
566
|
+
if (reviewerActive) {
|
|
567
|
+
const rElapsed = ls.reviewerElapsed ? `${Math.round(ls.reviewerElapsed / 1000)}s` : "";
|
|
568
|
+
const rTools = ls.reviewerToolCount || 0;
|
|
569
|
+
const rCtx = ls.reviewerContextPct ? `${Math.round(ls.reviewerContextPct)}%` : "";
|
|
570
|
+
const rLastTool = ls.reviewerLastTool || "";
|
|
571
|
+
const rCost = ls.reviewerCostUsd ? `$${ls.reviewerCostUsd.toFixed(2)}` : "";
|
|
572
|
+
const rType = ls.reviewerType || "review";
|
|
573
|
+
const rStep = ls.reviewerStep || "?";
|
|
574
|
+
reviewerRowHtml = `
|
|
575
|
+
<div class="task-row reviewer-sub-row">
|
|
576
|
+
<span class="task-icon"></span>
|
|
577
|
+
<span class="task-actions"></span>
|
|
578
|
+
<span class="reviewer-label">π Reviewer</span>
|
|
579
|
+
<span class="reviewer-type">${escapeHtml(rType)} Β· Step ${rStep}</span>
|
|
580
|
+
<span class="task-duration">${rElapsed}</span>
|
|
581
|
+
<span></span>
|
|
582
|
+
<span class="task-step">
|
|
583
|
+
<div class="worker-stats reviewer-stats">
|
|
584
|
+
<span class="worker-stat" title="Reviewer tool calls">π§ ${rTools}</span>
|
|
585
|
+
${rCtx ? `<span class="worker-stat" title="Reviewer context used">π ${rCtx}</span>` : ""}
|
|
586
|
+
${rCost ? `<span class="worker-stat" title="Reviewer cost">${rCost}</span>` : ""}
|
|
587
|
+
${rLastTool ? `<span class="worker-stat worker-last-tool" title="Reviewer last tool">${escapeHtml(rLastTool)}</span>` : ""}
|
|
588
|
+
</div>
|
|
589
|
+
</span>
|
|
590
|
+
</div>`;
|
|
591
|
+
}
|
|
592
|
+
|
|
563
593
|
const isViewingStatus = viewerMode === 'status-md' && viewerTarget === task.taskId;
|
|
564
594
|
const eyeHtml = task.status !== 'pending'
|
|
565
595
|
? `<button class="viewer-eye-btn${isViewingStatus ? ' active' : ''}" onclick="viewStatusMd('${escapeHtml(task.taskId)}')" title="View STATUS.md">π</button>`
|
|
@@ -575,6 +605,7 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
575
605
|
<span>${progressHtml}</span>
|
|
576
606
|
<span class="task-step">${stepHtml}${workerHtml}</span>
|
|
577
607
|
</div>`;
|
|
608
|
+
html += reviewerRowHtml;
|
|
578
609
|
}
|
|
579
610
|
|
|
580
611
|
html += `</div>`; // close lane-group
|
|
@@ -631,6 +631,32 @@ body {
|
|
|
631
631
|
text-overflow: ellipsis;
|
|
632
632
|
}
|
|
633
633
|
|
|
634
|
+
/* βββ Reviewer Sub-Row βββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
635
|
+
|
|
636
|
+
.reviewer-sub-row {
|
|
637
|
+
background: var(--bg-surface-hover);
|
|
638
|
+
border-left: 3px solid var(--yellow, #e5c07b);
|
|
639
|
+
padding-left: 11px; /* compensate for border */
|
|
640
|
+
font-size: 0.85em;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
.reviewer-sub-row .reviewer-label {
|
|
644
|
+
font-family: var(--font-mono);
|
|
645
|
+
font-size: 0.75rem;
|
|
646
|
+
color: var(--yellow, #e5c07b);
|
|
647
|
+
font-weight: 600;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
.reviewer-sub-row .reviewer-type {
|
|
651
|
+
font-family: var(--font-mono);
|
|
652
|
+
font-size: 0.7rem;
|
|
653
|
+
color: var(--text-muted);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
.reviewer-stats {
|
|
657
|
+
margin-top: 0;
|
|
658
|
+
}
|
|
659
|
+
|
|
634
660
|
/* βββ Telemetry Badges (retry, compaction) βββββββββββββββββββββββββββββββ */
|
|
635
661
|
|
|
636
662
|
.telem-badge {
|
package/dashboard/server.cjs
CHANGED
|
@@ -740,12 +740,15 @@ function computeBatchTotalCost(laneStates, telemetry) {
|
|
|
740
740
|
let totalCost = 0;
|
|
741
741
|
const coveredPrefixes = new Set();
|
|
742
742
|
|
|
743
|
-
// Primary: sum cost from lane states
|
|
743
|
+
// Primary: sum cost from lane states (worker + reviewer)
|
|
744
744
|
for (const [prefix, ls] of Object.entries(laneStates)) {
|
|
745
745
|
if (ls.workerCostUsd) {
|
|
746
746
|
totalCost += ls.workerCostUsd;
|
|
747
747
|
coveredPrefixes.add(prefix);
|
|
748
748
|
}
|
|
749
|
+
if (ls.reviewerCostUsd) {
|
|
750
|
+
totalCost += ls.reviewerCostUsd;
|
|
751
|
+
}
|
|
749
752
|
}
|
|
750
753
|
|
|
751
754
|
// Supplementary: add cost from telemetry for uncovered lanes only
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
21
21
|
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
22
|
+
import { Type } from "@mariozechner/pi-ai";
|
|
22
23
|
import { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
23
24
|
import { spawn, spawnSync } from "child_process";
|
|
24
25
|
import {
|
|
@@ -128,8 +129,17 @@ interface TaskState {
|
|
|
128
129
|
workerExitDiagnostic: TaskExitDiagnostic | null;
|
|
129
130
|
reviewerStatus: "idle" | "running" | "done" | "error";
|
|
130
131
|
reviewerType: string;
|
|
132
|
+
reviewerStep: number;
|
|
133
|
+
reviewerSessionName: string;
|
|
131
134
|
reviewerElapsed: number;
|
|
132
135
|
reviewerLastTool: string;
|
|
136
|
+
reviewerToolCount: number;
|
|
137
|
+
reviewerInputTokens: number;
|
|
138
|
+
reviewerOutputTokens: number;
|
|
139
|
+
reviewerCacheReadTokens: number;
|
|
140
|
+
reviewerCacheWriteTokens: number;
|
|
141
|
+
reviewerCostUsd: number;
|
|
142
|
+
reviewerContextPct: number;
|
|
133
143
|
reviewerProc: any;
|
|
134
144
|
reviewerTimer: any;
|
|
135
145
|
reviewCounter: number;
|
|
@@ -146,8 +156,10 @@ function freshState(): TaskState {
|
|
|
146
156
|
workerProc: null, workerTimer: null,
|
|
147
157
|
workerRetryActive: false, workerRetryCount: 0, workerLastRetryError: "",
|
|
148
158
|
workerExitDiagnostic: null,
|
|
149
|
-
reviewerStatus: "idle", reviewerType: "",
|
|
150
|
-
|
|
159
|
+
reviewerStatus: "idle", reviewerType: "", reviewerStep: 0, reviewerSessionName: "",
|
|
160
|
+
reviewerElapsed: 0, reviewerLastTool: "", reviewerToolCount: 0,
|
|
161
|
+
reviewerInputTokens: 0, reviewerOutputTokens: 0, reviewerCacheReadTokens: 0, reviewerCacheWriteTokens: 0,
|
|
162
|
+
reviewerCostUsd: 0, reviewerContextPct: 0, reviewerProc: null, reviewerTimer: null,
|
|
151
163
|
reviewCounter: 0, totalIterations: 0, stepStatuses: new Map(),
|
|
152
164
|
};
|
|
153
165
|
}
|
|
@@ -408,6 +420,18 @@ function writeLaneState(state: TaskState): void {
|
|
|
408
420
|
workerLastRetryError: state.workerLastRetryError,
|
|
409
421
|
workerExitDiagnostic: state.workerExitDiagnostic || undefined,
|
|
410
422
|
reviewerStatus: state.reviewerStatus || "idle",
|
|
423
|
+
reviewerSessionName: state.reviewerSessionName || "",
|
|
424
|
+
reviewerType: state.reviewerType || "",
|
|
425
|
+
reviewerStep: state.reviewerStep || 0,
|
|
426
|
+
reviewerElapsed: state.reviewerElapsed || 0,
|
|
427
|
+
reviewerContextPct: state.reviewerContextPct || 0,
|
|
428
|
+
reviewerLastTool: state.reviewerLastTool || "",
|
|
429
|
+
reviewerToolCount: state.reviewerToolCount || 0,
|
|
430
|
+
reviewerCostUsd: state.reviewerCostUsd || 0,
|
|
431
|
+
reviewerInputTokens: state.reviewerInputTokens || 0,
|
|
432
|
+
reviewerOutputTokens: state.reviewerOutputTokens || 0,
|
|
433
|
+
reviewerCacheReadTokens: state.reviewerCacheReadTokens || 0,
|
|
434
|
+
reviewerCacheWriteTokens: state.reviewerCacheWriteTokens || 0,
|
|
411
435
|
timestamp: Date.now(),
|
|
412
436
|
};
|
|
413
437
|
writeFileSync(filePath, JSON.stringify(data) + "\n");
|
|
@@ -609,6 +633,56 @@ function resolveRpcWrapperPath(): string {
|
|
|
609
633
|
);
|
|
610
634
|
}
|
|
611
635
|
|
|
636
|
+
/**
|
|
637
|
+
* Resolve the path to this extension file (task-runner.ts).
|
|
638
|
+
* Used to pass the extension to worker subprocesses so they have access
|
|
639
|
+
* to the review_step tool in orchestrated mode.
|
|
640
|
+
*
|
|
641
|
+
* Resolution strategy:
|
|
642
|
+
* 1. Derive from -e argument that loaded this extension
|
|
643
|
+
* 2. Package root + extensions/task-runner.ts
|
|
644
|
+
* 3. cwd/extensions/task-runner.ts (development fallback)
|
|
645
|
+
*
|
|
646
|
+
* Returns null if the extension path cannot be found (non-fatal β worker
|
|
647
|
+
* runs without review_step tool).
|
|
648
|
+
*/
|
|
649
|
+
function resolveExtensionPath(): string | null {
|
|
650
|
+
const extRelPath = join("extensions", "task-runner.ts");
|
|
651
|
+
|
|
652
|
+
// 1. Derive from the -e argument that loaded this file
|
|
653
|
+
try {
|
|
654
|
+
const args = process.argv;
|
|
655
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
656
|
+
if (args[i] === "-e" && args[i + 1]?.includes("task-runner")) {
|
|
657
|
+
const extPath = resolve(args[i + 1]);
|
|
658
|
+
if (existsSync(extPath)) return extPath;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
} catch { /* ignore argv parsing errors */ }
|
|
662
|
+
|
|
663
|
+
// 2. Package root
|
|
664
|
+
const root = findPackageRoot();
|
|
665
|
+
if (root) {
|
|
666
|
+
const p = join(root, extRelPath);
|
|
667
|
+
if (existsSync(p)) return p;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 3. Development fallback
|
|
671
|
+
const devPath = join(process.cwd(), extRelPath);
|
|
672
|
+
if (existsSync(devPath)) return devPath;
|
|
673
|
+
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Detect whether this extension instance is running inside a worker subprocess
|
|
679
|
+
* (set via TASK_RUNNER_WORKER_TOOL_MODE env var). When true, the extension only
|
|
680
|
+
* registers the review_step tool β no commands, widgets, or auto-start.
|
|
681
|
+
*/
|
|
682
|
+
function isWorkerToolMode(): boolean {
|
|
683
|
+
return process.env.TASK_RUNNER_WORKER_TOOL_MODE === "1";
|
|
684
|
+
}
|
|
685
|
+
|
|
612
686
|
/**
|
|
613
687
|
* Load an agent definition with prompt inheritance.
|
|
614
688
|
*
|
|
@@ -1540,6 +1614,9 @@ function spawnAgentTmux(opts: {
|
|
|
1540
1614
|
tools: string;
|
|
1541
1615
|
thinking: string;
|
|
1542
1616
|
taskId?: string;
|
|
1617
|
+
/** Optional extension paths to load in the spawned pi session (via rpc-wrapper --extensions).
|
|
1618
|
+
* When provided, --no-extensions is NOT passed to pi (would conflict). */
|
|
1619
|
+
extensions?: string[];
|
|
1543
1620
|
/** Called on each poll tick with accumulated telemetry from the sidecar JSONL.
|
|
1544
1621
|
* Enables the tmux poll loop to update TaskState (tokens, cost, context%, tools, retries)
|
|
1545
1622
|
* with the same signals that subprocess mode gets from onTokenUpdate/onContextPct/onToolCall. */
|
|
@@ -1652,12 +1729,20 @@ function spawnAgentTmux(opts: {
|
|
|
1652
1729
|
"--system-prompt-file", quoteArg(sysTmpFile),
|
|
1653
1730
|
"--prompt-file", quoteArg(promptTmpFile),
|
|
1654
1731
|
"--tools", quoteArg(opts.tools),
|
|
1655
|
-
// Passthrough pi args: flags forwarded to the underlying pi --mode rpc process.
|
|
1656
|
-
// Note: --no-session is NOT passed here β rpc-wrapper.mjs already injects it.
|
|
1657
|
-
"--",
|
|
1658
|
-
"--thinking", quoteArg(opts.thinking),
|
|
1659
|
-
"--no-extensions", "--no-skills",
|
|
1660
1732
|
];
|
|
1733
|
+
// When extensions are provided, pass them to rpc-wrapper (which translates to `pi -e`)
|
|
1734
|
+
// and do NOT pass --no-extensions (would conflict).
|
|
1735
|
+
if (opts.extensions && opts.extensions.length > 0) {
|
|
1736
|
+
wrapperArgs.push("--extensions", quoteArg(opts.extensions.join(",")));
|
|
1737
|
+
}
|
|
1738
|
+
// Passthrough pi args: flags forwarded to the underlying pi --mode rpc process.
|
|
1739
|
+
// Note: --no-session is NOT passed here β rpc-wrapper.mjs already injects it.
|
|
1740
|
+
wrapperArgs.push("--");
|
|
1741
|
+
wrapperArgs.push("--thinking", quoteArg(opts.thinking));
|
|
1742
|
+
if (!opts.extensions || opts.extensions.length === 0) {
|
|
1743
|
+
wrapperArgs.push("--no-extensions");
|
|
1744
|
+
}
|
|
1745
|
+
wrapperArgs.push("--no-skills");
|
|
1661
1746
|
const wrapperCommand = wrapperArgs.join(" ");
|
|
1662
1747
|
|
|
1663
1748
|
// ββ Handle stale session βββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -1957,6 +2042,255 @@ export default function (pi: ExtensionAPI) {
|
|
|
1957
2042
|
});
|
|
1958
2043
|
}
|
|
1959
2044
|
|
|
2045
|
+
// ββ review_step Tool (orchestrated mode only) βββββββββββββββββββ
|
|
2046
|
+
|
|
2047
|
+
/**
|
|
2048
|
+
* Reset reviewer telemetry fields on state to idle/zero.
|
|
2049
|
+
* Called after a review completes to clear dashboard metrics.
|
|
2050
|
+
*/
|
|
2051
|
+
function clearReviewerState(): void {
|
|
2052
|
+
state.reviewerStatus = "idle";
|
|
2053
|
+
state.reviewerType = "";
|
|
2054
|
+
state.reviewerStep = 0;
|
|
2055
|
+
state.reviewerSessionName = "";
|
|
2056
|
+
state.reviewerElapsed = 0;
|
|
2057
|
+
state.reviewerLastTool = "";
|
|
2058
|
+
state.reviewerToolCount = 0;
|
|
2059
|
+
state.reviewerInputTokens = 0;
|
|
2060
|
+
state.reviewerOutputTokens = 0;
|
|
2061
|
+
state.reviewerCacheReadTokens = 0;
|
|
2062
|
+
state.reviewerCacheWriteTokens = 0;
|
|
2063
|
+
state.reviewerCostUsd = 0;
|
|
2064
|
+
state.reviewerContextPct = 0;
|
|
2065
|
+
state.reviewerProc = null;
|
|
2066
|
+
if (state.reviewerTimer) clearInterval(state.reviewerTimer);
|
|
2067
|
+
state.reviewerTimer = null;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
if (isOrchestratedMode()) {
|
|
2071
|
+
pi.registerTool({
|
|
2072
|
+
name: "review_step",
|
|
2073
|
+
label: "Review Step",
|
|
2074
|
+
description:
|
|
2075
|
+
"Spawn a reviewer agent to evaluate your work on a step. " +
|
|
2076
|
+
"Returns APPROVE, REVISE, RETHINK, or UNAVAILABLE. " +
|
|
2077
|
+
"Use at step boundaries based on the task's review level.",
|
|
2078
|
+
promptSnippet: "review_step(step, type) β spawn reviewer for a step (plan/code review)",
|
|
2079
|
+
promptGuidelines: [
|
|
2080
|
+
"Call review_step at step boundaries based on the task's Review Level (from STATUS.md header).",
|
|
2081
|
+
"Review Level 0: skip all reviews. Level 1: plan review before implementing. Level 2: plan + code review. Level 3: plan + code + test review.",
|
|
2082
|
+
"Skip reviews for Step 0 (Preflight) and the final documentation/delivery step.",
|
|
2083
|
+
"On REVISE: read the review file in .reviews/ for detailed feedback, address the issues, commit fixes, then proceed.",
|
|
2084
|
+
"On RETHINK: reconsider your plan approach, adjust, then implement.",
|
|
2085
|
+
"On UNAVAILABLE: reviewer failed β proceed with caution.",
|
|
2086
|
+
],
|
|
2087
|
+
parameters: Type.Object({
|
|
2088
|
+
step: Type.Number({ description: "Step number to review" }),
|
|
2089
|
+
type: Type.Union(
|
|
2090
|
+
[Type.Literal("plan"), Type.Literal("code")],
|
|
2091
|
+
{ description: 'Review type: "plan" or "code"' },
|
|
2092
|
+
),
|
|
2093
|
+
}),
|
|
2094
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
2095
|
+
const { step: stepNum, type: reviewType } = params;
|
|
2096
|
+
|
|
2097
|
+
if (!state.task || !state.config) {
|
|
2098
|
+
return {
|
|
2099
|
+
content: [{ type: "text" as const, text: "UNAVAILABLE β no task loaded" }],
|
|
2100
|
+
details: undefined,
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
const task = state.task;
|
|
2105
|
+
const config = state.config;
|
|
2106
|
+
const statusPath = join(task.taskFolder, "STATUS.md");
|
|
2107
|
+
const reviewsDir = join(task.taskFolder, ".reviews");
|
|
2108
|
+
if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
|
|
2109
|
+
|
|
2110
|
+
// Low-risk step check (safety net β worker template also skips)
|
|
2111
|
+
if (isLowRiskStep(stepNum, task.steps.length)) {
|
|
2112
|
+
const label = stepNum === 0 ? "Preflight" : "final step";
|
|
2113
|
+
logExecution(statusPath, `Skip ${reviewType} review`, `Step ${stepNum} (${label}) β low-risk`);
|
|
2114
|
+
return {
|
|
2115
|
+
content: [{ type: "text" as const, text: `APPROVE β Step ${stepNum} is low-risk (${label}), review skipped` }],
|
|
2116
|
+
details: undefined,
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// Increment review counter
|
|
2121
|
+
state.reviewCounter++;
|
|
2122
|
+
const num = String(state.reviewCounter).padStart(3, "0");
|
|
2123
|
+
const requestPath = join(reviewsDir, `request-R${num}.md`);
|
|
2124
|
+
const outputPath = join(reviewsDir, `R${num}-${reviewType}-step${stepNum}.md`);
|
|
2125
|
+
|
|
2126
|
+
// Find step baseline commit for code reviews
|
|
2127
|
+
let stepBaselineCommit: string | undefined;
|
|
2128
|
+
if (reviewType === "code") {
|
|
2129
|
+
stepBaselineCommit = getHeadCommitSha();
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// Find step info for the name
|
|
2133
|
+
const stepInfo = task.steps.find(s => s.number === stepNum);
|
|
2134
|
+
const stepName = stepInfo?.name || `Step ${stepNum}`;
|
|
2135
|
+
|
|
2136
|
+
// Generate review request
|
|
2137
|
+
const request = generateReviewRequest(
|
|
2138
|
+
reviewType, stepNum, stepName, task, config, outputPath, stepBaselineCommit,
|
|
2139
|
+
);
|
|
2140
|
+
writeFileSync(requestPath, request);
|
|
2141
|
+
|
|
2142
|
+
// Load reviewer agent definition
|
|
2143
|
+
const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
|
|
2144
|
+
const reviewerModel = config.reviewer.model
|
|
2145
|
+
|| reviewerDef?.model
|
|
2146
|
+
|| "openai/gpt-5.3-codex";
|
|
2147
|
+
const reviewerPrompt = reviewerDef?.systemPrompt
|
|
2148
|
+
|| "You are a code reviewer. Read the request and write your review to the specified output file.";
|
|
2149
|
+
const systemPrompt = reviewerPrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
|
|
2150
|
+
|
|
2151
|
+
// Update state for dashboard visibility
|
|
2152
|
+
const sessionName = `${getTmuxPrefix()}-reviewer`;
|
|
2153
|
+
state.reviewerStatus = "running";
|
|
2154
|
+
state.reviewerType = `${reviewType} review`;
|
|
2155
|
+
state.reviewerStep = stepNum;
|
|
2156
|
+
state.reviewerSessionName = sessionName;
|
|
2157
|
+
state.reviewerElapsed = 0;
|
|
2158
|
+
state.reviewerLastTool = "";
|
|
2159
|
+
state.reviewerToolCount = 0;
|
|
2160
|
+
state.reviewerInputTokens = 0;
|
|
2161
|
+
state.reviewerOutputTokens = 0;
|
|
2162
|
+
state.reviewerCacheReadTokens = 0;
|
|
2163
|
+
state.reviewerCacheWriteTokens = 0;
|
|
2164
|
+
state.reviewerCostUsd = 0;
|
|
2165
|
+
state.reviewerContextPct = 0;
|
|
2166
|
+
updateWidgets();
|
|
2167
|
+
|
|
2168
|
+
const startTime = Date.now();
|
|
2169
|
+
state.reviewerTimer = setInterval(() => {
|
|
2170
|
+
state.reviewerElapsed = Date.now() - startTime;
|
|
2171
|
+
updateWidgets();
|
|
2172
|
+
}, 1000);
|
|
2173
|
+
|
|
2174
|
+
// Read the request file content as the prompt
|
|
2175
|
+
const promptContent = readFileSync(requestPath, "utf-8");
|
|
2176
|
+
|
|
2177
|
+
// Resolve context window for reviewer context% calculation
|
|
2178
|
+
const { contextWindow } = resolveContextWindow(config, ctx);
|
|
2179
|
+
|
|
2180
|
+
try {
|
|
2181
|
+
// Spawn reviewer via spawnAgentTmux with onTelemetry for live metrics
|
|
2182
|
+
const spawned = spawnAgentTmux({
|
|
2183
|
+
sessionName,
|
|
2184
|
+
cwd: ctx.cwd,
|
|
2185
|
+
systemPrompt,
|
|
2186
|
+
prompt: promptContent,
|
|
2187
|
+
model: reviewerModel,
|
|
2188
|
+
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
2189
|
+
thinking: config.reviewer.thinking || "on",
|
|
2190
|
+
taskId: task.taskId,
|
|
2191
|
+
onTelemetry: (delta) => {
|
|
2192
|
+
// Accumulate tokens and cost
|
|
2193
|
+
state.reviewerInputTokens += delta.inputTokens;
|
|
2194
|
+
state.reviewerOutputTokens += delta.outputTokens;
|
|
2195
|
+
state.reviewerCacheReadTokens += delta.cacheReadTokens;
|
|
2196
|
+
state.reviewerCacheWriteTokens += delta.cacheWriteTokens;
|
|
2197
|
+
state.reviewerCostUsd += delta.cost;
|
|
2198
|
+
|
|
2199
|
+
// Tool tracking
|
|
2200
|
+
state.reviewerToolCount += delta.toolCalls;
|
|
2201
|
+
if (delta.lastTool) {
|
|
2202
|
+
state.reviewerLastTool = delta.lastTool;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// Context %
|
|
2206
|
+
if (delta.latestTotalTokens > 0 && contextWindow > 0) {
|
|
2207
|
+
state.reviewerContextPct = (delta.latestTotalTokens / contextWindow) * 100;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
writeLaneState(state);
|
|
2211
|
+
updateWidgets();
|
|
2212
|
+
},
|
|
2213
|
+
});
|
|
2214
|
+
|
|
2215
|
+
state.reviewerProc = { kill: spawned.kill };
|
|
2216
|
+
|
|
2217
|
+
// Await reviewer completion
|
|
2218
|
+
const result = await spawned.promise;
|
|
2219
|
+
|
|
2220
|
+
clearInterval(state.reviewerTimer);
|
|
2221
|
+
state.reviewerElapsed = Date.now() - startTime;
|
|
2222
|
+
state.reviewerStatus = result.exitCode === 0 ? "done" : "error";
|
|
2223
|
+
state.reviewerProc = null;
|
|
2224
|
+
writeLaneState(state);
|
|
2225
|
+
updateWidgets();
|
|
2226
|
+
|
|
2227
|
+
// Extract verdict from review output
|
|
2228
|
+
let verdict = "UNKNOWN";
|
|
2229
|
+
let reviseDetails = "";
|
|
2230
|
+
if (existsSync(outputPath)) {
|
|
2231
|
+
const review = readFileSync(outputPath, "utf-8");
|
|
2232
|
+
verdict = extractVerdict(review);
|
|
2233
|
+
if (verdict === "REVISE") {
|
|
2234
|
+
// Extract a brief summary from the review for the worker
|
|
2235
|
+
const summaryMatch = review.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
|
|
2236
|
+
reviseDetails = summaryMatch
|
|
2237
|
+
? summaryMatch[1].trim().slice(0, 500)
|
|
2238
|
+
: "See review file for details.";
|
|
2239
|
+
}
|
|
2240
|
+
} else {
|
|
2241
|
+
verdict = "UNAVAILABLE";
|
|
2242
|
+
logExecution(statusPath, `Reviewer R${num}`,
|
|
2243
|
+
`${reviewType} review β reviewer did not produce output`);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// Log the review in STATUS.md
|
|
2247
|
+
logReview(statusPath, `R${num}`, reviewType, stepNum, verdict,
|
|
2248
|
+
`.reviews/R${num}-${reviewType}-step${stepNum}.md`);
|
|
2249
|
+
logExecution(statusPath, `Review R${num}`,
|
|
2250
|
+
`${reviewType} Step ${stepNum}: ${verdict}`);
|
|
2251
|
+
updateStatusField(statusPath, "Review Counter", `${state.reviewCounter}`);
|
|
2252
|
+
|
|
2253
|
+
// Clear reviewer state for dashboard
|
|
2254
|
+
clearReviewerState();
|
|
2255
|
+
writeLaneState(state);
|
|
2256
|
+
updateWidgets();
|
|
2257
|
+
|
|
2258
|
+
// Return verdict to the worker
|
|
2259
|
+
let resultText: string;
|
|
2260
|
+
if (verdict === "APPROVE") {
|
|
2261
|
+
resultText = "APPROVE";
|
|
2262
|
+
} else if (verdict === "REVISE") {
|
|
2263
|
+
resultText = `REVISE: ${reviseDetails}\n\nFull review: .reviews/R${num}-${reviewType}-step${stepNum}.md`;
|
|
2264
|
+
} else if (verdict === "RETHINK") {
|
|
2265
|
+
resultText = `RETHINK β reconsider your approach. See .reviews/R${num}-${reviewType}-step${stepNum}.md`;
|
|
2266
|
+
} else {
|
|
2267
|
+
resultText = `UNAVAILABLE β reviewer did not produce a usable verdict.`;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
return {
|
|
2271
|
+
content: [{ type: "text" as const, text: resultText }],
|
|
2272
|
+
details: undefined,
|
|
2273
|
+
};
|
|
2274
|
+
} catch (err: any) {
|
|
2275
|
+
// Reviewer crashed
|
|
2276
|
+
clearInterval(state.reviewerTimer);
|
|
2277
|
+
clearReviewerState();
|
|
2278
|
+
state.reviewerStatus = "error";
|
|
2279
|
+
writeLaneState(state);
|
|
2280
|
+
updateWidgets();
|
|
2281
|
+
|
|
2282
|
+
logExecution(statusPath, `Reviewer R${num}`,
|
|
2283
|
+
`${reviewType} review β reviewer crashed: ${err?.message || err}`);
|
|
2284
|
+
|
|
2285
|
+
return {
|
|
2286
|
+
content: [{ type: "text" as const, text: `UNAVAILABLE β reviewer error: ${err?.message || err}` }],
|
|
2287
|
+
details: undefined,
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
},
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
|
|
1960
2294
|
// ββ Execution Engine βββββββββββββββββββββββββββββββββββββββββββββ
|
|
1961
2295
|
|
|
1962
2296
|
async function executeTask(ctx: ExtensionContext): Promise<void> {
|
|
@@ -1972,27 +2306,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1972
2306
|
|
|
1973
2307
|
// ββ Per-task worker loop βββββββββββββββββββββββββββββββββββββ
|
|
1974
2308
|
// Spawn one worker per iteration; each worker handles ALL remaining
|
|
1975
|
-
// steps.
|
|
2309
|
+
// steps. The worker drives reviews inline via the review_step tool
|
|
2310
|
+
// (in orchestrated mode) β no deferred reviews after worker exit.
|
|
1976
2311
|
// If context limit is hit mid-task, the next iteration picks up from
|
|
1977
2312
|
// the first incomplete step via STATUS.md β same recovery mechanism.
|
|
1978
2313
|
|
|
1979
|
-
//
|
|
1980
|
-
// Baselines are updated at step boundaries so each code review
|
|
1981
|
-
// sees only that step's changes, not cumulative diffs.
|
|
1982
|
-
const stepBaselineCommits = new Map<number, string>();
|
|
1983
|
-
|
|
1984
|
-
// Track steps that received a REVISE verdict and need rework.
|
|
1985
|
-
// This prevents the checkbox-count heuristic from re-completing them
|
|
1986
|
-
// before the worker has had a chance to address reviewer feedback.
|
|
1987
|
-
const needsRework = new Set<number>();
|
|
1988
|
-
|
|
1989
|
-
// Track which steps have already received a plan review so we don't
|
|
1990
|
-
// re-run plan review on rework cycles (only code review reruns).
|
|
1991
|
-
const planReviewedSteps = new Set<number>();
|
|
1992
|
-
|
|
1993
|
-
// Mark all incomplete steps as in-progress and capture the baseline
|
|
1994
|
-
// commit for the first one. Reviews are transition-based: they run
|
|
1995
|
-
// when a step newly completes after the worker exits, not up-front.
|
|
2314
|
+
// Mark all incomplete steps as in-progress
|
|
1996
2315
|
{
|
|
1997
2316
|
const currentStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
|
|
1998
2317
|
for (const step of task.steps) {
|
|
@@ -2002,23 +2321,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
2002
2321
|
// Mark step as in-progress and log its start
|
|
2003
2322
|
updateStepStatus(statusPath, step.number, "in-progress");
|
|
2004
2323
|
logExecution(statusPath, `Step ${step.number} started`, step.name);
|
|
2005
|
-
|
|
2006
|
-
// Capture baseline commit for the FIRST incomplete step only.
|
|
2007
|
-
// Later steps get their baselines when the prior step completes
|
|
2008
|
-
// (see the newlyCompleted handler in the iteration loop).
|
|
2009
|
-
// This prevents cross-step diff bleeding in code reviews.
|
|
2010
|
-
if (!stepBaselineCommits.size) {
|
|
2011
|
-
stepBaselineCommits.set(step.number, getHeadCommitSha());
|
|
2012
|
-
}
|
|
2013
2324
|
}
|
|
2014
2325
|
}
|
|
2015
2326
|
|
|
2016
2327
|
// Helper: determine if a parsed step is complete.
|
|
2017
|
-
// A step in needsRework (set after REVISE) is never complete, even if
|
|
2018
|
-
// all checkboxes are checked β the worker must address the feedback first.
|
|
2019
2328
|
function isStepComplete(ss: StepInfo | undefined): boolean {
|
|
2020
2329
|
if (!ss) return false;
|
|
2021
|
-
if (needsRework.has(ss.number)) return false;
|
|
2022
2330
|
if (ss.status === "complete") return true;
|
|
2023
2331
|
// Fallback: infer from checkboxes (covers "in-progress" and "not-started")
|
|
2024
2332
|
return ss.totalChecked === ss.totalItems && ss.totalItems > 0;
|
|
@@ -2058,13 +2366,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
2058
2366
|
if (isStepComplete(ss)) completedBefore.add(ss.number);
|
|
2059
2367
|
}
|
|
2060
2368
|
|
|
2061
|
-
// Ensure the first remaining step has a baseline. On subsequent
|
|
2062
|
-
// iterations (after context-limit recovery), the first remaining step
|
|
2063
|
-
// may not have a baseline yet if it wasn't the first step originally.
|
|
2064
|
-
if (remainingSteps.length > 0 && !stepBaselineCommits.has(remainingSteps[0].number)) {
|
|
2065
|
-
stepBaselineCommits.set(remainingSteps[0].number, getHeadCommitSha());
|
|
2066
|
-
}
|
|
2067
|
-
|
|
2068
2369
|
await runWorker(remainingSteps, ctx);
|
|
2069
2370
|
|
|
2070
2371
|
if (state.phase === "error") return;
|
|
@@ -2090,49 +2391,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
2090
2391
|
}
|
|
2091
2392
|
|
|
2092
2393
|
// Find newly completed steps.
|
|
2093
|
-
// For steps in needsRework, the worker must have addressed the
|
|
2094
|
-
// reviewer feedback β we detect this by checking if STATUS.md was
|
|
2095
|
-
// updated (the worker adds/checks revision items). We temporarily
|
|
2096
|
-
// remove the step from needsRework to let isStepComplete() evaluate
|
|
2097
|
-
// the checkbox state, then re-add if it's still not complete.
|
|
2098
2394
|
const newlyCompleted: StepInfo[] = [];
|
|
2099
2395
|
for (const step of task.steps) {
|
|
2100
2396
|
if (completedBefore.has(step.number)) continue;
|
|
2101
2397
|
const ss = afterStatus.steps.find(s => s.number === step.number);
|
|
2102
|
-
if (
|
|
2103
|
-
// For rework steps, check if the worker set status to "complete"
|
|
2104
|
-
// or if the step now has all checkboxes checked (worker addressed feedback)
|
|
2105
|
-
if (ss?.status === "complete" ||
|
|
2106
|
-
(ss && ss.totalChecked === ss.totalItems && ss.totalItems > 0)) {
|
|
2107
|
-
needsRework.delete(step.number);
|
|
2108
|
-
updateStepStatus(statusPath, step.number, "complete");
|
|
2109
|
-
logExecution(statusPath, `Step ${step.number} complete`, `${step.name} (rework)`);
|
|
2110
|
-
newlyCompleted.push(step);
|
|
2111
|
-
}
|
|
2112
|
-
} else if (isStepComplete(ss)) {
|
|
2398
|
+
if (isStepComplete(ss)) {
|
|
2113
2399
|
updateStepStatus(statusPath, step.number, "complete");
|
|
2114
2400
|
logExecution(statusPath, `Step ${step.number} complete`, step.name);
|
|
2115
2401
|
newlyCompleted.push(step);
|
|
2116
2402
|
}
|
|
2117
2403
|
}
|
|
2118
2404
|
|
|
2119
|
-
// Update baselines for subsequent steps using step boundary commits.
|
|
2120
|
-
// When the worker completes multiple steps in one iteration, each step's
|
|
2121
|
-
// commit becomes the baseline for the next step's code review.
|
|
2122
|
-
if (newlyCompleted.length > 1) {
|
|
2123
|
-
for (let i = 0; i < newlyCompleted.length; i++) {
|
|
2124
|
-
const completedStep = newlyCompleted[i];
|
|
2125
|
-
const boundaryCommit = findStepBoundaryCommit(
|
|
2126
|
-
completedStep.number, task.taskId,
|
|
2127
|
-
stepBaselineCommits.get(completedStep.number)
|
|
2128
|
-
);
|
|
2129
|
-
if (boundaryCommit && i + 1 < newlyCompleted.length) {
|
|
2130
|
-
// Use this step's completion commit as the next step's baseline
|
|
2131
|
-
stepBaselineCommits.set(newlyCompleted[i + 1].number, boundaryCommit);
|
|
2132
|
-
}
|
|
2133
|
-
}
|
|
2134
|
-
}
|
|
2135
|
-
|
|
2136
2405
|
// Log iteration summary with progress delta and completed steps
|
|
2137
2406
|
const completedNames = newlyCompleted.map(s => `Step ${s.number}`).join(", ");
|
|
2138
2407
|
if (newlyCompleted.length > 0) {
|
|
@@ -2143,57 +2412,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2143
2412
|
ctx.ui.notify(`Iteration ${iter + 1}: +${progressDelta} checkboxes (no steps fully completed)`, "info");
|
|
2144
2413
|
}
|
|
2145
2414
|
|
|
2146
|
-
//
|
|
2147
|
-
//
|
|
2148
|
-
// run on every completion (including rework). Both respect review
|
|
2149
|
-
// level and low-risk skip logic.
|
|
2150
|
-
// Gate on phase !== "error" so reviews still run when paused
|
|
2151
|
-
// (pause is honored after reviews, before next iteration).
|
|
2152
|
-
if (state.phase !== "error") {
|
|
2153
|
-
for (const step of newlyCompleted) {
|
|
2154
|
-
const lowRisk = isLowRiskStep(step.number, task.steps.length);
|
|
2155
|
-
|
|
2156
|
-
// ββ Plan review (level β₯ 1, first completion only) ββ
|
|
2157
|
-
if (task.reviewLevel >= 1 && !planReviewedSteps.has(step.number)) {
|
|
2158
|
-
if (lowRisk) {
|
|
2159
|
-
const label = step.number === 0 ? "Preflight" : "final step";
|
|
2160
|
-
logExecution(statusPath, `Skip plan review`, `Step ${step.number} (${label}) β low-risk`);
|
|
2161
|
-
ctx.ui.notify(`βοΈ Skipping plan review for Step ${step.number} (${label})`, "info");
|
|
2162
|
-
} else {
|
|
2163
|
-
const verdict = await doReview("plan", step, ctx);
|
|
2164
|
-
if (verdict === "RETHINK") {
|
|
2165
|
-
ctx.ui.notify(`Reviewer: RETHINK on Step ${step.number} plan. Proceeding with caution.`, "warning");
|
|
2166
|
-
}
|
|
2167
|
-
}
|
|
2168
|
-
planReviewedSteps.add(step.number);
|
|
2169
|
-
}
|
|
2170
|
-
|
|
2171
|
-
// ββ Code review (level β₯ 2) ββ
|
|
2172
|
-
if (task.reviewLevel >= 2) {
|
|
2173
|
-
if (lowRisk) {
|
|
2174
|
-
const label = step.number === 0 ? "Preflight" : "final step";
|
|
2175
|
-
logExecution(statusPath, `Skip code review`, `Step ${step.number} (${label}) β low-risk`);
|
|
2176
|
-
ctx.ui.notify(`βοΈ Skipping code review for Step ${step.number} (${label})`, "info");
|
|
2177
|
-
} else {
|
|
2178
|
-
const baseline = stepBaselineCommits.get(step.number);
|
|
2179
|
-
const verdict = await doReview("code", step, ctx, baseline);
|
|
2180
|
-
if (verdict === "REVISE") {
|
|
2181
|
-
ctx.ui.notify(`Reviewer: REVISE on Step ${step.number}. Will rework in next iteration.`, "warning");
|
|
2182
|
-
// Mark step as needing rework β undo the "complete" status.
|
|
2183
|
-
// needsRework ensures isStepComplete() won't re-complete this
|
|
2184
|
-
// step based on checkbox counts alone; the worker must address
|
|
2185
|
-
// the reviewer feedback first, which will update STATUS.md and
|
|
2186
|
-
// clear the rework flag when the step is newly completed again.
|
|
2187
|
-
needsRework.add(step.number);
|
|
2188
|
-
updateStepStatus(statusPath, step.number, "in-progress");
|
|
2189
|
-
// Update baseline so the next code review for this step
|
|
2190
|
-
// diffs only the rework changes, not the original step work
|
|
2191
|
-
stepBaselineCommits.set(step.number, getHeadCommitSha());
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
}
|
|
2195
|
-
}
|
|
2196
|
-
}
|
|
2415
|
+
// Reviews are now driven inline by the worker via the review_step
|
|
2416
|
+
// tool (orchestrated mode). No deferred review logic here.
|
|
2197
2417
|
|
|
2198
2418
|
// Update local cache
|
|
2199
2419
|
const refreshed = parseStatusMd(readFileSync(statusPath, "utf-8"));
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { readFileSync, existsSync, statSync, unlinkSync, mkdirSync, writeFileSync } from "fs";
|
|
6
6
|
import { spawnSync } from "child_process";
|
|
7
7
|
import { join, dirname, resolve, relative, delimiter as pathDelimiter } from "path";
|
|
8
|
-
import {
|
|
8
|
+
import { userInfo } from "os";
|
|
9
9
|
|
|
10
10
|
import { DONE_GRACE_MS, EXECUTION_POLL_INTERVAL_MS, ExecutionError, SESSION_SPAWN_RETRY_MAX } from "./types.ts";
|
|
11
11
|
import type { AllocatedLane, AllocatedTask, DependencyGraph, LaneExecutionResult, LaneMonitorSnapshot, LaneTaskOutcome, LaneTaskStatus, MonitorState, MtimeTracker, OrchestratorConfig, ParsedTask, TaskMonitorSnapshot, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
|
|
@@ -106,6 +106,35 @@ export function resolveRpcWrapperPath(repoRoot: string): string {
|
|
|
106
106
|
return localPath;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// ββ Telemetry Helpers ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the operator ID for telemetry filenames.
|
|
113
|
+
*
|
|
114
|
+
* Priority: TASKPLANE_OPERATOR_ID env β OS username β "op" fallback.
|
|
115
|
+
* Shared by lane and merge telemetry path generators to avoid divergence.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveTelemOpId(): string {
|
|
118
|
+
const envOpId = process.env.TASKPLANE_OPERATOR_ID;
|
|
119
|
+
if (envOpId?.trim()) {
|
|
120
|
+
return envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const username = userInfo().username;
|
|
124
|
+
if (username?.trim()) {
|
|
125
|
+
return username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
126
|
+
}
|
|
127
|
+
} catch { /* userInfo() can throw on some platforms */ }
|
|
128
|
+
return "op";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Sanitize a string for use in telemetry filenames.
|
|
133
|
+
*/
|
|
134
|
+
function sanitizeForFilename(s: string, maxLen: number = 30): string {
|
|
135
|
+
return s.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, maxLen);
|
|
136
|
+
}
|
|
137
|
+
|
|
109
138
|
// ββ Telemetry Path Generation ββββββββββββββββββββββββββββββββββββββββ
|
|
110
139
|
|
|
111
140
|
/**
|
|
@@ -117,31 +146,20 @@ export function resolveRpcWrapperPath(repoRoot: string): string {
|
|
|
117
146
|
* @param sessionName - TMUX session name (e.g., "orch-lane-1")
|
|
118
147
|
* @param sidecarRoot - Root dir for sidecar files (e.g., <workspace>/.pi or <repo>/.pi)
|
|
119
148
|
* @param taskId - Task identifier (e.g., "TP-049")
|
|
149
|
+
* @param batchId - Actual batch ID from batch state (falls back to timestamp)
|
|
150
|
+
* @param repoId - Repo ID for workspace mode (falls back to "default")
|
|
120
151
|
* @returns { sidecarPath, exitSummaryPath, telemetryDir }
|
|
121
152
|
*/
|
|
122
153
|
export function generateTelemetryPaths(
|
|
123
154
|
sessionName: string,
|
|
124
155
|
sidecarRoot: string,
|
|
125
156
|
taskId?: string,
|
|
157
|
+
batchId?: string,
|
|
158
|
+
repoId?: string,
|
|
126
159
|
): { sidecarPath: string; exitSummaryPath: string; telemetryDir: string } {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
let opId = "op";
|
|
131
|
-
const envOpId = process.env.TASKPLANE_OPERATOR_ID;
|
|
132
|
-
if (envOpId?.trim()) {
|
|
133
|
-
opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
134
|
-
} else {
|
|
135
|
-
try {
|
|
136
|
-
const username = userInfo().username;
|
|
137
|
-
if (username?.trim()) {
|
|
138
|
-
opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
139
|
-
}
|
|
140
|
-
} catch { /* userInfo() can throw on some platforms */ }
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const batchId = String(telemetryTs);
|
|
144
|
-
const repoId = "default";
|
|
160
|
+
const opId = resolveTelemOpId();
|
|
161
|
+
const effectiveBatchId = batchId || String(Date.now());
|
|
162
|
+
const effectiveRepoId = repoId || "default";
|
|
145
163
|
|
|
146
164
|
// Extract role from sessionName β lane sessions are "worker" role
|
|
147
165
|
const role = "worker";
|
|
@@ -149,10 +167,8 @@ export function generateTelemetryPaths(
|
|
|
149
167
|
const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
|
|
150
168
|
|
|
151
169
|
// Include taskId when available
|
|
152
|
-
const taskIdSegment = taskId
|
|
153
|
-
|
|
154
|
-
: "";
|
|
155
|
-
const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
|
|
170
|
+
const taskIdSegment = taskId ? `-${sanitizeForFilename(taskId)}` : "";
|
|
171
|
+
const telemetryBasename = `${opId}-${effectiveBatchId}-${effectiveRepoId}${taskIdSegment}${laneSuffix}-${role}`;
|
|
156
172
|
const telemetryDir = join(sidecarRoot, "telemetry");
|
|
157
173
|
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
158
174
|
const sidecarPath = join(telemetryDir, `${telemetryBasename}.jsonl`);
|
|
@@ -390,8 +406,12 @@ export function buildTmuxSpawnArgs(
|
|
|
390
406
|
// Create a minimal prompt file for the RPC wrapper.
|
|
391
407
|
// The task-runner extension handles execution via TASK_AUTOSTART;
|
|
392
408
|
// this prompt satisfies the wrapper's --prompt-file requirement.
|
|
409
|
+
// Written to the sidecar dir (not tmpdir) so it's co-located with
|
|
410
|
+
// telemetry artifacts and cleaned up with them after the batch.
|
|
393
411
|
const promptId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
394
|
-
const
|
|
412
|
+
const promptDir = dirname(sidecarPath);
|
|
413
|
+
if (!existsSync(promptDir)) mkdirSync(promptDir, { recursive: true });
|
|
414
|
+
const promptTmpFile = join(promptDir, `lane-prompt-${promptId}.txt`);
|
|
395
415
|
writeFileSync(promptTmpFile, "Execute the task as configured by the task-runner extension.");
|
|
396
416
|
|
|
397
417
|
piCommand = [
|
|
@@ -690,7 +710,7 @@ export function spawnLaneSession(
|
|
|
690
710
|
|
|
691
711
|
// Generate telemetry file paths for RPC wrapper sidecar
|
|
692
712
|
const sidecarRoot = join(workspaceRoot || repoRoot, ".pi");
|
|
693
|
-
const telemetry = generateTelemetryPaths(sessionName, sidecarRoot, task.taskId);
|
|
713
|
+
const telemetry = generateTelemetryPaths(sessionName, sidecarRoot, task.taskId, config.orchestrator?.batchId, lane.repoId);
|
|
694
714
|
execLog(laneId, task.taskId, "telemetry paths generated", {
|
|
695
715
|
sidecar: telemetry.sidecarPath,
|
|
696
716
|
exitSummary: telemetry.exitSummaryPath,
|
|
@@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdi
|
|
|
6
6
|
import { execSync, spawnSync } from "child_process";
|
|
7
7
|
import { join, dirname, resolve, relative } from "path";
|
|
8
8
|
|
|
9
|
-
import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, generateTelemetryPaths, resolveRpcWrapperPath, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
|
|
9
|
+
import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, generateTelemetryPaths, resolveRpcWrapperPath, resolveTelemOpId, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
|
|
10
10
|
import { resolveOperatorId } from "./naming.ts";
|
|
11
11
|
import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MAX_RETRIES, MERGE_TIMEOUT_MS, MergeError, VALID_MERGE_STATUSES } from "./types.ts";
|
|
12
12
|
import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
|
|
@@ -20,47 +20,37 @@ import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./v
|
|
|
20
20
|
|
|
21
21
|
// ββ Merge Telemetry Helpers βββββββββββββββββββββββββββββββββββββββββββ
|
|
22
22
|
|
|
23
|
-
import { userInfo } from "os";
|
|
24
|
-
|
|
25
23
|
/**
|
|
26
24
|
* Generate telemetry file paths for a merge agent session.
|
|
27
25
|
*
|
|
28
|
-
*
|
|
26
|
+
* Uses the shared resolveTelemOpId() from execution.ts to avoid
|
|
27
|
+
* opId resolution divergence.
|
|
28
|
+
*
|
|
29
|
+
* Naming: {opId}-{batchId}-{repoId}[-merge-{N}]-merger.{ext}
|
|
29
30
|
* Role is always "merger" to distinguish from worker/reviewer in the dashboard.
|
|
30
31
|
*
|
|
31
32
|
* @param sessionName - TMUX session name (e.g., "orch-merge-1")
|
|
32
33
|
* @param sidecarRoot - Root dir for sidecar files (e.g., <workspace>/.pi)
|
|
34
|
+
* @param batchId - Actual batch ID from batch state (falls back to timestamp)
|
|
35
|
+
* @param repoId - Repo ID for workspace mode (falls back to "default")
|
|
33
36
|
* @returns { sidecarPath, exitSummaryPath }
|
|
34
37
|
*/
|
|
35
38
|
function generateMergeTelemetryPaths(
|
|
36
39
|
sessionName: string,
|
|
37
40
|
sidecarRoot: string,
|
|
41
|
+
batchId?: string,
|
|
42
|
+
repoId?: string,
|
|
38
43
|
): { sidecarPath: string; exitSummaryPath: string } {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
let opId = "op";
|
|
43
|
-
const envOpId = process.env.TASKPLANE_OPERATOR_ID;
|
|
44
|
-
if (envOpId?.trim()) {
|
|
45
|
-
opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
46
|
-
} else {
|
|
47
|
-
try {
|
|
48
|
-
const username = userInfo().username;
|
|
49
|
-
if (username?.trim()) {
|
|
50
|
-
opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
51
|
-
}
|
|
52
|
-
} catch { /* userInfo() can throw on some platforms */ }
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const batchId = String(telemetryTs);
|
|
56
|
-
const repoId = "default";
|
|
44
|
+
const opId = resolveTelemOpId();
|
|
45
|
+
const effectiveBatchId = batchId || String(Date.now());
|
|
46
|
+
const effectiveRepoId = repoId || "default";
|
|
57
47
|
|
|
58
48
|
// Extract merge-specific info from sessionName (e.g., "orch-merge-1")
|
|
59
49
|
const mergeMatch = sessionName.match(/merge-(\d+)/);
|
|
60
50
|
const mergeSuffix = mergeMatch ? `-merge-${mergeMatch[1]}` : "";
|
|
61
51
|
|
|
62
52
|
const role = "merger";
|
|
63
|
-
const telemetryBasename = `${opId}-${
|
|
53
|
+
const telemetryBasename = `${opId}-${effectiveBatchId}-${effectiveRepoId}${mergeSuffix}-${role}`;
|
|
64
54
|
const telemetryDir = join(sidecarRoot, "telemetry");
|
|
65
55
|
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
66
56
|
|
|
@@ -438,7 +428,6 @@ export async function spawnMergeAgent(
|
|
|
438
428
|
};
|
|
439
429
|
|
|
440
430
|
// Generate telemetry paths for this merge session
|
|
441
|
-
// Naming: {opId}-{batchId}-{repoId}-merger.jsonl (or with wave/lane info from sessionName)
|
|
442
431
|
const sidecarRoot = join(stateRoot ?? repoRoot, ".pi");
|
|
443
432
|
const telemetry = generateMergeTelemetryPaths(sessionName, sidecarRoot);
|
|
444
433
|
execLog("merge", sessionName, "telemetry paths generated", {
|
|
@@ -448,7 +437,19 @@ export async function spawnMergeAgent(
|
|
|
448
437
|
|
|
449
438
|
// Resolve paths
|
|
450
439
|
const rpcWrapperPath = resolveRpcWrapperPath(repoRoot);
|
|
451
|
-
|
|
440
|
+
|
|
441
|
+
// Resolve merger agent definition β check existence and fall back gracefully.
|
|
442
|
+
// Fresh projects that haven't run `taskplane init` may not have .pi/agents/task-merger.md.
|
|
443
|
+
const systemPromptCandidates = [
|
|
444
|
+
agentRoot ? join(agentRoot, "task-merger.md") : "",
|
|
445
|
+
join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md"),
|
|
446
|
+
].filter(Boolean);
|
|
447
|
+
let systemPromptPath = systemPromptCandidates.find(p => existsSync(p)) || "";
|
|
448
|
+
if (!systemPromptPath) {
|
|
449
|
+
execLog("merge", sessionName, "WARNING: merger agent definition not found β merge agent will use default system prompt", {
|
|
450
|
+
candidates: systemPromptCandidates,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
452
453
|
|
|
453
454
|
// Build RPC wrapper command
|
|
454
455
|
const wrapperParts = [
|
|
@@ -457,9 +458,14 @@ export async function spawnMergeAgent(
|
|
|
457
458
|
"--sidecar-path", shellQuote(telemetry.sidecarPath),
|
|
458
459
|
"--exit-summary-path", shellQuote(telemetry.exitSummaryPath),
|
|
459
460
|
"--prompt-file", shellQuote(mergeRequestPath),
|
|
460
|
-
"--system-prompt-file", shellQuote(systemPromptPath),
|
|
461
461
|
];
|
|
462
462
|
|
|
463
|
+
// Only pass --system-prompt-file when the file exists (fresh projects
|
|
464
|
+
// may not have .pi/agents/task-merger.md β rpc-wrapper would crash).
|
|
465
|
+
if (systemPromptPath) {
|
|
466
|
+
wrapperParts.push("--system-prompt-file", shellQuote(systemPromptPath));
|
|
467
|
+
}
|
|
468
|
+
|
|
463
469
|
// Add model args if specified
|
|
464
470
|
if (config.merge.model) {
|
|
465
471
|
wrapperParts.push("--model", shellQuote(config.merge.model));
|
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@ name: task-worker
|
|
|
16
16
|
- Multi-step execution (worker handles all remaining steps per invocation)
|
|
17
17
|
- Iteration recovery (context limit β next invocation resumes from STATUS.md)
|
|
18
18
|
- Git commit conventions (per-step commits) and .DONE file creation
|
|
19
|
+
- Review protocol (inline reviews via review_step tool when available)
|
|
19
20
|
- Review response handling
|
|
20
21
|
|
|
21
22
|
Add project-specific rules below. Common examples:
|
|
@@ -158,6 +158,44 @@ When a reviewer returns REVISE with specific feedback items:
|
|
|
158
158
|
- Do NOT expand task scope beyond what the steps require
|
|
159
159
|
- If you discover something out of scope, note it in STATUS.md Discoveries table
|
|
160
160
|
|
|
161
|
+
## Review Protocol
|
|
162
|
+
|
|
163
|
+
If you have access to a `review_step` tool, use it at step boundaries to spawn
|
|
164
|
+
a reviewer agent. The tool takes two parameters: `step` (number) and `type`
|
|
165
|
+
("plan" or "code"). It returns a verdict string.
|
|
166
|
+
|
|
167
|
+
**When to call reviews** (based on Review Level from STATUS.md header):
|
|
168
|
+
|
|
169
|
+
- **Review Level 0 (None):** Skip all reviews.
|
|
170
|
+
- **Review Level 1 (Plan Only):** Before implementing each step, call
|
|
171
|
+
`review_step(step=N, type="plan")` to get plan feedback.
|
|
172
|
+
- **Review Level 2 (Plan + Code):** Plan review before implementing, then code
|
|
173
|
+
review after implementing and committing.
|
|
174
|
+
- **Review Level 3 (Full):** Plan + code + test reviews.
|
|
175
|
+
|
|
176
|
+
**Always skip reviews for:** Step 0 (Preflight) and the final step (typically
|
|
177
|
+
documentation/delivery). These are low-risk steps where review overhead exceeds
|
|
178
|
+
value.
|
|
179
|
+
|
|
180
|
+
**Handling verdicts:**
|
|
181
|
+
- **APPROVE** β proceed to next step
|
|
182
|
+
- **RETHINK** β reconsider your plan approach, adjust, then implement
|
|
183
|
+
- **REVISE** β read the review file in `.reviews/` for detailed feedback,
|
|
184
|
+
address the issues, commit fixes, then proceed
|
|
185
|
+
- **UNAVAILABLE** β reviewer failed, proceed with caution
|
|
186
|
+
|
|
187
|
+
**Example flow for a Review Level 2 task, Step 3:**
|
|
188
|
+
1. Read Step 3 requirements
|
|
189
|
+
2. Call `review_step(step=3, type="plan")` β get plan feedback
|
|
190
|
+
3. Implement Step 3
|
|
191
|
+
4. Commit changes
|
|
192
|
+
5. Call `review_step(step=3, type="code")` β get code feedback
|
|
193
|
+
6. If REVISE: fix issues, commit again
|
|
194
|
+
7. Move to Step 4
|
|
195
|
+
|
|
196
|
+
If the `review_step` tool is not available (e.g., non-orchestrated mode), skip
|
|
197
|
+
this protocol entirely β the task-runner handles reviews externally.
|
|
198
|
+
|
|
161
199
|
## Self-Documentation
|
|
162
200
|
|
|
163
201
|
You have standing permission to:
|