taskplane 0.22.18 → 0.23.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 +365 -7
- package/dashboard/public/index.html +16 -0
- package/dashboard/public/style.css +105 -0
- package/dashboard/server.cjs +199 -0
- package/extensions/task-runner.ts +40 -286
- package/extensions/taskplane/abort.ts +11 -1
- package/extensions/taskplane/agent-bridge-extension.ts +159 -0
- package/extensions/taskplane/agent-host.ts +686 -0
- package/extensions/taskplane/engine.ts +75 -3
- package/extensions/taskplane/execution.ts +403 -9
- package/extensions/taskplane/extension.ts +322 -28
- package/extensions/taskplane/lane-runner.ts +567 -0
- package/extensions/taskplane/mailbox.ts +349 -1
- package/extensions/taskplane/merge.ts +208 -51
- package/extensions/taskplane/process-registry.ts +345 -0
- package/extensions/taskplane/resume.ts +185 -47
- package/extensions/taskplane/supervisor.ts +16 -12
- package/extensions/taskplane/task-executor-core.ts +553 -0
- package/extensions/taskplane/types.ts +517 -1
- package/package.json +1 -1
- package/skills/create-taskplane-task/SKILL.md +41 -33
- package/skills/create-taskplane-task/references/prompt-template.md +3 -3
package/dashboard/server.cjs
CHANGED
|
@@ -352,6 +352,167 @@ function tailJsonlFile(filePath) {
|
|
|
352
352
|
* @param {object|null} batchState - The batch state from batch-state.json
|
|
353
353
|
* @returns {object} Map of tmuxPrefix → accumulated telemetry
|
|
354
354
|
*/
|
|
355
|
+
|
|
356
|
+
// ── Runtime V2 Data Loaders (TP-107) ─────────────────────────────
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Load the Runtime V2 process registry for the current batch.
|
|
360
|
+
* Returns null if no registry exists (legacy batch).
|
|
361
|
+
*/
|
|
362
|
+
function loadRuntimeRegistry(batchId) {
|
|
363
|
+
if (!batchId) return null;
|
|
364
|
+
const registryPath = path.join(REPO_ROOT, ".pi", "runtime", batchId, "registry.json");
|
|
365
|
+
try {
|
|
366
|
+
if (!fs.existsSync(registryPath)) return null;
|
|
367
|
+
return JSON.parse(fs.readFileSync(registryPath, "utf-8"));
|
|
368
|
+
} catch {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Load Runtime V2 lane snapshots for the current batch.
|
|
375
|
+
* Returns a map of laneNumber → snapshot data.
|
|
376
|
+
*/
|
|
377
|
+
function loadRuntimeLaneSnapshots(batchId) {
|
|
378
|
+
if (!batchId) return {};
|
|
379
|
+
const lanesDir = path.join(REPO_ROOT, ".pi", "runtime", batchId, "lanes");
|
|
380
|
+
const snapshots = {};
|
|
381
|
+
try {
|
|
382
|
+
if (!fs.existsSync(lanesDir)) return snapshots;
|
|
383
|
+
const files = fs.readdirSync(lanesDir).filter(f => f.startsWith("lane-") && f.endsWith(".json"));
|
|
384
|
+
for (const file of files) {
|
|
385
|
+
try {
|
|
386
|
+
const data = JSON.parse(fs.readFileSync(path.join(lanesDir, file), "utf-8"));
|
|
387
|
+
if (data.laneNumber != null) snapshots[data.laneNumber] = data;
|
|
388
|
+
} catch { continue; }
|
|
389
|
+
}
|
|
390
|
+
} catch { /* dir missing */ }
|
|
391
|
+
return snapshots;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Load Runtime V2 agent events for a specific agent.
|
|
396
|
+
* Returns the last N events from the agent's events.jsonl.
|
|
397
|
+
*/
|
|
398
|
+
function loadRuntimeAgentEvents(batchId, agentId, maxEvents) {
|
|
399
|
+
if (!batchId || !agentId) return [];
|
|
400
|
+
maxEvents = maxEvents || 200;
|
|
401
|
+
const eventsPath = path.join(REPO_ROOT, ".pi", "runtime", batchId, "agents", agentId, "events.jsonl");
|
|
402
|
+
try {
|
|
403
|
+
if (!fs.existsSync(eventsPath)) return [];
|
|
404
|
+
const raw = fs.readFileSync(eventsPath, "utf-8");
|
|
405
|
+
const lines = raw.split("\n").filter(l => l.trim());
|
|
406
|
+
const events = [];
|
|
407
|
+
const start = Math.max(0, lines.length - maxEvents);
|
|
408
|
+
for (let i = start; i < lines.length; i++) {
|
|
409
|
+
try { events.push(JSON.parse(lines[i])); } catch { continue; }
|
|
410
|
+
}
|
|
411
|
+
return events;
|
|
412
|
+
} catch {
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Load mailbox message activity for the current batch.
|
|
419
|
+
*
|
|
420
|
+
* TP-093 hardening: event-authoritative model.
|
|
421
|
+
* Primary source: .pi/mailbox/{batchId}/events.jsonl (audit event stream).
|
|
422
|
+
* Fallback: directory scans (inbox/ack/outbox/outbox/processed) for
|
|
423
|
+
* compatibility when events.jsonl is absent.
|
|
424
|
+
*
|
|
425
|
+
* Includes:
|
|
426
|
+
* - Consumed replies (outbox/processed/) so they don't disappear after ack
|
|
427
|
+
* - Per-recipient broadcast delivery state from ack markers
|
|
428
|
+
* - Rate-limited events in the timeline
|
|
429
|
+
*/
|
|
430
|
+
function loadMailboxData(batchId) {
|
|
431
|
+
if (!batchId) return { messages: [], agentIds: [], auditEvents: [] };
|
|
432
|
+
const mbRoot = path.join(REPO_ROOT, ".pi", "mailbox", batchId);
|
|
433
|
+
if (!fs.existsSync(mbRoot)) return { messages: [], agentIds: [], auditEvents: [] };
|
|
434
|
+
|
|
435
|
+
// ── Primary: events.jsonl audit trail ──
|
|
436
|
+
const auditEvents = loadMailboxAuditEvents(mbRoot);
|
|
437
|
+
|
|
438
|
+
// ── Fallback: directory scan ──
|
|
439
|
+
const messages = [];
|
|
440
|
+
const agentIds = [];
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
const dirs = fs.readdirSync(mbRoot, { withFileTypes: true })
|
|
444
|
+
.filter(d => d.isDirectory())
|
|
445
|
+
.map(d => d.name);
|
|
446
|
+
|
|
447
|
+
for (const agentDir of dirs) {
|
|
448
|
+
if (agentDir === "_broadcast") continue;
|
|
449
|
+
agentIds.push(agentDir);
|
|
450
|
+
|
|
451
|
+
// Scan inbox (pending), ack (delivered), outbox (active replies), outbox/processed (consumed replies)
|
|
452
|
+
for (const subdir of ["inbox", "ack", "outbox", "outbox/processed"]) {
|
|
453
|
+
const dir = path.join(mbRoot, agentDir, subdir);
|
|
454
|
+
if (!fs.existsSync(dir)) continue;
|
|
455
|
+
try {
|
|
456
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".msg.json"));
|
|
457
|
+
for (const file of files) {
|
|
458
|
+
try {
|
|
459
|
+
const msg = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
460
|
+
let status;
|
|
461
|
+
if (subdir === "inbox") status = "pending";
|
|
462
|
+
else if (subdir === "ack") status = "delivered";
|
|
463
|
+
else if (subdir === "outbox") status = "reply";
|
|
464
|
+
else status = "reply-acked"; // outbox/processed
|
|
465
|
+
const isBroadcast = msg.to === "_broadcast";
|
|
466
|
+
messages.push({ ...msg, _status: status, _agentDir: agentDir, _isBroadcast: isBroadcast });
|
|
467
|
+
} catch { continue; }
|
|
468
|
+
}
|
|
469
|
+
} catch { continue; }
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// _broadcast: per-recipient delivery state
|
|
474
|
+
const broadcastInbox = path.join(mbRoot, "_broadcast", "inbox");
|
|
475
|
+
const broadcastAck = path.join(mbRoot, "_broadcast", "ack");
|
|
476
|
+
for (const [dir, status] of [[broadcastInbox, "pending"], [broadcastAck, "delivered"]]) {
|
|
477
|
+
if (!fs.existsSync(dir)) continue;
|
|
478
|
+
try {
|
|
479
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".msg.json"));
|
|
480
|
+
for (const file of files) {
|
|
481
|
+
try {
|
|
482
|
+
const msg = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
483
|
+
messages.push({ ...msg, _status: status, _agentDir: "_broadcast", _isBroadcast: true });
|
|
484
|
+
} catch { continue; }
|
|
485
|
+
}
|
|
486
|
+
} catch { continue; }
|
|
487
|
+
}
|
|
488
|
+
} catch { /* mailbox dir issues */ }
|
|
489
|
+
|
|
490
|
+
messages.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
|
491
|
+
return { messages, agentIds, auditEvents };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Load mailbox audit events from events.jsonl.
|
|
496
|
+
* Returns events sorted by timestamp. Includes: message_sent, message_delivered,
|
|
497
|
+
* message_replied, message_escalated, message_rate_limited.
|
|
498
|
+
*/
|
|
499
|
+
function loadMailboxAuditEvents(mbRoot) {
|
|
500
|
+
const eventsPath = path.join(mbRoot, "events.jsonl");
|
|
501
|
+
if (!fs.existsSync(eventsPath)) return [];
|
|
502
|
+
try {
|
|
503
|
+
const raw = fs.readFileSync(eventsPath, "utf-8");
|
|
504
|
+
const events = [];
|
|
505
|
+
for (const line of raw.split("\n")) {
|
|
506
|
+
if (!line.trim()) continue;
|
|
507
|
+
try { events.push(JSON.parse(line)); } catch { continue; }
|
|
508
|
+
}
|
|
509
|
+
return events;
|
|
510
|
+
} catch {
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
355
516
|
function loadTelemetryData(batchState) {
|
|
356
517
|
const telemetryDir = path.join(REPO_ROOT, ".pi", "telemetry");
|
|
357
518
|
const result = {};
|
|
@@ -849,11 +1010,20 @@ function buildDashboardState() {
|
|
|
849
1010
|
return { ...task, statusData };
|
|
850
1011
|
});
|
|
851
1012
|
|
|
1013
|
+
// TP-107: Load Runtime V2 data when available
|
|
1014
|
+
const runtimeRegistry = loadRuntimeRegistry(state.batchId);
|
|
1015
|
+
const runtimeLaneSnapshots = loadRuntimeLaneSnapshots(state.batchId);
|
|
1016
|
+
const mailboxData = loadMailboxData(state.batchId);
|
|
1017
|
+
|
|
852
1018
|
return {
|
|
853
1019
|
laneStates,
|
|
854
1020
|
telemetry,
|
|
855
1021
|
batchTotalCost,
|
|
856
1022
|
supervisor,
|
|
1023
|
+
// Runtime V2 data (null/empty for legacy batches)
|
|
1024
|
+
runtimeRegistry,
|
|
1025
|
+
runtimeLaneSnapshots,
|
|
1026
|
+
mailbox: mailboxData,
|
|
857
1027
|
batch: {
|
|
858
1028
|
batchId: state.batchId,
|
|
859
1029
|
phase: state.phase,
|
|
@@ -1220,6 +1390,35 @@ function createServer() {
|
|
|
1220
1390
|
} else if (pathname.startsWith("/api/conversation/") && req.method === "GET") {
|
|
1221
1391
|
const prefix = pathname.slice("/api/conversation/".length);
|
|
1222
1392
|
serveConversation(req, res, prefix);
|
|
1393
|
+
} else if (pathname.startsWith("/api/agent-events/") && req.method === "GET") {
|
|
1394
|
+
// TP-107: Serve Runtime V2 agent events (hardened)
|
|
1395
|
+
const agentId = decodeURIComponent(pathname.slice("/api/agent-events/".length));
|
|
1396
|
+
// Strict validation: same pattern as /api/conversation/:prefix
|
|
1397
|
+
if (!/^[\w-]+$/.test(agentId)) {
|
|
1398
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
1399
|
+
res.end("Invalid agent ID");
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
const batchState = loadBatchState();
|
|
1403
|
+
// Path containment: verify resolved path stays inside runtime dir
|
|
1404
|
+
if (batchState?.batchId) {
|
|
1405
|
+
const runtimeBase = path.join(REPO_ROOT, ".pi", "runtime", batchState.batchId, "agents");
|
|
1406
|
+
const resolvedAgent = path.resolve(runtimeBase, agentId);
|
|
1407
|
+
if (!resolvedAgent.startsWith(path.resolve(runtimeBase))) {
|
|
1408
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
1409
|
+
res.end("Forbidden");
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
// Optional: ?sinceTs= to return only events after a timestamp
|
|
1414
|
+
const reqUrl = new URL(req.url, "http://localhost");
|
|
1415
|
+
const sinceTs = parseInt(reqUrl.searchParams.get("sinceTs") || "0", 10);
|
|
1416
|
+
let events = loadRuntimeAgentEvents(batchState?.batchId, agentId, 300);
|
|
1417
|
+
if (sinceTs > 0) {
|
|
1418
|
+
events = events.filter(e => (e.ts || 0) > sinceTs);
|
|
1419
|
+
}
|
|
1420
|
+
res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
|
|
1421
|
+
res.end(JSON.stringify(events));
|
|
1223
1422
|
} else if (pathname === "/api/state" && req.method === "GET") {
|
|
1224
1423
|
const state = buildDashboardState();
|
|
1225
1424
|
res.writeHead(200, {
|
|
@@ -38,6 +38,28 @@ import {
|
|
|
38
38
|
} from "./taskplane/types.ts";
|
|
39
39
|
import { classifyExit } from "./taskplane/diagnostics.ts";
|
|
40
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";
|
|
41
63
|
import {
|
|
42
64
|
generateQualityGatePrompt,
|
|
43
65
|
generateFeedbackMd,
|
|
@@ -782,194 +804,38 @@ function loadAgentDef(cwd: string, name: string): { systemPrompt: string; tools:
|
|
|
782
804
|
// ── PROMPT.md Parser ─────────────────────────────────────────────────
|
|
783
805
|
|
|
784
806
|
function parsePromptMd(content: string, promptPath: string): ParsedTask {
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
// Task ID and name
|
|
789
|
-
let taskId = "", taskName = "";
|
|
790
|
-
const titleMatch = text.match(/^#\s+(?:Task:\s*)?(\S+-\d+)\s*[-–:]\s*(.+)/m);
|
|
791
|
-
if (titleMatch) { taskId = titleMatch[1]; taskName = titleMatch[2].trim(); }
|
|
792
|
-
else { taskId = basename(taskFolder); taskName = taskId; }
|
|
793
|
-
|
|
794
|
-
// Review level
|
|
795
|
-
let reviewLevel = 0;
|
|
796
|
-
const rlMatch = text.match(/##\s+Review Level[:\s]*(\d)/);
|
|
797
|
-
if (rlMatch) reviewLevel = parseInt(rlMatch[1]);
|
|
798
|
-
|
|
799
|
-
// Size
|
|
800
|
-
let size = "M";
|
|
801
|
-
const sizeMatch = text.match(/\*\*Size:\*\*\s*(\w+)/);
|
|
802
|
-
if (sizeMatch) size = sizeMatch[1];
|
|
803
|
-
|
|
804
|
-
// Steps
|
|
805
|
-
const steps: StepInfo[] = [];
|
|
806
|
-
const stepRegex = /###\s+Step\s+(\d+):\s*(.+)/g;
|
|
807
|
-
const positions: { number: number; name: string; start: number }[] = [];
|
|
808
|
-
let m;
|
|
809
|
-
while ((m = stepRegex.exec(text)) !== null) {
|
|
810
|
-
positions.push({ number: parseInt(m[1]), name: m[2].trim(), start: m.index });
|
|
811
|
-
}
|
|
812
|
-
for (let i = 0; i < positions.length; i++) {
|
|
813
|
-
const section = text.slice(positions[i].start, i + 1 < positions.length ? positions[i + 1].start : text.length);
|
|
814
|
-
const checkboxes: { text: string; checked: boolean }[] = [];
|
|
815
|
-
const cbRegex = /^\s*-\s*\[([ xX])\]\s*(.*)/gm;
|
|
816
|
-
let cb;
|
|
817
|
-
while ((cb = cbRegex.exec(section)) !== null) {
|
|
818
|
-
checkboxes.push({ text: cb[2].trim(), checked: cb[1].toLowerCase() === "x" });
|
|
819
|
-
}
|
|
820
|
-
steps.push({
|
|
821
|
-
number: positions[i].number, name: positions[i].name,
|
|
822
|
-
status: "not-started", checkboxes,
|
|
823
|
-
totalChecked: checkboxes.filter(c => c.checked).length,
|
|
824
|
-
totalItems: checkboxes.length,
|
|
825
|
-
});
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// Context docs
|
|
829
|
-
const contextDocs: string[] = [];
|
|
830
|
-
const ctxMatch = text.match(/##\s+Context to Read First\s*\n+([\s\S]*?)(?=\n##\s|$)/);
|
|
831
|
-
if (ctxMatch) {
|
|
832
|
-
const pathRegex = /`([^\s`]+\.(?:md|yaml|json|go|ts|js))`/g;
|
|
833
|
-
let pm;
|
|
834
|
-
while ((pm = pathRegex.exec(ctxMatch[1])) !== null) contextDocs.push(pm[1]);
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
return { taskId, taskName, reviewLevel, size, steps, contextDocs, taskFolder, promptPath };
|
|
807
|
+
const core = coreParsePromptMd(content, promptPath);
|
|
808
|
+
return { ...core };
|
|
838
809
|
}
|
|
839
810
|
|
|
840
811
|
// ── STATUS.md Parser ─────────────────────────────────────────────────
|
|
841
812
|
|
|
842
813
|
function parseStatusMd(content: string): { steps: StepInfo[]; reviewCounter: number; iteration: number } {
|
|
843
|
-
|
|
844
|
-
const steps: StepInfo[] = [];
|
|
845
|
-
let currentStep: StepInfo | null = null;
|
|
846
|
-
let reviewCounter = 0, iteration = 0;
|
|
847
|
-
|
|
848
|
-
for (const line of text.split("\n")) {
|
|
849
|
-
const rcMatch = line.match(/\*\*Review Counter:\*\*\s*(\d+)/);
|
|
850
|
-
if (rcMatch) reviewCounter = parseInt(rcMatch[1]);
|
|
851
|
-
const itMatch = line.match(/\*\*Iteration:\*\*\s*(\d+)/);
|
|
852
|
-
if (itMatch) iteration = parseInt(itMatch[1]);
|
|
853
|
-
|
|
854
|
-
const stepMatch = line.match(/^###\s+Step\s+(\d+):\s*(.+)/);
|
|
855
|
-
if (stepMatch) {
|
|
856
|
-
if (currentStep) {
|
|
857
|
-
currentStep.totalChecked = currentStep.checkboxes.filter(c => c.checked).length;
|
|
858
|
-
currentStep.totalItems = currentStep.checkboxes.length;
|
|
859
|
-
steps.push(currentStep);
|
|
860
|
-
}
|
|
861
|
-
currentStep = { number: parseInt(stepMatch[1]), name: stepMatch[2].trim(), status: "not-started", checkboxes: [], totalChecked: 0, totalItems: 0 };
|
|
862
|
-
continue;
|
|
863
|
-
}
|
|
864
|
-
if (currentStep) {
|
|
865
|
-
const ss = line.match(/\*\*Status:\*\*\s*(.*)/);
|
|
866
|
-
if (ss) {
|
|
867
|
-
const s = ss[1];
|
|
868
|
-
if (s.includes("✅") || s.toLowerCase().includes("complete")) currentStep.status = "complete";
|
|
869
|
-
else if (s.includes("🟨") || s.toLowerCase().includes("progress")) currentStep.status = "in-progress";
|
|
870
|
-
}
|
|
871
|
-
const cb = line.match(/^\s*-\s*\[([ xX])\]\s*(.*)/);
|
|
872
|
-
if (cb) currentStep.checkboxes.push({ text: cb[2].trim(), checked: cb[1].toLowerCase() === "x" });
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
if (currentStep) {
|
|
876
|
-
currentStep.totalChecked = currentStep.checkboxes.filter(c => c.checked).length;
|
|
877
|
-
currentStep.totalItems = currentStep.checkboxes.length;
|
|
878
|
-
steps.push(currentStep);
|
|
879
|
-
}
|
|
880
|
-
return { steps, reviewCounter, iteration };
|
|
814
|
+
return coreParseStatusMd(content);
|
|
881
815
|
}
|
|
882
816
|
|
|
883
817
|
// ── STATUS.md Generator ──────────────────────────────────────────────
|
|
884
818
|
|
|
885
819
|
function generateStatusMd(task: ParsedTask): string {
|
|
886
|
-
|
|
887
|
-
const lines: string[] = [
|
|
888
|
-
`# ${task.taskId}: ${task.taskName} — Status`, "",
|
|
889
|
-
`**Current Step:** Not Started`,
|
|
890
|
-
`**Status:** 🔵 Ready for Execution`,
|
|
891
|
-
`**Last Updated:** ${now}`,
|
|
892
|
-
`**Review Level:** ${task.reviewLevel}`,
|
|
893
|
-
`**Review Counter:** 0`,
|
|
894
|
-
`**Iteration:** 0`,
|
|
895
|
-
`**Size:** ${task.size}`, "", "---", "",
|
|
896
|
-
];
|
|
897
|
-
for (const step of task.steps) {
|
|
898
|
-
lines.push(`### Step ${step.number}: ${step.name}`, `**Status:** ⬜ Not Started`, "");
|
|
899
|
-
for (const cb of step.checkboxes) lines.push(`- [ ] ${cb.text}`);
|
|
900
|
-
lines.push("", "---", "");
|
|
901
|
-
}
|
|
902
|
-
lines.push(
|
|
903
|
-
"## Reviews", "", "| # | Type | Step | Verdict | File |", "|---|------|------|---------|------|", "", "---", "",
|
|
904
|
-
"## Discoveries", "", "| Discovery | Disposition | Location |", "|-----------|-------------|----------|", "", "---", "",
|
|
905
|
-
"## Execution Log", "", "| Timestamp | Action | Outcome |", "|-----------|--------|---------|",
|
|
906
|
-
`| ${now} | Task staged | STATUS.md auto-generated by task-runner |`, "", "---", "",
|
|
907
|
-
"## Blockers", "", "*None*", "", "---", "", "## Notes", "", "*Reserved for execution notes*",
|
|
908
|
-
);
|
|
909
|
-
return lines.join("\n");
|
|
820
|
+
return coreGenerateStatusMd(task);
|
|
910
821
|
}
|
|
911
822
|
|
|
912
823
|
// ── STATUS.md Updaters ───────────────────────────────────────────────
|
|
913
824
|
|
|
914
825
|
function updateStatusField(statusPath: string, field: string, value: string): void {
|
|
915
|
-
|
|
916
|
-
const pattern = new RegExp(`(\\*\\*${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\*\\*\\s*)(.+)`);
|
|
917
|
-
if (pattern.test(content)) {
|
|
918
|
-
content = content.replace(pattern, `$1${value}`);
|
|
919
|
-
} else {
|
|
920
|
-
// Append after last ** field
|
|
921
|
-
content = content.replace(/(\*\*[^*]+:\*\*\s*.+\n)/, `$1**${field}:** ${value}\n`);
|
|
922
|
-
}
|
|
923
|
-
writeFileSync(statusPath, content);
|
|
826
|
+
coreUpdateStatusField(statusPath, field, value);
|
|
924
827
|
}
|
|
925
828
|
|
|
926
829
|
function updateStepStatus(statusPath: string, stepNum: number, status: "not-started" | "in-progress" | "complete"): void {
|
|
927
|
-
|
|
928
|
-
const emoji = status === "complete" ? "✅ Complete" : status === "in-progress" ? "🟨 In Progress" : "⬜ Not Started";
|
|
929
|
-
const lines = content.split("\n");
|
|
930
|
-
let inTarget = false;
|
|
931
|
-
for (let i = 0; i < lines.length; i++) {
|
|
932
|
-
const sm = lines[i].match(/^###\s+Step\s+(\d+):/);
|
|
933
|
-
if (sm) inTarget = parseInt(sm[1]) === stepNum;
|
|
934
|
-
if (inTarget && lines[i].match(/^\*\*Status:\*\*/)) {
|
|
935
|
-
lines[i] = `**Status:** ${emoji}`;
|
|
936
|
-
break;
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
writeFileSync(statusPath, lines.join("\n"));
|
|
830
|
+
coreUpdateStepStatus(statusPath, stepNum, status);
|
|
940
831
|
}
|
|
941
832
|
|
|
942
833
|
function appendTableRow(statusPath: string, sectionName: string, row: string): void {
|
|
943
|
-
|
|
944
|
-
const lines = content.split("\n");
|
|
945
|
-
let insertIdx = -1, inSection = false, lastTableRow = -1;
|
|
946
|
-
for (let i = 0; i < lines.length; i++) {
|
|
947
|
-
if (lines[i].match(new RegExp(`^##\\s+${sectionName}`))) {
|
|
948
|
-
inSection = true;
|
|
949
|
-
continue;
|
|
950
|
-
}
|
|
951
|
-
if (inSection) {
|
|
952
|
-
// End of section — hit another ## heading or ---
|
|
953
|
-
if (lines[i].match(/^##\s/) || lines[i].trim() === "---") {
|
|
954
|
-
insertIdx = lastTableRow >= 0 ? lastTableRow + 1 : i;
|
|
955
|
-
break;
|
|
956
|
-
}
|
|
957
|
-
// Track last table data row (skip header separator |---|)
|
|
958
|
-
if (lines[i].startsWith("|") && !lines[i].match(/^\|[\s-|]+\|$/)) {
|
|
959
|
-
lastTableRow = i;
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
if (insertIdx === -1) {
|
|
964
|
-
insertIdx = lastTableRow >= 0 ? lastTableRow + 1 : lines.length;
|
|
965
|
-
}
|
|
966
|
-
lines.splice(insertIdx, 0, row);
|
|
967
|
-
writeFileSync(statusPath, lines.join("\n"));
|
|
834
|
+
coreAppendTableRow(statusPath, sectionName, row);
|
|
968
835
|
}
|
|
969
836
|
|
|
970
837
|
function logExecution(statusPath: string, action: string, outcome: string): void {
|
|
971
|
-
|
|
972
|
-
appendTableRow(statusPath, "Execution Log", `| ${ts} | ${action} | ${outcome} |`);
|
|
838
|
+
coreLogExecution(statusPath, action, outcome);
|
|
973
839
|
}
|
|
974
840
|
|
|
975
841
|
/**
|
|
@@ -977,13 +843,11 @@ function logExecution(statusPath: string, action: string, outcome: string): void
|
|
|
977
843
|
* Collapses newlines to " / ", escapes pipe characters, and truncates to 200 chars.
|
|
978
844
|
*/
|
|
979
845
|
function sanitizeSteeringContent(content: string): string {
|
|
980
|
-
|
|
981
|
-
if (s.length > 200) s = s.slice(0, 197) + "...";
|
|
982
|
-
return s;
|
|
846
|
+
return coreSanitizeSteeringContent(content);
|
|
983
847
|
}
|
|
984
848
|
|
|
985
849
|
function logReview(statusPath: string, num: string, type: string, stepNum: number, verdict: string, file: string): void {
|
|
986
|
-
|
|
850
|
+
coreLogReview(statusPath, num, type, stepNum, verdict, file);
|
|
987
851
|
}
|
|
988
852
|
|
|
989
853
|
// ── Project Context Builder ──────────────────────────────────────────
|
|
@@ -1020,15 +884,7 @@ function buildProjectContext(config: TaskConfig, taskFolder: string): string {
|
|
|
1020
884
|
* can diff against the correct range instead of just uncommitted changes.
|
|
1021
885
|
*/
|
|
1022
886
|
function getHeadCommitSha(): string {
|
|
1023
|
-
|
|
1024
|
-
const result = spawnSync("git", ["rev-parse", "--short", "HEAD"], {
|
|
1025
|
-
encoding: "utf-8",
|
|
1026
|
-
timeout: 5000,
|
|
1027
|
-
});
|
|
1028
|
-
return result.status === 0 ? (result.stdout || "").trim() : "";
|
|
1029
|
-
} catch {
|
|
1030
|
-
return "";
|
|
1031
|
-
}
|
|
887
|
+
return coreGetHeadCommitSha();
|
|
1032
888
|
}
|
|
1033
889
|
|
|
1034
890
|
/**
|
|
@@ -1038,18 +894,7 @@ function getHeadCommitSha(): string {
|
|
|
1038
894
|
* Returns the commit SHA if found, or empty string.
|
|
1039
895
|
*/
|
|
1040
896
|
function findStepBoundaryCommit(stepNumber: number, taskId: string, since?: string): string {
|
|
1041
|
-
|
|
1042
|
-
// Search git log for the step completion commit
|
|
1043
|
-
const args = ["log", "--oneline", "--grep", `complete Step ${stepNumber}`, "--grep", taskId, "--all-match", "-1", "--format=%H"];
|
|
1044
|
-
if (since) args.push(`${since}..HEAD`);
|
|
1045
|
-
const result = spawnSync("git", args, {
|
|
1046
|
-
encoding: "utf-8",
|
|
1047
|
-
timeout: 5000,
|
|
1048
|
-
});
|
|
1049
|
-
return result.status === 0 ? (result.stdout || "").trim() : "";
|
|
1050
|
-
} catch {
|
|
1051
|
-
return "";
|
|
1052
|
-
}
|
|
897
|
+
return coreFindStepBoundaryCommit(stepNumber, taskId, since);
|
|
1053
898
|
}
|
|
1054
899
|
|
|
1055
900
|
// ── Standards Resolution ─────────────────────────────────────────────
|
|
@@ -1065,24 +910,7 @@ function findStepBoundaryCommit(stepNumber: number, taskId: string, since?: stri
|
|
|
1065
910
|
* different review standards than Go backend service tasks.
|
|
1066
911
|
*/
|
|
1067
912
|
function resolveStandards(config: TaskConfig, taskFolder: string): { docs: string[]; rules: string[] } {
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
// Find which area this task belongs to
|
|
1071
|
-
for (const [areaName, areaCfg] of Object.entries(config.task_areas)) {
|
|
1072
|
-
const areaPath = areaCfg.path.replace(/\\/g, "/");
|
|
1073
|
-
if (normalizedFolder.includes(areaPath)) {
|
|
1074
|
-
const override = config.standards_overrides[areaName];
|
|
1075
|
-
if (override) {
|
|
1076
|
-
return {
|
|
1077
|
-
docs: override.docs ?? config.standards.docs,
|
|
1078
|
-
rules: override.rules ?? config.standards.rules,
|
|
1079
|
-
};
|
|
1080
|
-
}
|
|
1081
|
-
break; // Area found but no override — use global
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
return { docs: config.standards.docs, rules: config.standards.rules };
|
|
913
|
+
return coreResolveStandards(config.standards, config.standards_overrides, config.task_areas, taskFolder);
|
|
1086
914
|
}
|
|
1087
915
|
|
|
1088
916
|
// ── Review Request Generator ─────────────────────────────────────────
|
|
@@ -1092,84 +920,12 @@ function generateReviewRequest(
|
|
|
1092
920
|
task: ParsedTask, config: TaskConfig, outputPath: string,
|
|
1093
921
|
stepBaselineCommit?: string,
|
|
1094
922
|
): string {
|
|
1095
|
-
const
|
|
1096
|
-
|
|
1097
|
-
const standardsRules = resolved.rules.map(r => `- ${r}`).join("\n");
|
|
1098
|
-
|
|
1099
|
-
if (type === "plan") {
|
|
1100
|
-
return [
|
|
1101
|
-
`# Review Request: Plan Review`, "",
|
|
1102
|
-
`You are reviewing an implementation plan for a ${config.project.name} task.`,
|
|
1103
|
-
`You have full tool access — use \`read\` to examine files and \`bash\` to run commands.`, "",
|
|
1104
|
-
`## Task Context`, "",
|
|
1105
|
-
`- **Task PROMPT:** ${task.promptPath}`,
|
|
1106
|
-
`- **Task STATUS:** ${join(task.taskFolder, "STATUS.md")}`,
|
|
1107
|
-
`- **Step being planned:** Step ${stepNum}: ${stepName}`, "",
|
|
1108
|
-
`## Instructions`, "",
|
|
1109
|
-
`1. Read the PROMPT.md for full requirements`,
|
|
1110
|
-
`2. Read STATUS.md for progress so far`,
|
|
1111
|
-
`3. Check relevant source files for existing patterns:`,
|
|
1112
|
-
standardsDocs, "",
|
|
1113
|
-
`## Project Standards`, "", standardsRules, "",
|
|
1114
|
-
`## Output`, "",
|
|
1115
|
-
`Write your review to: \`${outputPath}\``,
|
|
1116
|
-
].join("\n");
|
|
1117
|
-
} else {
|
|
1118
|
-
// For code reviews, provide the baseline commit so the reviewer can
|
|
1119
|
-
// diff the full step's changes — not just uncommitted changes.
|
|
1120
|
-
// Workers commit via checkpoints, so `git diff` alone sees nothing.
|
|
1121
|
-
const diffCmd = stepBaselineCommit
|
|
1122
|
-
? `git diff ${stepBaselineCommit}..HEAD --name-only`
|
|
1123
|
-
: `git diff --name-only`;
|
|
1124
|
-
const diffFullCmd = stepBaselineCommit
|
|
1125
|
-
? `git diff ${stepBaselineCommit}..HEAD`
|
|
1126
|
-
: `git diff`;
|
|
1127
|
-
|
|
1128
|
-
return [
|
|
1129
|
-
`# Review Request: Code Review`, "",
|
|
1130
|
-
`You are reviewing code changes for a ${config.project.name} task.`,
|
|
1131
|
-
`You have full tool access — use \`read\` to examine files and \`bash\` to run commands.`, "",
|
|
1132
|
-
`## Task Context`, "",
|
|
1133
|
-
`- **Task PROMPT:** ${task.promptPath}`,
|
|
1134
|
-
`- **Task STATUS:** ${join(task.taskFolder, "STATUS.md")}`,
|
|
1135
|
-
`- **Step reviewed:** Step ${stepNum}: ${stepName}`,
|
|
1136
|
-
...(stepBaselineCommit ? [`- **Step baseline commit:** ${stepBaselineCommit}`] : []),
|
|
1137
|
-
"",
|
|
1138
|
-
`## Instructions`, "",
|
|
1139
|
-
`1. Run \`${diffCmd}\` to see files changed in this step`,
|
|
1140
|
-
` Then \`${diffFullCmd}\` for the full diff`,
|
|
1141
|
-
` **Important:** The worker commits code via checkpoints, so plain \`git diff\` may show nothing.`,
|
|
1142
|
-
` Always use the baseline commit range above to see all step changes.`,
|
|
1143
|
-
`2. Read changed files in full for context`,
|
|
1144
|
-
`3. Check neighboring files for pattern consistency`,
|
|
1145
|
-
`4. Check standards:`,
|
|
1146
|
-
standardsDocs, "",
|
|
1147
|
-
`## Project Standards`, "", standardsRules, "",
|
|
1148
|
-
`## Output`, "",
|
|
1149
|
-
`Write your review to: \`${outputPath}\``,
|
|
1150
|
-
].join("\n");
|
|
1151
|
-
}
|
|
923
|
+
const standards = resolveStandards(config, task.taskFolder);
|
|
924
|
+
return coreGenerateReviewRequest(type, stepNum, stepName, task.promptPath, task.taskFolder, config.project.name, standards, outputPath, stepBaselineCommit);
|
|
1152
925
|
}
|
|
1153
926
|
|
|
1154
927
|
function extractVerdict(reviewContent: string): string {
|
|
1155
|
-
|
|
1156
|
-
const match = reviewContent.match(/###?\s*Verdict[:\s]*(APPROVE|REVISE|RETHINK)/i);
|
|
1157
|
-
if (match) return match[1].toUpperCase();
|
|
1158
|
-
|
|
1159
|
-
// TP-068: Tolerate non-standard verdict formats from models that don't
|
|
1160
|
-
// follow the exact template (e.g., "Changes requested", "Needs revision").
|
|
1161
|
-
const lower = reviewContent.toLowerCase();
|
|
1162
|
-
if (/\b(request\s+changes?|changes?\s+requested|needs?\s+revision|please\s+revise|must\s+revise)\b/.test(lower)) {
|
|
1163
|
-
return "REVISE";
|
|
1164
|
-
}
|
|
1165
|
-
if (/\b(looks?\s+good|no\s+issues?\s+found|approved?)\b/.test(lower)) {
|
|
1166
|
-
return "APPROVE";
|
|
1167
|
-
}
|
|
1168
|
-
if (/\b(fundamentally\s+wrong|rethink|reconsider\s+the\s+approach)\b/.test(lower)) {
|
|
1169
|
-
return "RETHINK";
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
return "UNKNOWN";
|
|
928
|
+
return coreExtractVerdict(reviewContent);
|
|
1173
929
|
}
|
|
1174
930
|
|
|
1175
931
|
/**
|
|
@@ -1756,9 +1512,7 @@ export type { BuildExitDiagnosticInput };
|
|
|
1756
1512
|
* @returns true if the step should skip plan and code reviews
|
|
1757
1513
|
*/
|
|
1758
1514
|
export function isLowRiskStep(stepNumber: number, totalSteps: number): boolean {
|
|
1759
|
-
|
|
1760
|
-
const lastStepIndex = totalSteps - 1;
|
|
1761
|
-
return stepNumber === 0 || stepNumber === lastStepIndex;
|
|
1515
|
+
return coreIsLowRiskStep(stepNumber, totalSteps);
|
|
1762
1516
|
}
|
|
1763
1517
|
|
|
1764
1518
|
// ── TMUX Agent Spawner ───────────────────────────────────────────────
|
|
@@ -2255,7 +2009,7 @@ export const _cleanupOrphanProcesses = cleanupOrphanProcesses;
|
|
|
2255
2009
|
// ── Display Helpers ──────────────────────────────────────────────────
|
|
2256
2010
|
|
|
2257
2011
|
function displayName(name: string): string {
|
|
2258
|
-
return name
|
|
2012
|
+
return coreDisplayName(name);
|
|
2259
2013
|
}
|
|
2260
2014
|
|
|
2261
2015
|
// ── Extension ────────────────────────────────────────────────────────
|
|
@@ -7,6 +7,7 @@ import { execSync } from "child_process";
|
|
|
7
7
|
import { join } from "path";
|
|
8
8
|
|
|
9
9
|
import { execLog, resolveCanonicalTaskPaths, tmuxHasSession, tmuxKillSession } from "./execution.ts";
|
|
10
|
+
import { killMergeAgentV2, killAllMergeAgentsV2 } from "./merge.ts";
|
|
10
11
|
import { deleteBatchState, parseOrchSessionNames, persistRuntimeState } from "./persistence.ts";
|
|
11
12
|
import type { AbortActionStep, AbortErrorCode, AbortLaneResult, AbortMode, AbortResult, AbortTargetSession, AllocatedLane, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord } from "./types.ts";
|
|
12
13
|
|
|
@@ -267,6 +268,8 @@ export function killOrchSessions(
|
|
|
267
268
|
// Best-effort child cleanup even if not explicitly targeted.
|
|
268
269
|
tmuxKillSession(`${name}-worker`);
|
|
269
270
|
tmuxKillSession(`${name}-reviewer`);
|
|
271
|
+
// TP-108: Also kill V2 merge agents (no-op if not V2)
|
|
272
|
+
killMergeAgentV2(name);
|
|
270
273
|
|
|
271
274
|
const killed = tmuxKillSession(name);
|
|
272
275
|
results.push({
|
|
@@ -332,7 +335,14 @@ export async function executeAbort(
|
|
|
332
335
|
execLog("abort", batchState.batchId, `Failed to persist state during abort: ${err instanceof Error ? err.message : String(err)}`);
|
|
333
336
|
}
|
|
334
337
|
|
|
335
|
-
//
|
|
338
|
+
// TP-108: Kill all V2 merge agents (process-owned, not TMUX)
|
|
339
|
+
// This catches V2 merge agents that have no TMUX session.
|
|
340
|
+
const v2MergeKilled = killAllMergeAgentsV2();
|
|
341
|
+
if (v2MergeKilled > 0) {
|
|
342
|
+
execLog("abort", batchState.batchId, `killed ${v2MergeKilled} V2 merge agent(s)`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Step 3: List all orch sessions (TMUX — legacy + fallback)
|
|
336
346
|
let allSessionNames: string[];
|
|
337
347
|
try {
|
|
338
348
|
allSessionNames = parseOrchSessionNames(
|