From c46a4ec48491fcedd24721fad57d3a6ff84d8f2c Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 16:28:39 -0500 Subject: [PATCH] fix: execute capture resolutions after triage instead of just classifying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures classified as inject, replan, or quick-task were marked "resolved" in CAPTURES.md but their resolution actions were never executed — tasks were never injected into plans, replan triggers were never written, and quick-tasks were never dispatched. This wires up the existing resolution executor functions that were defined but never called: - After triage-captures unit completes, executeTriageResolutions() reads actionable captures and executes their resolutions: - inject: calls executeInject() to add tasks to the slice plan - replan: calls executeReplan() to write REPLAN-TRIGGER.md - quick-task: queues for dispatch as a new unit type - Quick-task dispatch block dispatches queued captures one at a time using buildQuickTaskPrompt(), with proper session/timeout handling - New markCaptureExecuted() and loadActionableCaptures() functions track execution state, preventing double-execution on retries - Quick-task unit type excluded from post-unit hooks (lightweight one-offs don't need hook chains) Closes #701 --- src/resources/extensions/gsd/auto.ts | 132 +++++++++++ src/resources/extensions/gsd/captures.ts | 49 +++++ .../extensions/gsd/post-unit-hooks.ts | 3 +- .../gsd/tests/triage-dispatch.test.ts | 120 ++++++++++ .../gsd/tests/triage-resolution.test.ts | 205 +++++++++++++++++- .../extensions/gsd/triage-resolution.ts | 83 +++++++ 6 files changed, 589 insertions(+), 3 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 4a5c16f1d..87ef155f4 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -297,6 +297,9 @@ let currentUnit: { type: string; id: string; startedAt: number } | null = null; /** Track dynamic routing decision for the current unit (for metrics) */ let currentUnitRouting: { tier: string; modelDowngraded: boolean } | null = null; +/** Queue of quick-task captures awaiting dispatch after triage resolution */ +let pendingQuickTasks: import("./captures.js").CaptureEntry[] = []; + /** * Model captured at auto-mode start. Used to prevent model bleed between * concurrent GSD instances sharing the same global settings.json (#650). @@ -629,6 +632,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi currentMilestoneId = null; originalBasePath = ""; completedUnits = []; + pendingQuickTasks = []; clearSliceProgressCache(); clearActivityLogState(); resetProactiveHealing(); @@ -998,6 +1002,7 @@ export async function startAuto( autoStartTime = Date.now(); resourceSyncedAtOnStart = readResourceSyncedAt(); completedUnits = []; + pendingQuickTasks = []; currentUnit = null; currentMilestoneId = state.activeMilestone?.id ?? null; originalModelId = ctx.model?.id ?? null; @@ -1297,6 +1302,53 @@ export async function handleAgentEnd( } } + // ── Post-triage: execute actionable resolutions (inject, replan, queue quick-tasks) ── + // After a triage-captures unit completes, the LLM has classified captures and + // updated CAPTURES.md. Now we execute those classifications: inject tasks into + // the plan, write replan triggers, and queue quick-tasks for dispatch. + if (currentUnit.type === "triage-captures") { + try { + const { executeTriageResolutions } = await import("./triage-resolution.js"); + const state = await deriveState(basePath); + const mid = state.activeMilestone?.id; + const sid = state.activeSlice?.id; + + if (mid && sid) { + const triageResult = executeTriageResolutions(basePath, mid, sid); + + if (triageResult.injected > 0) { + ctx.ui.notify( + `Triage: injected ${triageResult.injected} task${triageResult.injected === 1 ? "" : "s"} into ${sid} plan.`, + "info", + ); + } + if (triageResult.replanned > 0) { + ctx.ui.notify( + `Triage: replan trigger written for ${sid} — next dispatch will enter replanning.`, + "info", + ); + } + if (triageResult.quickTasks.length > 0) { + // Queue quick-tasks for dispatch. They'll be picked up by the + // quick-task dispatch block below the triage check. + for (const qt of triageResult.quickTasks) { + pendingQuickTasks.push(qt); + } + ctx.ui.notify( + `Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`, + "info", + ); + } + for (const action of triageResult.actions) { + process.stderr.write(`gsd-triage: ${action}\n`); + } + } + } catch (err) { + // Non-fatal — triage resolution failure shouldn't block dispatch + process.stderr.write(`gsd-triage: resolution execution failed: ${(err as Error).message}\n`); + } + } + // ── Path A fix: verify artifact and persist completion before re-entering dispatch ── // After doctor + rebuildState, check whether the just-completed unit actually // produced its expected artifact. If so, persist the completion key now so the @@ -1551,6 +1603,85 @@ export async function handleAgentEnd( } } + // ── Quick-task dispatch: execute queued quick-tasks from triage resolution ── + // Quick-tasks are self-contained one-off tasks that don't modify the plan. + // They're queued during post-triage resolution and dispatched here one at a time. + if ( + !stepMode && + pendingQuickTasks.length > 0 && + currentUnit && + currentUnit.type !== "quick-task" + ) { + try { + const capture = pendingQuickTasks.shift()!; + const { buildQuickTaskPrompt } = await import("./triage-resolution.js"); + const { markCaptureExecuted } = await import("./captures.js"); + const prompt = buildQuickTaskPrompt(capture); + + ctx.ui.notify( + `Executing quick-task: ${capture.id} — "${capture.text}"`, + "info", + ); + + // Close out previous unit metrics + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); + } + + // Dispatch quick-task as a new unit + const qtUnitType = "quick-task"; + const qtUnitId = `${currentMilestoneId}/${capture.id}`; + const qtStartedAt = Date.now(); + currentUnit = { type: qtUnitType, id: qtUnitId, startedAt: qtStartedAt }; + writeUnitRuntimeRecord(basePath, qtUnitType, qtUnitId, qtStartedAt, { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: qtStartedAt, + progressCount: 0, + lastProgressKind: "dispatch", + }); + const state = await deriveState(basePath); + updateProgressWidget(ctx, qtUnitType, qtUnitId, state); + + const result = await cmdCtx!.newSession(); + if (result.cancelled) { + await stopAuto(ctx, pi); + return; + } + const sessionFile = ctx.sessionManager.getSessionFile(); + writeLock(lockBase(), qtUnitType, qtUnitId, completedUnits.length, sessionFile); + + // Mark capture as executed now that the unit is dispatched + markCaptureExecuted(basePath, capture.id); + + // Start unit timeout for quick-task + clearUnitTimeout(); + const supervisor = resolveAutoSupervisorConfig(); + const qtTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; + unitTimeoutHandle = setTimeout(async () => { + unitTimeoutHandle = null; + if (!active) return; + ctx.ui.notify( + `Quick-task ${capture.id} exceeded timeout. Pausing auto-mode.`, + "warning", + ); + await pauseAuto(ctx, pi); + }, qtTimeoutMs); + + if (!active) return; + pi.sendMessage( + { customType: "gsd-auto", content: prompt, display: verbose }, + { triggerTurn: true }, + ); + return; // handleAgentEnd will fire again when quick-task session completes + } catch { + // Non-fatal — proceed to normal dispatch + } + } + // In step mode, pause and show a wizard instead of immediately dispatching if (stepMode) { await showStepWizard(ctx, pi); @@ -3168,6 +3299,7 @@ export async function dispatchHookUnit( autoStartTime = Date.now(); currentUnit = null; completedUnits = []; + pendingQuickTasks = []; } const hookUnitType = `hook/${hookName}`; diff --git a/src/resources/extensions/gsd/captures.ts b/src/resources/extensions/gsd/captures.ts index 1c49adce5..2c18a987c 100644 --- a/src/resources/extensions/gsd/captures.ts +++ b/src/resources/extensions/gsd/captures.ts @@ -26,6 +26,7 @@ export interface CaptureEntry { resolution?: string; rationale?: string; resolvedAt?: string; + executed?: boolean; } export interface TriageResult { @@ -211,6 +212,52 @@ export function markCaptureResolved( writeFileSync(filePath, updated, "utf-8"); } +/** + * Mark a resolved capture as executed — its resolution action was carried out. + * Appends `**Executed:** ` to the capture's section in CAPTURES.md. + */ +export function markCaptureExecuted(basePath: string, captureId: string): void { + const filePath = resolveCapturesPath(basePath); + if (!existsSync(filePath)) return; + + const content = readFileSync(filePath, "utf-8"); + const executedAt = new Date().toISOString(); + + const sectionRegex = new RegExp( + `(### ${escapeRegex(captureId)}\\n(?:(?!### ).)*?)(?=### |$)`, + "s", + ); + const match = sectionRegex.exec(content); + if (!match) return; + + let section = match[1]; + + // Remove any existing Executed field (in case of re-execution) + section = section.replace(/\*\*Executed:\*\*\s*.+\n?/g, ""); + + // Append Executed timestamp + section = section.trimEnd() + "\n" + `**Executed:** ${executedAt}` + "\n"; + + const updated = content.replace(sectionRegex, section); + writeFileSync(filePath, updated, "utf-8"); +} + +/** + * Load resolved captures that have actionable classifications (inject, replan, + * quick-task) but have NOT yet been executed. + * These are captures whose resolutions need to be carried out. + */ +export function loadActionableCaptures(basePath: string): CaptureEntry[] { + return loadAllCaptures(basePath).filter( + c => + c.status === "resolved" && + !c.executed && + (c.classification === "inject" || + c.classification === "replan" || + c.classification === "quick-task"), + ); +} + // ─── Parser ─────────────────────────────────────────────────────────────────── /** @@ -235,6 +282,7 @@ function parseCapturesContent(content: string): CaptureEntry[] { const resolution = extractBoldField(body, "Resolution"); const rationale = extractBoldField(body, "Rationale"); const resolvedAt = extractBoldField(body, "Resolved"); + const executedAt = extractBoldField(body, "Executed"); if (!text || !timestamp) continue; @@ -251,6 +299,7 @@ function parseCapturesContent(content: string): CaptureEntry[] { ...(resolution ? { resolution } : {}), ...(rationale ? { rationale } : {}), ...(resolvedAt ? { resolvedAt } : {}), + ...(executedAt ? { executed: true } : {}), }); } diff --git a/src/resources/extensions/gsd/post-unit-hooks.ts b/src/resources/extensions/gsd/post-unit-hooks.ts index dc6675341..649566259 100644 --- a/src/resources/extensions/gsd/post-unit-hooks.ts +++ b/src/resources/extensions/gsd/post-unit-hooks.ts @@ -60,7 +60,8 @@ export function checkPostUnitHooks( // Don't trigger hooks for other hook units (prevent hook-on-hook chains) // Don't trigger hooks for triage units (prevent hook-on-triage chains) - if (completedUnitType.startsWith("hook/") || completedUnitType === "triage-captures") return null; + // Don't trigger hooks for quick-task units (lightweight one-offs from captures) + if (completedUnitType.startsWith("hook/") || completedUnitType === "triage-captures" || completedUnitType === "quick-task") return null; // Check if any hooks are configured for this unit type const hooks = resolvePostUnitHooks().filter(h => diff --git a/src/resources/extensions/gsd/tests/triage-dispatch.test.ts b/src/resources/extensions/gsd/tests/triage-dispatch.test.ts index df8d05dc1..57db9f1a8 100644 --- a/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +++ b/src/resources/extensions/gsd/tests/triage-dispatch.test.ts @@ -222,3 +222,123 @@ test("dashboard: overlay labels triage-captures and quick-task unit types", () = "unitLabel should handle quick-task", ); }); + +// ─── Post-triage resolution execution ───────────────────────────────────────── + +test("dispatch: post-triage resolution executor fires after triage-captures unit", () => { + const triageCompletionBlock = autoSrc.slice( + autoSrc.indexOf("Post-triage: execute actionable resolutions"), + autoSrc.indexOf("Path A fix: verify artifact"), + ); + assert.ok( + triageCompletionBlock.includes('currentUnit.type === "triage-captures"'), + "should check for triage-captures unit completion", + ); + assert.ok( + triageCompletionBlock.includes("executeTriageResolutions"), + "should call executeTriageResolutions", + ); +}); + +test("dispatch: post-triage executor handles inject results", () => { + const triageCompletionBlock = autoSrc.slice( + autoSrc.indexOf("Post-triage: execute actionable resolutions"), + autoSrc.indexOf("Path A fix: verify artifact"), + ); + assert.ok( + triageCompletionBlock.includes("triageResult.injected"), + "should check injected count", + ); +}); + +test("dispatch: post-triage executor handles replan results", () => { + const triageCompletionBlock = autoSrc.slice( + autoSrc.indexOf("Post-triage: execute actionable resolutions"), + autoSrc.indexOf("Path A fix: verify artifact"), + ); + assert.ok( + triageCompletionBlock.includes("triageResult.replanned"), + "should check replanned count", + ); +}); + +test("dispatch: post-triage executor queues quick-tasks", () => { + const triageCompletionBlock = autoSrc.slice( + autoSrc.indexOf("Post-triage: execute actionable resolutions"), + autoSrc.indexOf("Path A fix: verify artifact"), + ); + assert.ok( + triageCompletionBlock.includes("pendingQuickTasks"), + "should push quick-tasks to pendingQuickTasks queue", + ); +}); + +// ─── Quick-task dispatch ────────────────────────────────────────────────────── + +test("dispatch: quick-task dispatch block exists after triage check", () => { + const quickTaskBlock = autoSrc.indexOf("Quick-task dispatch: execute queued quick-tasks"); + const triageBlock = autoSrc.indexOf("Triage check: dispatch triage unit"); + const stepModeBlock = autoSrc.indexOf("In step mode, pause and show a wizard"); + + assert.ok(quickTaskBlock > 0, "quick-task dispatch block should exist"); + assert.ok( + quickTaskBlock > triageBlock, + "quick-task dispatch should come after triage check", + ); + assert.ok( + quickTaskBlock < stepModeBlock, + "quick-task dispatch should come before step mode check", + ); +}); + +test("dispatch: quick-task dispatch uses buildQuickTaskPrompt", () => { + const quickTaskSection = autoSrc.slice( + autoSrc.indexOf("Quick-task dispatch: execute queued quick-tasks"), + autoSrc.indexOf("In step mode, pause and show a wizard"), + ); + assert.ok( + quickTaskSection.includes("buildQuickTaskPrompt"), + "should call buildQuickTaskPrompt for quick-task dispatch", + ); +}); + +test("dispatch: quick-task dispatch marks capture as executed", () => { + const quickTaskSection = autoSrc.slice( + autoSrc.indexOf("Quick-task dispatch: execute queued quick-tasks"), + autoSrc.indexOf("In step mode, pause and show a wizard"), + ); + assert.ok( + quickTaskSection.includes("markCaptureExecuted"), + "should mark capture as executed after dispatch", + ); +}); + +test("dispatch: quick-task dispatch uses early-return pattern", () => { + const quickTaskSection = autoSrc.slice( + autoSrc.indexOf("Quick-task dispatch: execute queued quick-tasks"), + autoSrc.indexOf("In step mode, pause and show a wizard"), + ); + assert.ok( + quickTaskSection.includes("return; // handleAgentEnd will fire again when quick-task session completes"), + "quick-task dispatch should return after sending message", + ); +}); + +// ─── Post-unit hook exclusion for quick-task ────────────────────────────────── + +test("dispatch: quick-task excluded from post-unit hook triggering", () => { + assert.ok( + hooksSrc.includes('"quick-task"'), + "post-unit-hooks.ts should reference quick-task", + ); +}); + +// ─── pendingQuickTasks queue lifecycle ──────────────────────────────────────── + +test("dispatch: pendingQuickTasks queue is reset on auto-mode start/stop", () => { + const resetMatches = autoSrc.match(/pendingQuickTasks = \[\]/g); + assert.ok( + resetMatches && resetMatches.length >= 3, + "pendingQuickTasks should be reset in at least 3 places (start, stop, manual hook)", + ); +}); diff --git a/src/resources/extensions/gsd/tests/triage-resolution.test.ts b/src/resources/extensions/gsd/tests/triage-resolution.test.ts index 7c62025c2..29cf26b8e 100644 --- a/src/resources/extensions/gsd/tests/triage-resolution.test.ts +++ b/src/resources/extensions/gsd/tests/triage-resolution.test.ts @@ -7,10 +7,10 @@ import assert from "node:assert/strict"; import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { appendCapture, markCaptureResolved, loadAllCaptures } from "../captures.ts"; +import { appendCapture, markCaptureResolved, markCaptureExecuted, loadAllCaptures, loadActionableCaptures } from "../captures.ts"; // Import only the functions that don't depend on @gsd/pi-coding-agent // (triage-ui.ts imports next-action-ui.ts which imports the unavailable package) -import { executeInject, executeReplan, detectFileOverlap, loadDeferredCaptures, loadReplanCaptures, buildQuickTaskPrompt } from "../triage-resolution.ts"; +import { executeInject, executeReplan, detectFileOverlap, loadDeferredCaptures, loadReplanCaptures, buildQuickTaskPrompt, executeTriageResolutions } from "../triage-resolution.ts"; function makeTempDir(prefix: string): string { const dir = join( @@ -213,3 +213,204 @@ test("resolution: buildQuickTaskPrompt includes capture text and ID", () => { assert.ok(prompt.includes("Quick Task"), "should have Quick Task header"); assert.ok(prompt.includes("Do NOT modify"), "should warn about plan files"); }); + +// ─── markCaptureExecuted ───────────────────────────────────────────────────── + +test("resolution: markCaptureExecuted adds Executed field to capture", () => { + const tmp = makeTempDir("res-executed"); + try { + const id = appendCapture(tmp, "fix the button"); + markCaptureResolved(tmp, id, "quick-task", "execute as quick-task", "small fix"); + + markCaptureExecuted(tmp, id); + + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 1); + assert.strictEqual(all[0].executed, true, "should be marked as executed"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("resolution: markCaptureExecuted is idempotent", () => { + const tmp = makeTempDir("res-executed-idem"); + try { + const id = appendCapture(tmp, "fix something"); + markCaptureResolved(tmp, id, "inject", "inject task", "needed"); + + markCaptureExecuted(tmp, id); + markCaptureExecuted(tmp, id); // call again — should not duplicate + + const filePath = join(tmp, ".gsd", "CAPTURES.md"); + const content = readFileSync(filePath, "utf-8"); + const executedMatches = content.match(/\*\*Executed:\*\*/g); + assert.strictEqual(executedMatches?.length, 1, "should have exactly one Executed field"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── loadActionableCaptures ────────────────────────────────────────────────── + +test("resolution: loadActionableCaptures returns only unexecuted actionable captures", () => { + const tmp = makeTempDir("res-actionable"); + try { + const id1 = appendCapture(tmp, "inject this task"); + const id2 = appendCapture(tmp, "quick fix"); + const id3 = appendCapture(tmp, "just a note"); + const id4 = appendCapture(tmp, "replan needed"); + const id5 = appendCapture(tmp, "already executed inject"); + + markCaptureResolved(tmp, id1, "inject", "add task", "needed"); + markCaptureResolved(tmp, id2, "quick-task", "quick fix", "small"); + markCaptureResolved(tmp, id3, "note", "acknowledged", "info"); + markCaptureResolved(tmp, id4, "replan", "replan triggered", "approach changed"); + markCaptureResolved(tmp, id5, "inject", "add task", "needed"); + markCaptureExecuted(tmp, id5); // mark as executed + + const actionable = loadActionableCaptures(tmp); + assert.strictEqual(actionable.length, 3, "should have 3 actionable captures"); + assert.deepStrictEqual( + actionable.map(c => c.id), + [id1, id2, id4], + "should include inject, quick-task, replan but not note or executed inject", + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── executeTriageResolutions ──────────────────────────────────────────────── + +test("resolution: executeTriageResolutions executes inject captures", () => { + const tmp = makeTempDir("res-exec-inject"); + try { + setupPlanFile(tmp, "M001", "S01", SAMPLE_PLAN); + const id1 = appendCapture(tmp, "add error handling"); + const id2 = appendCapture(tmp, "add retry logic"); + markCaptureResolved(tmp, id1, "inject", "add task", "needed"); + markCaptureResolved(tmp, id2, "inject", "add task", "also needed"); + + const result = executeTriageResolutions(tmp, "M001", "S01"); + + assert.strictEqual(result.injected, 2, "should inject 2 tasks"); + assert.strictEqual(result.replanned, 0); + assert.strictEqual(result.quickTasks.length, 0); + + // Verify tasks were added to plan + const planPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"); + const planContent = readFileSync(planPath, "utf-8"); + assert.ok(planContent.includes("**T04:"), "should have T04"); + assert.ok(planContent.includes("**T05:"), "should have T05"); + + // Verify captures marked as executed + const all = loadAllCaptures(tmp); + assert.strictEqual(all[0].executed, true, "first capture should be executed"); + assert.strictEqual(all[1].executed, true, "second capture should be executed"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("resolution: executeTriageResolutions executes replan captures", () => { + const tmp = makeTempDir("res-exec-replan"); + try { + setupPlanFile(tmp, "M001", "S01", SAMPLE_PLAN); + const id = appendCapture(tmp, "approach is wrong"); + markCaptureResolved(tmp, id, "replan", "replan triggered", "wrong approach"); + + const result = executeTriageResolutions(tmp, "M001", "S01"); + + assert.strictEqual(result.injected, 0); + assert.strictEqual(result.replanned, 1, "should trigger 1 replan"); + assert.strictEqual(result.quickTasks.length, 0); + + // Verify trigger file was written + const triggerPath = join( + tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-REPLAN-TRIGGER.md", + ); + assert.ok(existsSync(triggerPath), "replan trigger should exist"); + + // Verify capture marked as executed + const all = loadAllCaptures(tmp); + assert.strictEqual(all[0].executed, true, "capture should be executed"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("resolution: executeTriageResolutions queues quick-tasks without executing inline", () => { + const tmp = makeTempDir("res-exec-qt"); + try { + const id = appendCapture(tmp, "fix typo in readme"); + markCaptureResolved(tmp, id, "quick-task", "execute as quick-task", "small fix"); + + const result = executeTriageResolutions(tmp, "M001", "S01"); + + assert.strictEqual(result.injected, 0); + assert.strictEqual(result.replanned, 0); + assert.strictEqual(result.quickTasks.length, 1, "should queue 1 quick-task"); + assert.strictEqual(result.quickTasks[0].id, id); + + // Quick-tasks should NOT be marked as executed yet (caller marks after dispatch) + const all = loadAllCaptures(tmp); + assert.ok(!all[0].executed, "quick-task should not be executed yet"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("resolution: executeTriageResolutions handles mixed classifications", () => { + const tmp = makeTempDir("res-exec-mixed"); + try { + setupPlanFile(tmp, "M001", "S01", SAMPLE_PLAN); + const id1 = appendCapture(tmp, "inject a task"); + const id2 = appendCapture(tmp, "quick fix typo"); + const id3 = appendCapture(tmp, "just a note"); + const id4 = appendCapture(tmp, "defer to later"); + + markCaptureResolved(tmp, id1, "inject", "add task", "needed"); + markCaptureResolved(tmp, id2, "quick-task", "quick fix", "small"); + markCaptureResolved(tmp, id3, "note", "acknowledged", "info"); + markCaptureResolved(tmp, id4, "defer", "deferred", "later"); + + const result = executeTriageResolutions(tmp, "M001", "S01"); + + assert.strictEqual(result.injected, 1, "should inject 1 task"); + assert.strictEqual(result.replanned, 0); + assert.strictEqual(result.quickTasks.length, 1, "should queue 1 quick-task"); + assert.strictEqual(result.actions.length, 2, "should have 2 action entries (note/defer excluded)"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("resolution: executeTriageResolutions skips already-executed captures", () => { + const tmp = makeTempDir("res-exec-skip"); + try { + setupPlanFile(tmp, "M001", "S01", SAMPLE_PLAN); + const id = appendCapture(tmp, "already done"); + markCaptureResolved(tmp, id, "inject", "add task", "needed"); + markCaptureExecuted(tmp, id); // already executed + + const result = executeTriageResolutions(tmp, "M001", "S01"); + + assert.strictEqual(result.injected, 0, "should not inject again"); + assert.strictEqual(result.actions.length, 0, "should have no actions"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("resolution: executeTriageResolutions returns empty result when no actionable captures", () => { + const tmp = makeTempDir("res-exec-empty"); + try { + const result = executeTriageResolutions(tmp, "M001", "S01"); + assert.strictEqual(result.injected, 0); + assert.strictEqual(result.replanned, 0); + assert.strictEqual(result.quickTasks.length, 0); + assert.strictEqual(result.actions.length, 0); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/src/resources/extensions/gsd/triage-resolution.ts b/src/resources/extensions/gsd/triage-resolution.ts index 0d49c4c39..8ddc4a6ba 100644 --- a/src/resources/extensions/gsd/triage-resolution.ts +++ b/src/resources/extensions/gsd/triage-resolution.ts @@ -16,7 +16,9 @@ import type { Classification, CaptureEntry } from "./captures.js"; import { loadPendingCaptures, loadAllCaptures, + loadActionableCaptures, markCaptureResolved, + markCaptureExecuted, } from "./captures.js"; // ─── Resolution Executors ───────────────────────────────────────────────────── @@ -198,3 +200,84 @@ export function buildQuickTaskPrompt(capture: CaptureEntry): string { `5. When done, say: "Quick task complete."`, ].join("\n"); } + +// ─── Post-Triage Resolution Executor ───────────────────────────────────────── + +/** + * Result of executing triage resolutions after a triage-captures unit completes. + */ +export interface TriageExecutionResult { + /** Number of inject resolutions executed (tasks added to plan) */ + injected: number; + /** Number of replan triggers written */ + replanned: number; + /** Captures classified as quick-task that need dispatch */ + quickTasks: CaptureEntry[]; + /** Details of each action taken, for logging */ + actions: string[]; +} + +/** + * Execute pending triage resolutions. + * + * Called after a triage-captures unit completes. Reads CAPTURES.md for + * resolved captures that have actionable classifications (inject, replan, + * quick-task) but haven't been executed yet, then: + * + * - inject: calls executeInject() to add a task to the current slice plan + * - replan: calls executeReplan() to write the REPLAN-TRIGGER.md marker + * - quick-task: collects for dispatch (caller handles dispatching quick-task units) + * + * Each capture is marked as executed after its resolution action succeeds, + * preventing double-execution on retries or restarts. + */ +export function executeTriageResolutions( + basePath: string, + mid: string, + sid: string, +): TriageExecutionResult { + const result: TriageExecutionResult = { + injected: 0, + replanned: 0, + quickTasks: [], + actions: [], + }; + + const actionable = loadActionableCaptures(basePath); + if (actionable.length === 0) return result; + + for (const capture of actionable) { + switch (capture.classification) { + case "inject": { + const newTaskId = executeInject(basePath, mid, sid, capture); + if (newTaskId) { + markCaptureExecuted(basePath, capture.id); + result.injected++; + result.actions.push(`Injected ${newTaskId} from ${capture.id}: "${capture.text}"`); + } else { + result.actions.push(`Failed to inject ${capture.id}: "${capture.text}" (no plan file or parse error)`); + } + break; + } + case "replan": { + const success = executeReplan(basePath, mid, sid, capture); + if (success) { + markCaptureExecuted(basePath, capture.id); + result.replanned++; + result.actions.push(`Replan triggered from ${capture.id}: "${capture.text}"`); + } else { + result.actions.push(`Failed to trigger replan from ${capture.id}: "${capture.text}"`); + } + break; + } + case "quick-task": { + // Quick-tasks are collected for dispatch, not executed inline + result.quickTasks.push(capture); + result.actions.push(`Quick-task queued from ${capture.id}: "${capture.text}"`); + break; + } + } + } + + return result; +}