feat(M004): mid-execution flexibility — capture, triage, and redirect (#512)
Solo developers can fire-and-forget thoughts during auto-mode execution via /gsd capture. The system triages accumulated captures at natural seams between tasks, classifies their impact into five types (quick-task, inject, defer, replan, note), and proposes appropriate action with user confirmation for plan-modifying resolutions. Pipeline: capture → triage → confirm → resolve → resume - /gsd capture appends to .gsd/CAPTURES.md (worktree-aware) - Triage fires automatically between tasks in handleAgentEnd - Five resolution types: inline quick task, inject task into plan, defer for reassess, trigger replan with context, acknowledge as note - Dashboard overlay shows pending capture count badge - Capture context injected into replan-slice and reassess-roadmap prompts - Parse failure falls back to note — pipeline never blocks New modules: captures.ts, triage-ui.ts, triage-resolution.ts New prompt: triage-captures.md 52 tests across 3 test files, all passing Requirements R045-R051 validated Closes #505 chore: pre-merge cleanup — remove dead code, single-read dashboard optimization - Remove processTriageResults() and associated types (dead code, superseded by inline resolution in auto.ts dispatch loop) - Add countPendingCaptures() for single-read regex count on dashboard hot path (replaces two-phase hasPendingCaptures + loadPendingCaptures) - Update triage-dispatch tests to match new implementation
This commit is contained in:
parent
77309207ce
commit
e0a309f5b5
15 changed files with 1980 additions and 3 deletions
|
|
@ -39,6 +39,8 @@ export interface AutoDashboardData {
|
|||
projectedRemainingCost?: number;
|
||||
/** Whether token profile has been auto-downgraded due to budget prediction */
|
||||
profileDowngraded?: boolean;
|
||||
/** Number of pending captures awaiting triage (0 if none or file missing) */
|
||||
pendingCaptureCount: number;
|
||||
}
|
||||
|
||||
// ─── Unit Description Helpers ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -777,6 +777,20 @@ export async function buildReplanSlicePrompt(
|
|||
|
||||
const replanPath = `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`;
|
||||
|
||||
// Build capture context for replan prompt (captures that triggered this replan)
|
||||
let captureContext = "(none)";
|
||||
try {
|
||||
const { loadReplanCaptures } = await import("./triage-resolution.js");
|
||||
const replanCaptures = loadReplanCaptures(base);
|
||||
if (replanCaptures.length > 0) {
|
||||
captureContext = replanCaptures.map(c =>
|
||||
`- **${c.id}**: "${c.text}" — ${c.rationale ?? "no rationale"}`
|
||||
).join("\n");
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — captures module may not be available
|
||||
}
|
||||
|
||||
return loadPrompt("replan-slice", {
|
||||
workingDirectory: base,
|
||||
milestoneId: mid,
|
||||
|
|
@ -787,6 +801,7 @@ export async function buildReplanSlicePrompt(
|
|||
blockerTaskId,
|
||||
inlinedContext,
|
||||
replanPath,
|
||||
captureContext,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -849,6 +864,20 @@ export async function buildReassessRoadmapPrompt(
|
|||
|
||||
const assessmentPath = relSliceFile(base, mid, completedSliceId, "ASSESSMENT");
|
||||
|
||||
// Build deferred captures context for reassess prompt
|
||||
let deferredCaptures = "(none)";
|
||||
try {
|
||||
const { loadDeferredCaptures } = await import("./triage-resolution.js");
|
||||
const deferred = loadDeferredCaptures(base);
|
||||
if (deferred.length > 0) {
|
||||
deferredCaptures = deferred.map(c =>
|
||||
`- **${c.id}**: "${c.text}" — ${c.rationale ?? "deferred during triage"}`
|
||||
).join("\n");
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — captures module may not be available
|
||||
}
|
||||
|
||||
return loadPrompt("reassess-roadmap", {
|
||||
workingDirectory: base,
|
||||
milestoneId: mid,
|
||||
|
|
@ -858,6 +887,7 @@ export async function buildReassessRoadmapPrompt(
|
|||
completedSliceSummaryPath: summaryRel,
|
||||
assessmentPath,
|
||||
inlinedContext,
|
||||
deferredCaptures,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type {
|
|||
import { deriveState, invalidateStateCache } from "./state.js";
|
||||
import type { BudgetEnforcementMode, GSDState } from "./types.js";
|
||||
import { loadFile, parseRoadmap, getManifestStatus, resolveAllOverrides } from "./files.js";
|
||||
import { loadPrompt } from "./prompt-loader.js";
|
||||
export { inlinePriorMilestoneSummary } from "./files.js";
|
||||
import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
|
||||
import {
|
||||
|
|
@ -132,6 +133,7 @@ import {
|
|||
deregisterSigtermHandler as _deregisterSigtermHandler,
|
||||
detectWorkingTreeActivity,
|
||||
} from "./auto-supervisor.js";
|
||||
import { hasPendingCaptures, loadPendingCaptures, countPendingCaptures } from "./captures.js";
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -307,6 +309,15 @@ export { type AutoDashboardData } from "./auto-dashboard.js";
|
|||
export function getAutoDashboardData(): AutoDashboardData {
|
||||
const ledger = getLedger();
|
||||
const totals = ledger ? getProjectTotals(ledger.units) : null;
|
||||
// Pending capture count — lazy check, non-fatal
|
||||
let pendingCaptureCount = 0;
|
||||
try {
|
||||
if (basePath) {
|
||||
pendingCaptureCount = countPendingCaptures(basePath);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — captures module may not be loaded
|
||||
}
|
||||
return {
|
||||
active,
|
||||
paused,
|
||||
|
|
@ -318,6 +329,7 @@ export function getAutoDashboardData(): AutoDashboardData {
|
|||
basePath,
|
||||
totalCost: totals?.cost ?? 0,
|
||||
totalTokens: totals?.tokens.total ?? 0,
|
||||
pendingCaptureCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1116,6 +1128,108 @@ export async function handleAgentEnd(
|
|||
}
|
||||
}
|
||||
|
||||
// ── Triage check: dispatch triage unit if pending captures exist ──────────
|
||||
// Fires after hooks complete, before normal dispatch. Follows the same
|
||||
// early-dispatch-and-return pattern as hooks and fix-merge.
|
||||
// Skip for: step mode (shows wizard instead), triage units (prevent triage-on-triage),
|
||||
// hook units (hooks run before triage conceptually).
|
||||
if (
|
||||
!stepMode &&
|
||||
currentUnit &&
|
||||
!currentUnit.type.startsWith("hook/") &&
|
||||
currentUnit.type !== "triage-captures" &&
|
||||
currentUnit.type !== "quick-task"
|
||||
) {
|
||||
try {
|
||||
if (hasPendingCaptures(basePath)) {
|
||||
const pending = loadPendingCaptures(basePath);
|
||||
if (pending.length > 0) {
|
||||
const state = await deriveState(basePath);
|
||||
const mid = state.activeMilestone?.id;
|
||||
const sid = state.activeSlice?.id;
|
||||
|
||||
if (mid && sid) {
|
||||
// Build triage prompt with current context
|
||||
let currentPlan = "";
|
||||
let roadmapContext = "";
|
||||
const planFile = resolveSliceFile(basePath, mid, sid, "PLAN");
|
||||
if (planFile) currentPlan = (await loadFile(planFile)) ?? "";
|
||||
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
||||
if (roadmapFile) roadmapContext = (await loadFile(roadmapFile)) ?? "";
|
||||
|
||||
const capturesList = pending.map(c =>
|
||||
`- **${c.id}**: "${c.text}" (captured: ${c.timestamp})`
|
||||
).join("\n");
|
||||
|
||||
const prompt = loadPrompt("triage-captures", {
|
||||
pendingCaptures: capturesList,
|
||||
currentPlan: currentPlan || "(no active slice plan)",
|
||||
roadmapContext: roadmapContext || "(no active roadmap)",
|
||||
});
|
||||
|
||||
ctx.ui.notify(
|
||||
`Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`,
|
||||
"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 triage as a new unit (early-dispatch-and-return)
|
||||
const triageUnitType = "triage-captures";
|
||||
const triageUnitId = `${mid}/${sid}/triage`;
|
||||
const triageStartedAt = Date.now();
|
||||
currentUnit = { type: triageUnitType, id: triageUnitId, startedAt: triageStartedAt };
|
||||
writeUnitRuntimeRecord(basePath, triageUnitType, triageUnitId, triageStartedAt, {
|
||||
phase: "dispatched",
|
||||
wrapupWarningSent: false,
|
||||
timeoutAt: null,
|
||||
lastProgressAt: triageStartedAt,
|
||||
progressCount: 0,
|
||||
lastProgressKind: "dispatch",
|
||||
});
|
||||
updateProgressWidget(ctx, triageUnitType, triageUnitId, state);
|
||||
|
||||
const result = await cmdCtx!.newSession();
|
||||
if (result.cancelled) {
|
||||
await stopAuto(ctx, pi);
|
||||
return;
|
||||
}
|
||||
const sessionFile = ctx.sessionManager.getSessionFile();
|
||||
writeLock(basePath, triageUnitType, triageUnitId, completedUnits.length, sessionFile);
|
||||
|
||||
// Start unit timeout for triage (use same supervisor config as hooks)
|
||||
clearUnitTimeout();
|
||||
const supervisor = resolveAutoSupervisorConfig();
|
||||
const triageTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
|
||||
unitTimeoutHandle = setTimeout(async () => {
|
||||
unitTimeoutHandle = null;
|
||||
if (!active) return;
|
||||
ctx.ui.notify(
|
||||
`Triage unit exceeded timeout. Pausing auto-mode.`,
|
||||
"warning",
|
||||
);
|
||||
await pauseAuto(ctx, pi);
|
||||
}, triageTimeoutMs);
|
||||
|
||||
if (!active) return;
|
||||
pi.sendMessage(
|
||||
{ customType: "gsd-auto", content: prompt, display: verbose },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
return; // handleAgentEnd will fire again when triage session completes
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Triage check failure is non-fatal — proceed to normal dispatch
|
||||
}
|
||||
}
|
||||
|
||||
// In step mode, pause and show a wizard instead of immediately dispatching
|
||||
if (stepMode) {
|
||||
await showStepWizard(ctx, pi);
|
||||
|
|
|
|||
384
src/resources/extensions/gsd/captures.ts
Normal file
384
src/resources/extensions/gsd/captures.ts
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
/**
|
||||
* GSD Captures — Fire-and-forget thought capture with triage classification
|
||||
*
|
||||
* Append-only capture file at `.gsd/CAPTURES.md`. Each capture is an H3 section
|
||||
* with bold metadata fields, parseable by the same patterns used in files.ts.
|
||||
*
|
||||
* Worktree-aware: captures always resolve to the original project root's
|
||||
* `.gsd/CAPTURES.md`, not the worktree's local `.gsd/`.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { gsdRoot } from "./paths.js";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Classification = "quick-task" | "inject" | "defer" | "replan" | "note";
|
||||
|
||||
export interface CaptureEntry {
|
||||
id: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
status: "pending" | "triaged" | "resolved";
|
||||
classification?: Classification;
|
||||
resolution?: string;
|
||||
rationale?: string;
|
||||
resolvedAt?: string;
|
||||
}
|
||||
|
||||
export interface TriageResult {
|
||||
captureId: string;
|
||||
classification: Classification;
|
||||
rationale: string;
|
||||
affectedFiles?: string[];
|
||||
targetSlice?: string;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const CAPTURES_FILENAME = "CAPTURES.md";
|
||||
const VALID_CLASSIFICATIONS: readonly string[] = [
|
||||
"quick-task", "inject", "defer", "replan", "note",
|
||||
];
|
||||
|
||||
// ─── Path Resolution ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the path to CAPTURES.md, aware of worktree context.
|
||||
*
|
||||
* In worktree-isolated mode, basePath is `.gsd/worktrees/<MID>/`.
|
||||
* Captures must resolve to the *original* project root's `.gsd/CAPTURES.md`,
|
||||
* not the worktree-local `.gsd/`. This ensures all captures go to one file
|
||||
* regardless of which worktree the agent is running in.
|
||||
*
|
||||
* Detection: if basePath contains `/.gsd/worktrees/`, walk up to the
|
||||
* directory that contains `.gsd/worktrees/` — that's the project root.
|
||||
*/
|
||||
export function resolveCapturesPath(basePath: string): string {
|
||||
const resolved = resolve(basePath);
|
||||
const worktreeMarker = `${sep}.gsd${sep}worktrees${sep}`;
|
||||
const idx = resolved.indexOf(worktreeMarker);
|
||||
if (idx !== -1) {
|
||||
// basePath is inside a worktree — resolve to project root
|
||||
const projectRoot = resolved.slice(0, idx);
|
||||
return join(projectRoot, ".gsd", CAPTURES_FILENAME);
|
||||
}
|
||||
return join(gsdRoot(basePath), CAPTURES_FILENAME);
|
||||
}
|
||||
|
||||
// ─── File I/O ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Append a new capture entry to CAPTURES.md.
|
||||
* Creates `.gsd/` and the file if they don't exist.
|
||||
* Returns the generated capture ID.
|
||||
*/
|
||||
export function appendCapture(basePath: string, text: string): string {
|
||||
const filePath = resolveCapturesPath(basePath);
|
||||
const dir = join(filePath, "..");
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const id = `CAP-${randomUUID().slice(0, 8)}`;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const entry = [
|
||||
`### ${id}`,
|
||||
`**Text:** ${text}`,
|
||||
`**Captured:** ${timestamp}`,
|
||||
`**Status:** pending`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
const existing = readFileSync(filePath, "utf-8");
|
||||
writeFileSync(filePath, existing.trimEnd() + "\n\n" + entry, "utf-8");
|
||||
} else {
|
||||
const header = `# Captures\n\n`;
|
||||
writeFileSync(filePath, header + entry, "utf-8");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all capture entries from CAPTURES.md.
|
||||
* Returns entries in file order (oldest first).
|
||||
*/
|
||||
export function loadAllCaptures(basePath: string): CaptureEntry[] {
|
||||
const filePath = resolveCapturesPath(basePath);
|
||||
if (!existsSync(filePath)) return [];
|
||||
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
return parseCapturesContent(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only pending (unresolved) captures.
|
||||
*/
|
||||
export function loadPendingCaptures(basePath: string): CaptureEntry[] {
|
||||
return loadAllCaptures(basePath).filter(c => c.status === "pending");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast check for pending captures without full parse.
|
||||
* Reads the file and scans for `**Status:** pending` via regex.
|
||||
* Returns false if the file doesn't exist.
|
||||
*/
|
||||
export function hasPendingCaptures(basePath: string): boolean {
|
||||
const filePath = resolveCapturesPath(basePath);
|
||||
if (!existsSync(filePath)) return false;
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
return /\*\*Status:\*\*\s*pending/i.test(content);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count pending captures without full parse — single file read.
|
||||
* Uses regex to count `**Status:** pending` occurrences.
|
||||
* Returns 0 if file doesn't exist or on error.
|
||||
*/
|
||||
export function countPendingCaptures(basePath: string): number {
|
||||
const filePath = resolveCapturesPath(basePath);
|
||||
if (!existsSync(filePath)) return 0;
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const matches = content.match(/\*\*Status:\*\*\s*pending/gi);
|
||||
return matches ? matches.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a capture as resolved with classification and rationale.
|
||||
* Rewrites the entry in place, preserving other entries.
|
||||
*/
|
||||
export function markCaptureResolved(
|
||||
basePath: string,
|
||||
captureId: string,
|
||||
classification: Classification,
|
||||
resolution: string,
|
||||
rationale: string,
|
||||
): void {
|
||||
const filePath = resolveCapturesPath(basePath);
|
||||
if (!existsSync(filePath)) return;
|
||||
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const resolvedAt = new Date().toISOString();
|
||||
|
||||
// Find the section for this capture ID and rewrite its fields
|
||||
const sectionRegex = new RegExp(
|
||||
`(### ${escapeRegex(captureId)}\\n(?:(?!### ).)*?)(?=### |$)`,
|
||||
"s",
|
||||
);
|
||||
const match = sectionRegex.exec(content);
|
||||
if (!match) return;
|
||||
|
||||
let section = match[1];
|
||||
|
||||
// Update Status field
|
||||
section = section.replace(
|
||||
/\*\*Status:\*\*\s*.+/,
|
||||
`**Status:** resolved`,
|
||||
);
|
||||
|
||||
// Append classification, resolution, rationale, and timestamp if not present
|
||||
const newFields = [
|
||||
`**Classification:** ${classification}`,
|
||||
`**Resolution:** ${resolution}`,
|
||||
`**Rationale:** ${rationale}`,
|
||||
`**Resolved:** ${resolvedAt}`,
|
||||
];
|
||||
|
||||
// Remove any existing classification/resolution/rationale/resolved fields
|
||||
// (in case of re-triage)
|
||||
section = section.replace(/\*\*Classification:\*\*\s*.+\n?/g, "");
|
||||
section = section.replace(/\*\*Resolution:\*\*\s*.+\n?/g, "");
|
||||
section = section.replace(/\*\*Rationale:\*\*\s*.+\n?/g, "");
|
||||
section = section.replace(/\*\*Resolved:\*\*\s*.+\n?/g, "");
|
||||
|
||||
// Add new fields after Status line
|
||||
section = section.trimEnd() + "\n" + newFields.join("\n") + "\n";
|
||||
|
||||
const updated = content.replace(sectionRegex, section);
|
||||
writeFileSync(filePath, updated, "utf-8");
|
||||
}
|
||||
|
||||
// ─── Parser ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse CAPTURES.md content into CaptureEntry array.
|
||||
*/
|
||||
function parseCapturesContent(content: string): CaptureEntry[] {
|
||||
const entries: CaptureEntry[] = [];
|
||||
|
||||
// Split on H3 headings
|
||||
const sections = content.split(/^### /m).slice(1); // skip content before first H3
|
||||
|
||||
for (const section of sections) {
|
||||
const lines = section.split("\n");
|
||||
const id = lines[0]?.trim();
|
||||
if (!id) continue;
|
||||
|
||||
const body = lines.slice(1).join("\n");
|
||||
const text = extractBoldField(body, "Text");
|
||||
const timestamp = extractBoldField(body, "Captured");
|
||||
const statusRaw = extractBoldField(body, "Status");
|
||||
const classification = extractBoldField(body, "Classification") as Classification | null;
|
||||
const resolution = extractBoldField(body, "Resolution");
|
||||
const rationale = extractBoldField(body, "Rationale");
|
||||
const resolvedAt = extractBoldField(body, "Resolved");
|
||||
|
||||
if (!text || !timestamp) continue;
|
||||
|
||||
const status = (statusRaw === "resolved" || statusRaw === "triaged")
|
||||
? statusRaw
|
||||
: "pending";
|
||||
|
||||
entries.push({
|
||||
id,
|
||||
text,
|
||||
timestamp,
|
||||
status,
|
||||
...(classification && VALID_CLASSIFICATIONS.includes(classification) ? { classification } : {}),
|
||||
...(resolution ? { resolution } : {}),
|
||||
...(rationale ? { rationale } : {}),
|
||||
...(resolvedAt ? { resolvedAt } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract value from a bold-prefixed line like "**Key:** Value".
|
||||
* Local copy of the pattern from files.ts to keep this module self-contained.
|
||||
*/
|
||||
function extractBoldField(text: string, key: string): string | null {
|
||||
const regex = new RegExp(`^\\*\\*${escapeRegex(key)}:\\*\\*\\s*(.+)$`, "m");
|
||||
const match = regex.exec(text);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// ─── Triage Output Parser ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse LLM triage output into TriageResult array.
|
||||
*
|
||||
* Handles:
|
||||
* - Clean JSON array
|
||||
* - JSON wrapped in fenced code block (```json ... ```)
|
||||
* - JSON with leading/trailing prose
|
||||
* - Single object (not array) — wraps in array
|
||||
* - Malformed JSON — returns empty array (caller should fall back to note)
|
||||
* - Partial results — valid entries are kept, invalid skipped
|
||||
*/
|
||||
export function parseTriageOutput(llmResponse: string): TriageResult[] {
|
||||
if (!llmResponse || !llmResponse.trim()) return [];
|
||||
|
||||
// Try to extract JSON from fenced code blocks first
|
||||
const fenced = llmResponse.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
|
||||
const jsonStr = fenced ? fenced[1] : extractJsonSubstring(llmResponse);
|
||||
|
||||
if (!jsonStr) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return arr
|
||||
.filter(isValidTriageResult)
|
||||
.map(normalizeTriageResult);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find a JSON array or object substring in prose text.
|
||||
* Looks for the first [ or { and finds its matching bracket.
|
||||
*/
|
||||
function extractJsonSubstring(text: string): string | null {
|
||||
// Find first [ or {
|
||||
const arrStart = text.indexOf("[");
|
||||
const objStart = text.indexOf("{");
|
||||
|
||||
let start: number;
|
||||
let openChar: string;
|
||||
let closeChar: string;
|
||||
|
||||
if (arrStart === -1 && objStart === -1) return null;
|
||||
if (arrStart === -1) {
|
||||
start = objStart;
|
||||
openChar = "{";
|
||||
closeChar = "}";
|
||||
} else if (objStart === -1) {
|
||||
start = arrStart;
|
||||
openChar = "[";
|
||||
closeChar = "]";
|
||||
} else {
|
||||
start = Math.min(arrStart, objStart);
|
||||
openChar = start === arrStart ? "[" : "{";
|
||||
closeChar = start === arrStart ? "]" : "}";
|
||||
}
|
||||
|
||||
// Find matching bracket
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
if (ch === openChar) depth++;
|
||||
if (ch === closeChar) depth--;
|
||||
if (depth === 0) {
|
||||
return text.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidTriageResult(obj: unknown): boolean {
|
||||
if (!obj || typeof obj !== "object") return false;
|
||||
const o = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof o.captureId === "string" &&
|
||||
typeof o.classification === "string" &&
|
||||
VALID_CLASSIFICATIONS.includes(o.classification) &&
|
||||
typeof o.rationale === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTriageResult(obj: Record<string, unknown>): TriageResult {
|
||||
return {
|
||||
captureId: obj.captureId as string,
|
||||
classification: obj.classification as Classification,
|
||||
rationale: obj.rationale as string,
|
||||
...(Array.isArray(obj.affectedFiles) ? { affectedFiles: obj.affectedFiles as string[] } : {}),
|
||||
...(typeof obj.targetSlice === "string" ? { targetSlice: obj.targetSlice } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { GSDDashboardOverlay } from "./dashboard-overlay.js";
|
|||
import { showQueue, showDiscuss } from "./guided-flow.js";
|
||||
import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote } from "./auto.js";
|
||||
import { resolveProjectRoot } from "./worktree.js";
|
||||
import { appendCapture, hasPendingCaptures, loadPendingCaptures } from "./captures.js";
|
||||
import {
|
||||
getGlobalGSDPreferencesPath,
|
||||
getLegacyGlobalGSDPreferencesPath,
|
||||
|
|
@ -64,10 +65,11 @@ function projectRoot(): string {
|
|||
|
||||
export function registerGSDCommand(pi: ExtensionAPI): void {
|
||||
pi.registerCommand("gsd", {
|
||||
description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer|knowledge",
|
||||
description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|capture|triage|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer|knowledge",
|
||||
getArgumentCompletions: (prefix: string) => {
|
||||
const subcommands = [
|
||||
"next", "auto", "stop", "pause", "status", "queue", "discuss",
|
||||
"capture", "triage",
|
||||
"history", "undo", "skip", "export", "cleanup", "prefs",
|
||||
"config", "hooks", "doctor", "migrate", "remote", "steer", "knowledge",
|
||||
];
|
||||
|
|
@ -259,6 +261,16 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("capture ") || trimmed === "capture") {
|
||||
await handleCapture(trimmed.replace(/^capture\s*/, "").trim(), ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "triage") {
|
||||
await handleTriage(ctx, pi, process.cwd());
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "config") {
|
||||
await handleConfig(ctx);
|
||||
return;
|
||||
|
|
@ -306,7 +318,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|||
}
|
||||
|
||||
ctx.ui.notify(
|
||||
`Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip <unit>|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer <change>|knowledge <type> <entry>.`,
|
||||
`Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|capture|triage|discuss|history|undo|skip <unit>|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer <change>|knowledge <type> <entry>.`,
|
||||
"warning",
|
||||
);
|
||||
},
|
||||
|
|
@ -1195,6 +1207,102 @@ async function handleKnowledge(args: string, ctx: ExtensionCommandContext): Prom
|
|||
ctx.ui.notify(`Added ${type} to KNOWLEDGE.md: "${entryText}"`, "success");
|
||||
}
|
||||
|
||||
// ─── Capture Command ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle `/gsd capture "..."` — fire-and-forget thought capture.
|
||||
* Appends to `.gsd/CAPTURES.md` without interrupting auto-mode.
|
||||
* Works in all modes: auto running, paused, stopped, no project.
|
||||
*/
|
||||
async function handleCapture(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
||||
// Strip surrounding quotes from the argument
|
||||
let text = args.trim();
|
||||
if (!text) {
|
||||
ctx.ui.notify('Usage: /gsd capture "your thought here"', "warning");
|
||||
return;
|
||||
}
|
||||
// Remove wrapping quotes (single or double)
|
||||
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
|
||||
text = text.slice(1, -1);
|
||||
}
|
||||
if (!text) {
|
||||
ctx.ui.notify('Usage: /gsd capture "your thought here"', "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const basePath = process.cwd();
|
||||
|
||||
// Ensure .gsd/ exists — capture should work even without a milestone
|
||||
const gsdDir = join(basePath, ".gsd");
|
||||
if (!existsSync(gsdDir)) {
|
||||
mkdirSync(gsdDir, { recursive: true });
|
||||
}
|
||||
|
||||
const id = appendCapture(basePath, text);
|
||||
ctx.ui.notify(`Captured: ${id} — "${text.length > 60 ? text.slice(0, 57) + "..." : text}"`, "info");
|
||||
}
|
||||
|
||||
// ─── Triage Command ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle `/gsd triage` — manually trigger triage of pending captures.
|
||||
* Dispatches the triage prompt to the LLM for classification.
|
||||
* Triage result handling (confirmation UI) is wired in T03.
|
||||
*/
|
||||
async function handleTriage(ctx: ExtensionCommandContext, pi: ExtensionAPI, basePath: string): Promise<void> {
|
||||
if (!hasPendingCaptures(basePath)) {
|
||||
ctx.ui.notify("No pending captures to triage.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = loadPendingCaptures(basePath);
|
||||
ctx.ui.notify(`Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`, "info");
|
||||
|
||||
// Build context for the triage prompt
|
||||
const state = await deriveState(basePath);
|
||||
let currentPlan = "";
|
||||
let roadmapContext = "";
|
||||
|
||||
if (state.activeMilestone && state.activeSlice) {
|
||||
const { resolveSliceFile, resolveMilestoneFile } = await import("./paths.js");
|
||||
const planFile = resolveSliceFile(basePath, state.activeMilestone.id, state.activeSlice.id, "PLAN");
|
||||
if (planFile) {
|
||||
const { loadFile: load } = await import("./files.js");
|
||||
currentPlan = (await load(planFile)) ?? "";
|
||||
}
|
||||
const roadmapFile = resolveMilestoneFile(basePath, state.activeMilestone.id, "ROADMAP");
|
||||
if (roadmapFile) {
|
||||
const { loadFile: load } = await import("./files.js");
|
||||
roadmapContext = (await load(roadmapFile)) ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
// Format pending captures for the prompt
|
||||
const capturesList = pending.map(c =>
|
||||
`- **${c.id}**: "${c.text}" (captured: ${c.timestamp})`
|
||||
).join("\n");
|
||||
|
||||
// Dispatch triage prompt
|
||||
const { loadPrompt } = await import("./prompt-loader.js");
|
||||
const prompt = loadPrompt("triage-captures", {
|
||||
pendingCaptures: capturesList,
|
||||
currentPlan: currentPlan || "(no active slice plan)",
|
||||
roadmapContext: roadmapContext || "(no active roadmap)",
|
||||
});
|
||||
|
||||
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
|
||||
const workflow = readFileSync(workflowPath, "utf-8");
|
||||
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "gsd-triage",
|
||||
content: `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${prompt}`,
|
||||
display: false,
|
||||
},
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSteer(change: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
||||
const basePath = process.cwd();
|
||||
const state = await deriveState(basePath);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ function unitLabel(type: string): string {
|
|||
case "execute-task": return "Execute";
|
||||
case "complete-slice": return "Complete";
|
||||
case "reassess-roadmap": return "Reassess";
|
||||
case "triage-captures": return "Triage";
|
||||
case "quick-task": return "Quick Task";
|
||||
case "replan-slice": return "Replan";
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
|
|
@ -345,6 +348,13 @@ export class GSDDashboardOverlay {
|
|||
lines.push(blank());
|
||||
}
|
||||
|
||||
// Pending captures badge — only shown when captures are waiting for triage
|
||||
if (this.dashData.pendingCaptureCount > 0) {
|
||||
const count = this.dashData.pendingCaptureCount;
|
||||
lines.push(row(th.fg("warning", `📌 ${count} pending capture${count === 1 ? "" : "s"} awaiting triage`)));
|
||||
lines.push(blank());
|
||||
}
|
||||
|
||||
if (this.loading) {
|
||||
lines.push(centered(th.fg("dim", "Loading dashboard…")));
|
||||
return lines;
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ export function checkPostUnitHooks(
|
|||
}
|
||||
|
||||
// Don't trigger hooks for other hook units (prevent hook-on-hook chains)
|
||||
if (completedUnitType.startsWith("hook/")) return null;
|
||||
// Don't trigger hooks for triage units (prevent hook-on-triage chains)
|
||||
if (completedUnitType.startsWith("hook/") || completedUnitType === "triage-captures") return null;
|
||||
|
||||
// Check if any hooks are configured for this unit type
|
||||
const hooks = resolvePostUnitHooks().filter(h =>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ All relevant context has been preloaded below — the current roadmap, completed
|
|||
|
||||
{{inlinedContext}}
|
||||
|
||||
## Deferred Captures
|
||||
|
||||
The following user thoughts were captured during execution and deferred to future slices during triage. Consider whether any should influence the remaining roadmap:
|
||||
|
||||
{{deferredCaptures}}
|
||||
|
||||
If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during reassessment, without relaxing required verification or artifact rules.
|
||||
|
||||
Then assess whether the remaining roadmap still makes sense given what was just built.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ All relevant context has been preloaded below — the roadmap, current slice pla
|
|||
|
||||
{{inlinedContext}}
|
||||
|
||||
## Capture Context
|
||||
|
||||
The following user-captured thoughts triggered or informed this replan:
|
||||
|
||||
{{captureContext}}
|
||||
|
||||
Consider these captures when rewriting the remaining tasks — they represent the user's real-time insights about what needs to change.
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
- **Do NOT renumber or remove completed tasks.** All `[x]` tasks and their IDs must remain exactly as they are in the plan.
|
||||
|
|
|
|||
62
src/resources/extensions/gsd/prompts/triage-captures.md
Normal file
62
src/resources/extensions/gsd/prompts/triage-captures.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
You are triaging user-captured thoughts during a GSD session.
|
||||
|
||||
## UNIT: Triage Captures
|
||||
|
||||
The user captured thoughts during execution using `/gsd capture`. Your job is to classify each capture, present your proposals, get user confirmation, and update CAPTURES.md with the final classifications.
|
||||
|
||||
## Pending Captures
|
||||
|
||||
{{pendingCaptures}}
|
||||
|
||||
## Current Slice Plan
|
||||
|
||||
{{currentPlan}}
|
||||
|
||||
## Current Roadmap
|
||||
|
||||
{{roadmapContext}}
|
||||
|
||||
## Classification Criteria
|
||||
|
||||
For each capture, classify it as one of:
|
||||
|
||||
- **quick-task**: Small, self-contained, no downstream impact. Can be done in minutes without modifying the plan. Examples: fix a typo, add a missing import, tweak a config value.
|
||||
- **inject**: Belongs in the current slice but wasn't planned. Needs a new task added to the slice plan. Examples: add error handling to a module being built, add a missing test case for current work.
|
||||
- **defer**: Belongs in a future slice or milestone. Not urgent for current work. Examples: performance optimization, feature that depends on unbuilt infrastructure, nice-to-have enhancement.
|
||||
- **replan**: Changes the shape of remaining work in the current slice. Existing incomplete tasks may need rewriting. Examples: "the approach is wrong, we need to use X instead of Y", discovering a fundamental constraint.
|
||||
- **note**: Informational only. No action needed right now. Good context for future reference. Examples: "remember that the API has a rate limit", observations about code quality.
|
||||
|
||||
## Decision Guidelines
|
||||
|
||||
- Prefer **quick-task** when the work is clearly small and self-contained.
|
||||
- Prefer **inject** over **replan** when only a new task is needed, not rewriting existing ones.
|
||||
- Prefer **defer** over **inject** when the work doesn't belong in the current slice's scope.
|
||||
- Use **replan** only when remaining incomplete tasks need to change — not just for adding work.
|
||||
- Use **note** for observations that don't require action.
|
||||
- When unsure between quick-task and inject, consider: will this take more than 10 minutes? If yes, inject.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Classify** each pending capture using the criteria above.
|
||||
|
||||
2. **Present** your classifications to the user using `ask_user_questions`. For each capture, show:
|
||||
- The capture text
|
||||
- Your proposed classification
|
||||
- Your rationale
|
||||
- If applicable, which files would be affected
|
||||
|
||||
For captures classified as **note** or **defer**, auto-confirm without asking — these are low-impact.
|
||||
For captures classified as **quick-task**, **inject**, or **replan**, ask the user to confirm or choose a different classification.
|
||||
|
||||
3. **Update** `.gsd/CAPTURES.md` — for each capture, update its section with the confirmed classification:
|
||||
- Change `**Status:** pending` to `**Status:** resolved`
|
||||
- Add `**Classification:** <type>`
|
||||
- Add `**Resolution:** <brief description of what will happen>`
|
||||
- Add `**Rationale:** <why this classification>`
|
||||
- Add `**Resolved:** <current ISO timestamp>`
|
||||
|
||||
4. **Summarize** what was triaged: how many captures, what classifications were assigned, and what actions are pending (e.g., "2 quick-tasks ready for execution, 1 deferred to S03").
|
||||
|
||||
**Important:** Do NOT execute any resolutions. Only classify and update CAPTURES.md. Resolution execution happens separately (in auto-mode dispatch or manually by the user).
|
||||
|
||||
When done, say: "Triage complete."
|
||||
438
src/resources/extensions/gsd/tests/captures.test.ts
Normal file
438
src/resources/extensions/gsd/tests/captures.test.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
/**
|
||||
* Unit tests for GSD Captures — file I/O, parsing, and worktree path resolution.
|
||||
*
|
||||
* Exercises the boundary contract that S02 (auto-mode dispatch) depends on:
|
||||
* - appendCapture creates/appends entries to CAPTURES.md
|
||||
* - loadAllCaptures / loadPendingCaptures parse and filter correctly
|
||||
* - hasPendingCaptures does fast regex check without full parse
|
||||
* - markCaptureResolved updates entry in place
|
||||
* - resolveCapturesPath handles worktree paths
|
||||
* - parseTriageOutput handles valid, malformed, and partial JSON
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
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,
|
||||
loadAllCaptures,
|
||||
loadPendingCaptures,
|
||||
hasPendingCaptures,
|
||||
markCaptureResolved,
|
||||
resolveCapturesPath,
|
||||
parseTriageOutput,
|
||||
} from "../captures.ts";
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
const dir = join(
|
||||
tmpdir(),
|
||||
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
// ─── appendCapture ────────────────────────────────────────────────────────────
|
||||
|
||||
test("captures: appendCapture creates CAPTURES.md on first call", () => {
|
||||
const tmp = makeTempDir("cap-create");
|
||||
try {
|
||||
const id = appendCapture(tmp, "first thought");
|
||||
assert.ok(id.startsWith("CAP-"), "ID should start with CAP-");
|
||||
assert.ok(
|
||||
existsSync(join(tmp, ".gsd", "CAPTURES.md")),
|
||||
"CAPTURES.md should exist",
|
||||
);
|
||||
const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8");
|
||||
assert.ok(content.includes("# Captures"), "should have header");
|
||||
assert.ok(content.includes(`### ${id}`), "should have entry heading");
|
||||
assert.ok(
|
||||
content.includes("**Text:** first thought"),
|
||||
"should have text field",
|
||||
);
|
||||
assert.ok(
|
||||
content.includes("**Status:** pending"),
|
||||
"should have pending status",
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("captures: appendCapture appends to existing file", () => {
|
||||
const tmp = makeTempDir("cap-append");
|
||||
try {
|
||||
const id1 = appendCapture(tmp, "thought one");
|
||||
const id2 = appendCapture(tmp, "thought two");
|
||||
assert.notStrictEqual(id1, id2, "IDs should be unique");
|
||||
|
||||
const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8");
|
||||
assert.ok(content.includes(`### ${id1}`), "should have first entry");
|
||||
assert.ok(content.includes(`### ${id2}`), "should have second entry");
|
||||
assert.ok(
|
||||
content.includes("**Text:** thought one"),
|
||||
"should have first text",
|
||||
);
|
||||
assert.ok(
|
||||
content.includes("**Text:** thought two"),
|
||||
"should have second text",
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── loadAllCaptures / loadPendingCaptures ────────────────────────────────────
|
||||
|
||||
test("captures: loadAllCaptures parses entries correctly", () => {
|
||||
const tmp = makeTempDir("cap-load");
|
||||
try {
|
||||
appendCapture(tmp, "alpha");
|
||||
appendCapture(tmp, "beta");
|
||||
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 2, "should have 2 entries");
|
||||
assert.strictEqual(all[0].text, "alpha");
|
||||
assert.strictEqual(all[1].text, "beta");
|
||||
assert.strictEqual(all[0].status, "pending");
|
||||
assert.strictEqual(all[1].status, "pending");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("captures: loadAllCaptures returns empty array when no file", () => {
|
||||
const tmp = makeTempDir("cap-nofile");
|
||||
try {
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 0);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("captures: loadPendingCaptures filters resolved entries", () => {
|
||||
const tmp = makeTempDir("cap-pending");
|
||||
try {
|
||||
const id1 = appendCapture(tmp, "pending one");
|
||||
appendCapture(tmp, "pending two");
|
||||
|
||||
// Resolve the first one
|
||||
markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note");
|
||||
|
||||
const pending = loadPendingCaptures(tmp);
|
||||
assert.strictEqual(pending.length, 1, "should have 1 pending");
|
||||
assert.strictEqual(pending[0].text, "pending two");
|
||||
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 2, "all should still have 2");
|
||||
assert.strictEqual(all[0].status, "resolved");
|
||||
assert.strictEqual(all[1].status, "pending");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── hasPendingCaptures ───────────────────────────────────────────────────────
|
||||
|
||||
test("captures: hasPendingCaptures returns false when no file", () => {
|
||||
const tmp = makeTempDir("cap-has-nofile");
|
||||
try {
|
||||
assert.strictEqual(hasPendingCaptures(tmp), false);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("captures: hasPendingCaptures returns true with pending entries", () => {
|
||||
const tmp = makeTempDir("cap-has-true");
|
||||
try {
|
||||
appendCapture(tmp, "something");
|
||||
assert.strictEqual(hasPendingCaptures(tmp), true);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("captures: hasPendingCaptures returns false when all resolved", () => {
|
||||
const tmp = makeTempDir("cap-has-false");
|
||||
try {
|
||||
const id = appendCapture(tmp, "will resolve");
|
||||
markCaptureResolved(tmp, id, "note", "done", "resolved it");
|
||||
assert.strictEqual(hasPendingCaptures(tmp), false);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── markCaptureResolved ──────────────────────────────────────────────────────
|
||||
|
||||
test("captures: markCaptureResolved updates entry in place", () => {
|
||||
const tmp = makeTempDir("cap-resolve");
|
||||
try {
|
||||
const id1 = appendCapture(tmp, "keep pending");
|
||||
const id2 = appendCapture(tmp, "will resolve");
|
||||
appendCapture(tmp, "also pending");
|
||||
|
||||
markCaptureResolved(tmp, id2, "quick-task", "executed inline", "small fix");
|
||||
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 3, "should still have 3 entries");
|
||||
|
||||
const resolved = all.find((c) => c.id === id2)!;
|
||||
assert.strictEqual(resolved.status, "resolved");
|
||||
assert.strictEqual(resolved.classification, "quick-task");
|
||||
assert.strictEqual(resolved.resolution, "executed inline");
|
||||
assert.strictEqual(resolved.rationale, "small fix");
|
||||
assert.ok(resolved.resolvedAt, "should have resolved timestamp");
|
||||
|
||||
// Others should be unaffected
|
||||
const kept = all.find((c) => c.id === id1)!;
|
||||
assert.strictEqual(kept.status, "pending");
|
||||
assert.strictEqual(kept.classification, undefined);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── resolveCapturesPath ──────────────────────────────────────────────────────
|
||||
|
||||
test("captures: resolveCapturesPath returns .gsd/CAPTURES.md for normal path", () => {
|
||||
const base = join(tmpdir(), "cap-test-project");
|
||||
const result = resolveCapturesPath(base);
|
||||
assert.ok(result.endsWith(join(".gsd", "CAPTURES.md")));
|
||||
assert.ok(result.startsWith(base));
|
||||
});
|
||||
|
||||
test("captures: resolveCapturesPath resolves worktree path to project root", () => {
|
||||
const base = join(tmpdir(), "cap-test-project");
|
||||
const worktreePath = join(base, ".gsd", "worktrees", "M004");
|
||||
const result = resolveCapturesPath(worktreePath);
|
||||
assert.ok(
|
||||
result.endsWith(join(".gsd", "CAPTURES.md")),
|
||||
`should end with .gsd/CAPTURES.md, got: ${result}`,
|
||||
);
|
||||
// Should resolve to project root, not worktree root
|
||||
assert.ok(
|
||||
!result.includes("worktrees"),
|
||||
`should not contain worktrees, got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.startsWith(base),
|
||||
`should start with ${base}, got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ─── parseTriageOutput ────────────────────────────────────────────────────────
|
||||
|
||||
test("triage: parseTriageOutput handles valid JSON array", () => {
|
||||
const input = JSON.stringify([
|
||||
{
|
||||
captureId: "CAP-abc123",
|
||||
classification: "quick-task",
|
||||
rationale: "Small fix",
|
||||
affectedFiles: ["src/foo.ts"],
|
||||
},
|
||||
{
|
||||
captureId: "CAP-def456",
|
||||
classification: "defer",
|
||||
rationale: "Future work",
|
||||
targetSlice: "S03",
|
||||
},
|
||||
]);
|
||||
|
||||
const results = parseTriageOutput(input);
|
||||
assert.strictEqual(results.length, 2);
|
||||
assert.strictEqual(results[0].captureId, "CAP-abc123");
|
||||
assert.strictEqual(results[0].classification, "quick-task");
|
||||
assert.deepStrictEqual(results[0].affectedFiles, ["src/foo.ts"]);
|
||||
assert.strictEqual(results[1].classification, "defer");
|
||||
assert.strictEqual(results[1].targetSlice, "S03");
|
||||
});
|
||||
|
||||
test("triage: parseTriageOutput handles fenced code block", () => {
|
||||
const input = `Here are my classifications:
|
||||
|
||||
\`\`\`json
|
||||
[
|
||||
{
|
||||
"captureId": "CAP-aaa",
|
||||
"classification": "note",
|
||||
"rationale": "Just informational"
|
||||
}
|
||||
]
|
||||
\`\`\`
|
||||
|
||||
That's my analysis.`;
|
||||
|
||||
const results = parseTriageOutput(input);
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].captureId, "CAP-aaa");
|
||||
assert.strictEqual(results[0].classification, "note");
|
||||
});
|
||||
|
||||
test("triage: parseTriageOutput handles JSON with leading/trailing prose", () => {
|
||||
const input = `I've analyzed the captures. Here are my results:
|
||||
[{"captureId": "CAP-bbb", "classification": "inject", "rationale": "Needs a new task"}]
|
||||
Let me know if you need changes.`;
|
||||
|
||||
const results = parseTriageOutput(input);
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].classification, "inject");
|
||||
});
|
||||
|
||||
test("triage: parseTriageOutput returns empty array on malformed JSON", () => {
|
||||
const results = parseTriageOutput("this is not json at all");
|
||||
assert.strictEqual(results.length, 0);
|
||||
});
|
||||
|
||||
test("triage: parseTriageOutput returns empty array on empty input", () => {
|
||||
assert.strictEqual(parseTriageOutput("").length, 0);
|
||||
assert.strictEqual(parseTriageOutput(" ").length, 0);
|
||||
});
|
||||
|
||||
test("triage: parseTriageOutput filters invalid entries from partial results", () => {
|
||||
const input = JSON.stringify([
|
||||
{
|
||||
captureId: "CAP-good",
|
||||
classification: "note",
|
||||
rationale: "Valid entry",
|
||||
},
|
||||
{
|
||||
captureId: "CAP-bad",
|
||||
classification: "invalid-type",
|
||||
rationale: "Bad classification",
|
||||
},
|
||||
{
|
||||
// Missing required fields
|
||||
captureId: "CAP-incomplete",
|
||||
},
|
||||
{
|
||||
captureId: "CAP-also-good",
|
||||
classification: "replan",
|
||||
rationale: "Needs restructuring",
|
||||
},
|
||||
]);
|
||||
|
||||
const results = parseTriageOutput(input);
|
||||
assert.strictEqual(results.length, 2, "should keep only valid entries");
|
||||
assert.strictEqual(results[0].captureId, "CAP-good");
|
||||
assert.strictEqual(results[1].captureId, "CAP-also-good");
|
||||
});
|
||||
|
||||
test("triage: parseTriageOutput wraps single object in array", () => {
|
||||
const input = JSON.stringify({
|
||||
captureId: "CAP-single",
|
||||
classification: "quick-task",
|
||||
rationale: "Just one",
|
||||
});
|
||||
|
||||
const results = parseTriageOutput(input);
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].captureId, "CAP-single");
|
||||
});
|
||||
|
||||
test("triage: parseTriageOutput handles all five classification types", () => {
|
||||
const types = [
|
||||
"quick-task",
|
||||
"inject",
|
||||
"defer",
|
||||
"replan",
|
||||
"note",
|
||||
] as const;
|
||||
|
||||
const input = JSON.stringify(
|
||||
types.map((t, i) => ({
|
||||
captureId: `CAP-${i}`,
|
||||
classification: t,
|
||||
rationale: `Type: ${t}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const results = parseTriageOutput(input);
|
||||
assert.strictEqual(results.length, 5);
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
assert.strictEqual(results[i].classification, types[i]);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Edge Cases ───────────────────────────────────────────────────────────────
|
||||
|
||||
test("captures: appendCapture handles special characters in text", () => {
|
||||
const tmp = makeTempDir("cap-special");
|
||||
try {
|
||||
const id = appendCapture(tmp, 'text with "quotes" and **bold** and `code`');
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 1);
|
||||
assert.ok(all[0].text.includes('"quotes"'), "should preserve quotes");
|
||||
assert.ok(all[0].text.includes("**bold**"), "should preserve bold");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("captures: markCaptureResolved is no-op for non-existent ID", () => {
|
||||
const tmp = makeTempDir("cap-noop");
|
||||
try {
|
||||
appendCapture(tmp, "real capture");
|
||||
// Should not throw
|
||||
markCaptureResolved(tmp, "CAP-nonexistent", "note", "test", "test");
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 1);
|
||||
assert.strictEqual(all[0].status, "pending", "original should be unchanged");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("captures: markCaptureResolved is no-op when no file exists", () => {
|
||||
const tmp = makeTempDir("cap-nofile-resolve");
|
||||
try {
|
||||
// Should not throw
|
||||
markCaptureResolved(tmp, "CAP-abc", "note", "test", "test");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("captures: re-resolving a capture overwrites previous resolution", () => {
|
||||
const tmp = makeTempDir("cap-reresolve");
|
||||
try {
|
||||
const id = appendCapture(tmp, "will re-resolve");
|
||||
markCaptureResolved(tmp, id, "note", "first resolution", "first rationale");
|
||||
markCaptureResolved(tmp, id, "inject", "second resolution", "second rationale");
|
||||
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 1);
|
||||
assert.strictEqual(all[0].classification, "inject", "should have updated classification");
|
||||
assert.strictEqual(all[0].resolution, "second resolution");
|
||||
assert.strictEqual(all[0].rationale, "second rationale");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("triage: parseTriageOutput preserves affectedFiles and targetSlice", () => {
|
||||
const input = JSON.stringify([
|
||||
{
|
||||
captureId: "CAP-files",
|
||||
classification: "quick-task",
|
||||
rationale: "Has files",
|
||||
affectedFiles: ["src/a.ts", "src/b.ts"],
|
||||
},
|
||||
{
|
||||
captureId: "CAP-target",
|
||||
classification: "defer",
|
||||
rationale: "Has target",
|
||||
targetSlice: "S04",
|
||||
},
|
||||
]);
|
||||
|
||||
const results = parseTriageOutput(input);
|
||||
assert.deepStrictEqual(results[0].affectedFiles, ["src/a.ts", "src/b.ts"]);
|
||||
assert.strictEqual(results[0].targetSlice, undefined);
|
||||
assert.strictEqual(results[1].targetSlice, "S04");
|
||||
assert.strictEqual(results[1].affectedFiles, undefined);
|
||||
});
|
||||
224
src/resources/extensions/gsd/tests/triage-dispatch.test.ts
Normal file
224
src/resources/extensions/gsd/tests/triage-dispatch.test.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* Triage dispatch ordering contract tests.
|
||||
*
|
||||
* These tests verify structural invariants of the triage integration
|
||||
* by inspecting the actual source code of auto.ts and post-unit-hooks.ts.
|
||||
* Full behavioral testing requires the @gsd/pi-coding-agent runtime.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const autoPath = join(__dirname, "..", "auto.ts");
|
||||
const hooksPath = join(__dirname, "..", "post-unit-hooks.ts");
|
||||
const autoPromptsPath = join(__dirname, "..", "auto-prompts.ts");
|
||||
|
||||
const autoSrc = readFileSync(autoPath, "utf-8");
|
||||
const hooksSrc = readFileSync(hooksPath, "utf-8");
|
||||
const autoPromptsSrc = (() => { try { return readFileSync(autoPromptsPath, "utf-8"); } catch { return autoSrc; } })();
|
||||
|
||||
// ─── Hook exclusion ──────────────────────────────────────────────────────────
|
||||
|
||||
test("dispatch: triage-captures excluded from post-unit hook triggering", () => {
|
||||
// post-unit-hooks.ts must return null for triage-captures unit type
|
||||
assert.ok(
|
||||
hooksSrc.includes('"triage-captures"'),
|
||||
"post-unit-hooks.ts should reference triage-captures",
|
||||
);
|
||||
assert.ok(
|
||||
hooksSrc.includes('completedUnitType === "triage-captures"'),
|
||||
"should check for triage-captures in the hook exclusion guard",
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Triage check placement ──────────────────────────────────────────────────
|
||||
|
||||
test("dispatch: triage check appears after hook section and before stepMode check", () => {
|
||||
const hookRetryIndex = autoSrc.indexOf("isRetryPending()");
|
||||
// Find the triage check in handleAgentEnd (not in getAutoDashboardData)
|
||||
const triageCheckIndex = autoSrc.indexOf("Triage check: dispatch triage unit");
|
||||
const stepModeIndex = autoSrc.indexOf("In step mode, pause and show a wizard");
|
||||
|
||||
assert.ok(hookRetryIndex > 0, "hook retry check should exist");
|
||||
assert.ok(triageCheckIndex > 0, "triage check block should exist");
|
||||
assert.ok(stepModeIndex > 0, "step mode check should exist");
|
||||
|
||||
assert.ok(
|
||||
triageCheckIndex > hookRetryIndex,
|
||||
"triage check should come after hook retry check",
|
||||
);
|
||||
assert.ok(
|
||||
triageCheckIndex < stepModeIndex,
|
||||
"triage check should come before stepMode check",
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Guard conditions ────────────────────────────────────────────────────────
|
||||
|
||||
test("dispatch: triage check guards against step mode", () => {
|
||||
// The triage block should check !stepMode
|
||||
const triageBlock = autoSrc.slice(
|
||||
autoSrc.indexOf("Triage check: dispatch triage unit"),
|
||||
autoSrc.indexOf("In step mode, pause and show a wizard"),
|
||||
);
|
||||
assert.ok(
|
||||
triageBlock.includes("!stepMode"),
|
||||
"triage block should guard against step mode",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: triage check guards against hook unit types", () => {
|
||||
const triageBlock = autoSrc.slice(
|
||||
autoSrc.indexOf("Triage check: dispatch triage unit"),
|
||||
autoSrc.indexOf("In step mode, pause and show a wizard"),
|
||||
);
|
||||
assert.ok(
|
||||
triageBlock.includes('!currentUnit.type.startsWith("hook/")'),
|
||||
"triage block should not fire for hook units",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: triage check guards against triage-on-triage", () => {
|
||||
const triageBlock = autoSrc.slice(
|
||||
autoSrc.indexOf("Triage check: dispatch triage unit"),
|
||||
autoSrc.indexOf("In step mode, pause and show a wizard"),
|
||||
);
|
||||
assert.ok(
|
||||
triageBlock.includes('currentUnit.type !== "triage-captures"'),
|
||||
"triage block should not fire for triage units",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: triage check guards against quick-task triggering triage", () => {
|
||||
const triageBlock = autoSrc.slice(
|
||||
autoSrc.indexOf("Triage check: dispatch triage unit"),
|
||||
autoSrc.indexOf("In step mode, pause and show a wizard"),
|
||||
);
|
||||
assert.ok(
|
||||
triageBlock.includes('currentUnit.type !== "quick-task"'),
|
||||
"triage block should not fire for quick-task units",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: triage dispatch uses early-return pattern", () => {
|
||||
const triageBlock = autoSrc.slice(
|
||||
autoSrc.indexOf("Triage check: dispatch triage unit"),
|
||||
autoSrc.indexOf("In step mode, pause and show a wizard"),
|
||||
);
|
||||
assert.ok(
|
||||
triageBlock.includes("return; // handleAgentEnd will fire again"),
|
||||
"triage dispatch should return after sending message",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: triage imports hasPendingCaptures and loadPendingCaptures", () => {
|
||||
assert.ok(
|
||||
autoSrc.includes('hasPendingCaptures, loadPendingCaptures, countPendingCaptures') &&
|
||||
autoSrc.includes('from "./captures.js"'),
|
||||
"auto.ts should import capture functions including countPendingCaptures",
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Prompt integration ──────────────────────────────────────────────────────
|
||||
|
||||
test("dispatch: replan prompt builder loads capture context", () => {
|
||||
const src = autoPromptsSrc;
|
||||
assert.ok(
|
||||
src.includes("loadReplanCaptures"),
|
||||
"buildReplanSlicePrompt should load replan captures",
|
||||
);
|
||||
assert.ok(
|
||||
src.includes("captureContext"),
|
||||
"buildReplanSlicePrompt should pass captureContext to template",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: reassess prompt builder loads deferred captures", () => {
|
||||
const src = autoPromptsSrc;
|
||||
assert.ok(
|
||||
src.includes("loadDeferredCaptures"),
|
||||
"buildReassessRoadmapPrompt should load deferred captures",
|
||||
);
|
||||
assert.ok(
|
||||
src.includes("deferredCaptures"),
|
||||
"buildReassessRoadmapPrompt should pass deferredCaptures to template",
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Prompt templates ────────────────────────────────────────────────────────
|
||||
|
||||
test("dispatch: replan prompt template includes captureContext variable", () => {
|
||||
const promptPath = join(__dirname, "..", "prompts", "replan-slice.md");
|
||||
const prompt = readFileSync(promptPath, "utf-8");
|
||||
assert.ok(
|
||||
prompt.includes("{{captureContext}}"),
|
||||
"replan-slice.md should include {{captureContext}}",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: reassess prompt template includes deferredCaptures variable", () => {
|
||||
const promptPath = join(__dirname, "..", "prompts", "reassess-roadmap.md");
|
||||
const prompt = readFileSync(promptPath, "utf-8");
|
||||
assert.ok(
|
||||
prompt.includes("{{deferredCaptures}}"),
|
||||
"reassess-roadmap.md should include {{deferredCaptures}}",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: triage prompt template exists and has classification criteria", () => {
|
||||
const promptPath = join(__dirname, "..", "prompts", "triage-captures.md");
|
||||
const prompt = readFileSync(promptPath, "utf-8");
|
||||
assert.ok(prompt.includes("quick-task"), "should have quick-task classification");
|
||||
assert.ok(prompt.includes("inject"), "should have inject classification");
|
||||
assert.ok(prompt.includes("defer"), "should have defer classification");
|
||||
assert.ok(prompt.includes("replan"), "should have replan classification");
|
||||
assert.ok(prompt.includes("note"), "should have note classification");
|
||||
assert.ok(prompt.includes("{{pendingCaptures}}"), "should have pending captures variable");
|
||||
});
|
||||
|
||||
// ─── Dashboard integration ───────────────────────────────────────────────────
|
||||
|
||||
test("dashboard: AutoDashboardData includes pendingCaptureCount field", () => {
|
||||
assert.ok(
|
||||
autoSrc.includes("pendingCaptureCount"),
|
||||
"auto.ts should have pendingCaptureCount in AutoDashboardData",
|
||||
);
|
||||
});
|
||||
|
||||
test("dashboard: getAutoDashboardData computes pendingCaptureCount", () => {
|
||||
assert.ok(
|
||||
autoSrc.includes("pendingCaptureCount = countPendingCaptures") ||
|
||||
autoSrc.includes("pendingCaptureCount = countPendingCaptures(basePath)"),
|
||||
"getAutoDashboardData should compute pendingCaptureCount from countPendingCaptures (single-read)",
|
||||
);
|
||||
});
|
||||
|
||||
test("dashboard: overlay renders pending captures badge", () => {
|
||||
const overlayPath = join(__dirname, "..", "dashboard-overlay.ts");
|
||||
const overlaySrc = readFileSync(overlayPath, "utf-8");
|
||||
assert.ok(
|
||||
overlaySrc.includes("pendingCaptureCount"),
|
||||
"dashboard-overlay.ts should reference pendingCaptureCount",
|
||||
);
|
||||
assert.ok(
|
||||
overlaySrc.includes("pending capture"),
|
||||
"dashboard-overlay.ts should show pending captures text",
|
||||
);
|
||||
});
|
||||
|
||||
test("dashboard: overlay labels triage-captures and quick-task unit types", () => {
|
||||
const overlayPath = join(__dirname, "..", "dashboard-overlay.ts");
|
||||
const overlaySrc = readFileSync(overlayPath, "utf-8");
|
||||
assert.ok(
|
||||
overlaySrc.includes('"triage-captures"'),
|
||||
"unitLabel should handle triage-captures",
|
||||
);
|
||||
assert.ok(
|
||||
overlaySrc.includes('"quick-task"'),
|
||||
"unitLabel should handle quick-task",
|
||||
);
|
||||
});
|
||||
215
src/resources/extensions/gsd/tests/triage-resolution.test.ts
Normal file
215
src/resources/extensions/gsd/tests/triage-resolution.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* Unit tests for GSD Triage Resolution — resolution execution and file overlap detection.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
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 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";
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
const dir = join(
|
||||
tmpdir(),
|
||||
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function setupPlanFile(tmp: string, mid: string, sid: string, content: string): string {
|
||||
const planDir = join(tmp, ".gsd", "milestones", mid, "slices", sid);
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
const planPath = join(planDir, `${sid}-PLAN.md`);
|
||||
writeFileSync(planPath, content, "utf-8");
|
||||
return planPath;
|
||||
}
|
||||
|
||||
const SAMPLE_PLAN = `# S01: Test Slice
|
||||
|
||||
**Goal:** Test
|
||||
**Demo:** Test
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- Something works
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] **T01: First task** \`est:1h\`
|
||||
- Why: Setup
|
||||
- Files: \`src/foo.ts\`, \`src/bar.ts\`
|
||||
- Do: Build it
|
||||
- Done when: Tests pass
|
||||
|
||||
- [ ] **T02: Second task** \`est:1h\`
|
||||
- Why: Feature
|
||||
- Files: \`src/baz.ts\`, \`src/qux.ts\`
|
||||
- Do: Build it
|
||||
- Done when: Tests pass
|
||||
|
||||
- [ ] **T03: Third task** \`est:30m\`
|
||||
- Why: Polish
|
||||
- Files: \`src/qux.ts\`, \`src/config.ts\`
|
||||
- Do: Build it
|
||||
- Done when: Tests pass
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- \`src/foo.ts\`
|
||||
- \`src/bar.ts\`
|
||||
`;
|
||||
|
||||
// ─── executeInject ────────────────────────────────────────────────────────────
|
||||
|
||||
test("resolution: executeInject appends a new task to the plan", () => {
|
||||
const tmp = makeTempDir("res-inject");
|
||||
try {
|
||||
const planPath = setupPlanFile(tmp, "M001", "S01", SAMPLE_PLAN);
|
||||
const captureId = appendCapture(tmp, "add retry logic");
|
||||
const captures = loadAllCaptures(tmp);
|
||||
const capture = captures[0];
|
||||
|
||||
const newId = executeInject(tmp, "M001", "S01", capture);
|
||||
|
||||
assert.strictEqual(newId, "T04", "should be T04 (next after T03)");
|
||||
|
||||
const updated = readFileSync(planPath, "utf-8");
|
||||
assert.ok(updated.includes("**T04:"), "should have T04 in plan");
|
||||
assert.ok(updated.includes(capture.text), "should include capture text");
|
||||
assert.ok(updated.includes("## Files Likely Touched"), "should preserve files section");
|
||||
|
||||
// T04 should appear before Files Likely Touched
|
||||
const t04Pos = updated.indexOf("**T04:");
|
||||
const filesPos = updated.indexOf("## Files Likely Touched");
|
||||
assert.ok(t04Pos < filesPos, "T04 should be before Files section");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolution: executeInject returns null when plan doesn't exist", () => {
|
||||
const tmp = makeTempDir("res-inject-noplan");
|
||||
try {
|
||||
const captureId = appendCapture(tmp, "some task");
|
||||
const captures = loadAllCaptures(tmp);
|
||||
const result = executeInject(tmp, "M001", "S01", captures[0]);
|
||||
assert.strictEqual(result, null);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── executeReplan ────────────────────────────────────────────────────────────
|
||||
|
||||
test("resolution: executeReplan writes REPLAN-TRIGGER.md", () => {
|
||||
const tmp = makeTempDir("res-replan");
|
||||
try {
|
||||
setupPlanFile(tmp, "M001", "S01", SAMPLE_PLAN);
|
||||
const captureId = appendCapture(tmp, "approach is wrong, need different strategy");
|
||||
const captures = loadAllCaptures(tmp);
|
||||
const capture = captures[0];
|
||||
|
||||
const result = executeReplan(tmp, "M001", "S01", capture);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const triggerPath = join(
|
||||
tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-REPLAN-TRIGGER.md",
|
||||
);
|
||||
assert.ok(existsSync(triggerPath), "trigger file should exist");
|
||||
|
||||
const content = readFileSync(triggerPath, "utf-8");
|
||||
assert.ok(content.includes(capture.id), "should include capture ID");
|
||||
assert.ok(content.includes(capture.text), "should include capture text");
|
||||
assert.ok(content.includes("# Replan Trigger"), "should have header");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── detectFileOverlap ───────────────────────────────────────────────────────
|
||||
|
||||
test("resolution: detectFileOverlap finds overlapping incomplete tasks", () => {
|
||||
const overlaps = detectFileOverlap(["src/qux.ts"], SAMPLE_PLAN);
|
||||
assert.deepStrictEqual(overlaps, ["T02", "T03"]);
|
||||
});
|
||||
|
||||
test("resolution: detectFileOverlap ignores completed tasks", () => {
|
||||
// T01 is [x] and uses src/foo.ts — should NOT be returned
|
||||
const overlaps = detectFileOverlap(["src/foo.ts"], SAMPLE_PLAN);
|
||||
assert.deepStrictEqual(overlaps, []);
|
||||
});
|
||||
|
||||
test("resolution: detectFileOverlap returns empty when no overlap", () => {
|
||||
const overlaps = detectFileOverlap(["src/unrelated.ts"], SAMPLE_PLAN);
|
||||
assert.deepStrictEqual(overlaps, []);
|
||||
});
|
||||
|
||||
test("resolution: detectFileOverlap returns empty for empty affected files", () => {
|
||||
assert.deepStrictEqual(detectFileOverlap([], SAMPLE_PLAN), []);
|
||||
});
|
||||
|
||||
test("resolution: detectFileOverlap is case-insensitive", () => {
|
||||
const overlaps = detectFileOverlap(["SRC/QUX.TS"], SAMPLE_PLAN);
|
||||
assert.deepStrictEqual(overlaps, ["T02", "T03"]);
|
||||
});
|
||||
|
||||
// ─── loadDeferredCaptures / loadReplanCaptures ───────────────────────────────
|
||||
|
||||
test("resolution: loadDeferredCaptures returns only deferred captures", () => {
|
||||
const tmp = makeTempDir("res-deferred");
|
||||
try {
|
||||
const id1 = appendCapture(tmp, "deferred one");
|
||||
const id2 = appendCapture(tmp, "note one");
|
||||
const id3 = appendCapture(tmp, "deferred two");
|
||||
|
||||
markCaptureResolved(tmp, id1, "defer", "deferred to S03", "future work");
|
||||
markCaptureResolved(tmp, id2, "note", "acknowledged", "just a note");
|
||||
markCaptureResolved(tmp, id3, "defer", "deferred to S04", "later");
|
||||
|
||||
const deferred = loadDeferredCaptures(tmp);
|
||||
assert.strictEqual(deferred.length, 2);
|
||||
assert.strictEqual(deferred[0].id, id1);
|
||||
assert.strictEqual(deferred[1].id, id3);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolution: loadReplanCaptures returns only replan captures", () => {
|
||||
const tmp = makeTempDir("res-replan-load");
|
||||
try {
|
||||
const id1 = appendCapture(tmp, "needs replan");
|
||||
const id2 = appendCapture(tmp, "just a note");
|
||||
|
||||
markCaptureResolved(tmp, id1, "replan", "replan triggered", "approach changed");
|
||||
markCaptureResolved(tmp, id2, "note", "acknowledged", "info only");
|
||||
|
||||
const replans = loadReplanCaptures(tmp);
|
||||
assert.strictEqual(replans.length, 1);
|
||||
assert.strictEqual(replans[0].id, id1);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── buildQuickTaskPrompt ────────────────────────────────────────────────────
|
||||
|
||||
test("resolution: buildQuickTaskPrompt includes capture text and ID", () => {
|
||||
const prompt = buildQuickTaskPrompt({
|
||||
id: "CAP-abc123",
|
||||
text: "add retry logic to OAuth",
|
||||
timestamp: "2026-03-15T20:00:00Z",
|
||||
status: "resolved",
|
||||
classification: "quick-task",
|
||||
});
|
||||
|
||||
assert.ok(prompt.includes("CAP-abc123"), "should include capture ID");
|
||||
assert.ok(prompt.includes("add retry logic to OAuth"), "should include capture text");
|
||||
assert.ok(prompt.includes("Quick Task"), "should have Quick Task header");
|
||||
assert.ok(prompt.includes("Do NOT modify"), "should warn about plan files");
|
||||
});
|
||||
200
src/resources/extensions/gsd/triage-resolution.ts
Normal file
200
src/resources/extensions/gsd/triage-resolution.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* GSD Triage Resolution — Execute triage classifications
|
||||
*
|
||||
* Provides resolution executors for each capture classification type:
|
||||
*
|
||||
* - inject: appends a new task to the current slice plan
|
||||
* - replan: writes REPLAN-TRIGGER.md so next dispatchNextUnit enters replanning-slice
|
||||
* - defer/note: query helpers for loading deferred/replan captures
|
||||
*
|
||||
* Also provides detectFileOverlap() for surfacing downstream impact on quick tasks.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Classification, CaptureEntry } from "./captures.js";
|
||||
import {
|
||||
loadPendingCaptures,
|
||||
loadAllCaptures,
|
||||
markCaptureResolved,
|
||||
} from "./captures.js";
|
||||
|
||||
// ─── Resolution Executors ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inject a new task into the current slice plan.
|
||||
* Reads the plan, finds the highest task ID, appends a new task entry.
|
||||
* Returns the new task ID, or null if injection failed.
|
||||
*/
|
||||
export function executeInject(
|
||||
basePath: string,
|
||||
mid: string,
|
||||
sid: string,
|
||||
capture: CaptureEntry,
|
||||
): string | null {
|
||||
try {
|
||||
// Resolve the plan file path
|
||||
const planPath = join(basePath, ".gsd", "milestones", mid, "slices", sid, `${sid}-PLAN.md`);
|
||||
if (!existsSync(planPath)) return null;
|
||||
|
||||
const content = readFileSync(planPath, "utf-8");
|
||||
|
||||
// Find the highest existing task ID
|
||||
const taskMatches = [...content.matchAll(/- \[[ x]\] \*\*T(\d+):/g)];
|
||||
if (taskMatches.length === 0) return null;
|
||||
|
||||
const maxId = Math.max(...taskMatches.map(m => parseInt(m[1], 10)));
|
||||
const newId = `T${String(maxId + 1).padStart(2, "0")}`;
|
||||
|
||||
// Build the new task entry
|
||||
const newTask = [
|
||||
`- [ ] **${newId}: ${capture.text}** \`est:30m\``,
|
||||
` - Why: Injected from capture ${capture.id} during triage`,
|
||||
` - Do: ${capture.text}`,
|
||||
` - Done when: Capture intent fulfilled`,
|
||||
].join("\n");
|
||||
|
||||
// Find the last task entry and append after it
|
||||
// Look for the "## Files Likely Touched" section as the boundary
|
||||
const filesSection = content.indexOf("## Files Likely Touched");
|
||||
if (filesSection !== -1) {
|
||||
const updated = content.slice(0, filesSection) + newTask + "\n\n" + content.slice(filesSection);
|
||||
writeFileSync(planPath, updated, "utf-8");
|
||||
} else {
|
||||
// No Files section — append at end
|
||||
writeFileSync(planPath, content.trimEnd() + "\n\n" + newTask + "\n", "utf-8");
|
||||
}
|
||||
|
||||
return newId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger replanning by writing a REPLAN-TRIGGER.md marker file.
|
||||
* The existing state.ts derivation detects this and sets phase to "replanning-slice".
|
||||
* Returns true if the trigger was written successfully.
|
||||
*/
|
||||
export function executeReplan(
|
||||
basePath: string,
|
||||
mid: string,
|
||||
sid: string,
|
||||
capture: CaptureEntry,
|
||||
): boolean {
|
||||
try {
|
||||
const triggerPath = join(
|
||||
basePath, ".gsd", "milestones", mid, "slices", sid, `${sid}-REPLAN-TRIGGER.md`,
|
||||
);
|
||||
const content = [
|
||||
`# Replan Trigger`,
|
||||
``,
|
||||
`**Source:** Capture ${capture.id}`,
|
||||
`**Capture:** ${capture.text}`,
|
||||
`**Rationale:** ${capture.rationale ?? "User-initiated replan via capture triage"}`,
|
||||
`**Triggered:** ${new Date().toISOString()}`,
|
||||
``,
|
||||
`This file was created by the triage pipeline. The next dispatch cycle`,
|
||||
`will detect it and enter the replanning-slice phase.`,
|
||||
].join("\n");
|
||||
|
||||
writeFileSync(triggerPath, content, "utf-8");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── File Overlap Detection ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect file overlap between a capture's affected files and planned tasks.
|
||||
*
|
||||
* Parses the slice plan for task file references and returns task IDs
|
||||
* whose files overlap with the capture's affected files.
|
||||
*
|
||||
* @param affectedFiles - Files the capture would touch
|
||||
* @param planContent - Content of the slice plan.md
|
||||
* @returns Array of task IDs (e.g., ["T03", "T04"]) whose files overlap
|
||||
*/
|
||||
export function detectFileOverlap(
|
||||
affectedFiles: string[],
|
||||
planContent: string,
|
||||
): string[] {
|
||||
if (!affectedFiles || affectedFiles.length === 0) return [];
|
||||
|
||||
const overlappingTasks: string[] = [];
|
||||
|
||||
// Normalize affected files for comparison
|
||||
const normalizedAffected = new Set(
|
||||
affectedFiles.map(f => f.replace(/^\.\//, "").toLowerCase()),
|
||||
);
|
||||
|
||||
// Parse plan for incomplete tasks and their file references
|
||||
const taskPattern = /- \[ \] \*\*(T\d+):[^*]*\*\*/g;
|
||||
const tasks = [...planContent.matchAll(taskPattern)];
|
||||
|
||||
for (const taskMatch of tasks) {
|
||||
const taskId = taskMatch[1];
|
||||
const taskStart = taskMatch.index!;
|
||||
|
||||
// Find the end of this task (next task or end of section)
|
||||
const nextTask = planContent.indexOf("- [", taskStart + 1);
|
||||
const sectionEnd = planContent.indexOf("##", taskStart + 1);
|
||||
const taskEnd = Math.min(
|
||||
nextTask === -1 ? planContent.length : nextTask,
|
||||
sectionEnd === -1 ? planContent.length : sectionEnd,
|
||||
);
|
||||
|
||||
const taskContent = planContent.slice(taskStart, taskEnd);
|
||||
|
||||
// Extract file references — look for backtick-quoted paths
|
||||
const fileRefs = [...taskContent.matchAll(/`([^`]+\.[a-z]+)`/g)]
|
||||
.map(m => m[1].replace(/^\.\//, "").toLowerCase());
|
||||
|
||||
// Check for overlap
|
||||
const hasOverlap = fileRefs.some(f => normalizedAffected.has(f));
|
||||
if (hasOverlap) {
|
||||
overlappingTasks.push(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
return overlappingTasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load deferred captures (classification === "defer") for injection into
|
||||
* reassess-roadmap prompts.
|
||||
*/
|
||||
export function loadDeferredCaptures(basePath: string): CaptureEntry[] {
|
||||
return loadAllCaptures(basePath).filter(c => c.classification === "defer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load replan-triggering captures for injection into replan-slice prompts.
|
||||
*/
|
||||
export function loadReplanCaptures(basePath: string): CaptureEntry[] {
|
||||
return loadAllCaptures(basePath).filter(c => c.classification === "replan");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a quick-task execution prompt from a capture.
|
||||
*/
|
||||
export function buildQuickTaskPrompt(capture: CaptureEntry): string {
|
||||
return [
|
||||
`You are executing a quick one-off task captured during a GSD auto-mode session.`,
|
||||
``,
|
||||
`## Quick Task`,
|
||||
``,
|
||||
`**Capture ID:** ${capture.id}`,
|
||||
`**Task:** ${capture.text}`,
|
||||
``,
|
||||
`## Instructions`,
|
||||
``,
|
||||
`1. Execute this task as a small, self-contained change.`,
|
||||
`2. Do NOT modify any \`.gsd/\` plan files — this is a one-off, not a planned task.`,
|
||||
`3. Commit your changes with a descriptive message.`,
|
||||
`4. Keep changes minimal and focused on the capture text.`,
|
||||
`5. When done, say: "Quick task complete."`,
|
||||
].join("\n");
|
||||
}
|
||||
175
src/resources/extensions/gsd/triage-ui.ts
Normal file
175
src/resources/extensions/gsd/triage-ui.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* GSD Triage UI — Confirmation flow for programmatic triage results
|
||||
*
|
||||
* Used by auto-mode dispatch (S02) when triage fires between tasks.
|
||||
* For manual `/gsd triage`, the LLM session handles confirmation directly.
|
||||
*
|
||||
* This module provides `showTriageConfirmation` which presents each
|
||||
* triage result to the user via `showNextAction` and returns the
|
||||
* confirmed classifications.
|
||||
*/
|
||||
|
||||
import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
|
||||
import { showNextAction } from "../shared/next-action-ui.js";
|
||||
import type { CaptureEntry, Classification, TriageResult } from "./captures.js";
|
||||
import { markCaptureResolved } from "./captures.js";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ConfirmedTriage {
|
||||
captureId: string;
|
||||
classification: Classification;
|
||||
rationale: string;
|
||||
affectedFiles?: string[];
|
||||
targetSlice?: string;
|
||||
userOverride: boolean; // true if user changed the proposed classification
|
||||
}
|
||||
|
||||
// ─── Classification Labels ────────────────────────────────────────────────────
|
||||
|
||||
const CLASSIFICATION_LABELS: Record<Classification, { label: string; description: string }> = {
|
||||
"quick-task": {
|
||||
label: "Quick task",
|
||||
description: "Execute as a one-off at the next seam — no plan modification.",
|
||||
},
|
||||
"inject": {
|
||||
label: "Inject into plan",
|
||||
description: "Add a new task to the current slice plan.",
|
||||
},
|
||||
"defer": {
|
||||
label: "Defer",
|
||||
description: "Move to a future slice or milestone — not urgent now.",
|
||||
},
|
||||
"replan": {
|
||||
label: "Replan slice",
|
||||
description: "Remaining tasks need rewriting — triggers slice replan.",
|
||||
},
|
||||
"note": {
|
||||
label: "Note",
|
||||
description: "Informational only — no action needed.",
|
||||
},
|
||||
};
|
||||
|
||||
const ALL_CLASSIFICATIONS: Classification[] = [
|
||||
"quick-task", "inject", "defer", "replan", "note",
|
||||
];
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Present triage results to the user for confirmation.
|
||||
*
|
||||
* For each capture:
|
||||
* - note/defer: auto-confirm (no user interaction needed)
|
||||
* - quick-task/inject/replan: show confirmation UI with proposed + alternatives
|
||||
*
|
||||
* Returns confirmed results with final classifications.
|
||||
* Updates CAPTURES.md with resolved status.
|
||||
*
|
||||
* @param fileOverlaps - Map of captureId → list of planned task IDs whose files overlap
|
||||
*/
|
||||
export async function showTriageConfirmation(
|
||||
ctx: ExtensionCommandContext,
|
||||
triageResults: TriageResult[],
|
||||
captures: CaptureEntry[],
|
||||
basePath: string,
|
||||
fileOverlaps?: Map<string, string[]>,
|
||||
): Promise<ConfirmedTriage[]> {
|
||||
const confirmed: ConfirmedTriage[] = [];
|
||||
const captureMap = new Map(captures.map(c => [c.id, c]));
|
||||
|
||||
for (const result of triageResults) {
|
||||
const capture = captureMap.get(result.captureId);
|
||||
if (!capture) continue;
|
||||
|
||||
// Auto-confirm note and defer — low-impact, no plan modification
|
||||
if (result.classification === "note" || result.classification === "defer") {
|
||||
const resolution = result.classification === "note"
|
||||
? "acknowledged as note"
|
||||
: `deferred${result.targetSlice ? ` to ${result.targetSlice}` : ""}`;
|
||||
|
||||
markCaptureResolved(
|
||||
basePath,
|
||||
result.captureId,
|
||||
result.classification,
|
||||
resolution,
|
||||
result.rationale,
|
||||
);
|
||||
|
||||
confirmed.push({
|
||||
captureId: result.captureId,
|
||||
classification: result.classification,
|
||||
rationale: result.rationale,
|
||||
affectedFiles: result.affectedFiles,
|
||||
targetSlice: result.targetSlice,
|
||||
userOverride: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build summary lines for the confirmation UI
|
||||
const summary: string[] = [
|
||||
`"${capture.text}"`,
|
||||
"",
|
||||
`Proposed: **${CLASSIFICATION_LABELS[result.classification].label}** — ${result.rationale}`,
|
||||
];
|
||||
|
||||
// Add file overlap warning if present
|
||||
const overlaps = fileOverlaps?.get(result.captureId);
|
||||
if (overlaps && overlaps.length > 0) {
|
||||
summary.push("");
|
||||
summary.push(`⚠ Touches files planned for ${overlaps.join(", ")} — consider inject or defer`);
|
||||
}
|
||||
|
||||
if (result.affectedFiles && result.affectedFiles.length > 0) {
|
||||
summary.push("");
|
||||
summary.push(`Files: ${result.affectedFiles.join(", ")}`);
|
||||
}
|
||||
|
||||
// Build action options — proposed first (recommended), then alternatives
|
||||
const proposed = result.classification;
|
||||
const actions = ALL_CLASSIFICATIONS.map(cls => ({
|
||||
id: cls,
|
||||
label: CLASSIFICATION_LABELS[cls].label,
|
||||
description: CLASSIFICATION_LABELS[cls].description,
|
||||
recommended: cls === proposed,
|
||||
}));
|
||||
|
||||
const choice = await showNextAction(ctx as any, {
|
||||
title: `Triage: ${result.captureId}`,
|
||||
summary,
|
||||
actions,
|
||||
notYetMessage: "Capture will remain pending for later triage.",
|
||||
});
|
||||
|
||||
if (choice === "not_yet") {
|
||||
// User skipped — leave capture pending
|
||||
continue;
|
||||
}
|
||||
|
||||
const finalClassification = choice as Classification;
|
||||
const userOverride = finalClassification !== proposed;
|
||||
const resolution = userOverride
|
||||
? `user chose ${finalClassification} (was ${proposed})`
|
||||
: `confirmed as ${finalClassification}`;
|
||||
|
||||
markCaptureResolved(
|
||||
basePath,
|
||||
result.captureId,
|
||||
finalClassification,
|
||||
resolution,
|
||||
userOverride ? `User override: ${result.rationale}` : result.rationale,
|
||||
);
|
||||
|
||||
confirmed.push({
|
||||
captureId: result.captureId,
|
||||
classification: finalClassification,
|
||||
rationale: result.rationale,
|
||||
affectedFiles: result.affectedFiles,
|
||||
targetSlice: result.targetSlice,
|
||||
userOverride,
|
||||
});
|
||||
}
|
||||
|
||||
return confirmed;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue