diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 95051386f..977a7881a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -300,7 +300,7 @@ Doctor rebuilds `STATE.md` from plan and roadmap files on disk and fixes detecte ### "GSD database is not available" -**Symptoms:** `gsd_save_decision`, `gsd_update_requirement`, or `gsd_save_summary` fail with this error. +**Symptoms:** `gsd_decision_save` (or its alias `gsd_save_decision`), `gsd_requirement_update` (or `gsd_update_requirement`), or `gsd_summary_save` (or `gsd_save_summary`) fail with this error. **Cause:** The SQLite database wasn't initialized. This happens in manual `/gsd` sessions (non-auto mode) on versions before v2.29. diff --git a/mintlify-docs/docs b/mintlify-docs/docs new file mode 160000 index 000000000..5c549fdff --- /dev/null +++ b/mintlify-docs/docs @@ -0,0 +1 @@ +Subproject commit 5c549fdffb1eb56cacec19d33b8157a3b1e19d3c diff --git a/native/crates/engine/src/text.rs b/native/crates/engine/src/text.rs index 1f080741d..526107af3 100644 --- a/native/crates/engine/src/text.rs +++ b/native/crates/engine/src/text.rs @@ -498,15 +498,91 @@ fn visible_width_u16(data: &[u16], tab_width: usize) -> usize { // wrapTextWithAnsi // ============================================================================ -#[inline] -fn write_active_codes(state: &AnsiState, out: &mut Vec) { - if !state.is_empty() { - state.write_restore_u16(out); +// OSC 8 hyperlink state — tracks the active hyperlink URL (if any) so we can +// close it before a line break and re-open it on the next line. +#[derive(Clone, Default)] +struct Osc8State { + /// The full OSC 8 open sequence (e.g. ESC ]8;params;uri BEL), stored as + /// UTF-16 code units. Empty means no active hyperlink. + open_seq: Vec, +} + +impl Osc8State { + fn new() -> Self { + Self { open_seq: Vec::new() } + } + + fn is_active(&self) -> bool { + !self.open_seq.is_empty() + } + + /// Write the OSC 8 close sequence: ESC ]8;; BEL + fn write_close(out: &mut Vec) { + out.extend_from_slice(&[ESC, b']' as u16, b'8' as u16, b';' as u16, b';' as u16, 0x07]); + } + + /// Write the stored open sequence to re-open the hyperlink. + fn write_open(&self, out: &mut Vec) { + if self.is_active() { + out.extend_from_slice(&self.open_seq); + } + } + + /// Parse an OSC sequence and update state. Returns true if it was an OSC 8. + fn update_from_osc(&mut self, seq: &[u16]) -> bool { + // OSC 8 format: ESC ]8; params ; uri BEL (or ST) + // Minimum: ESC ]8;; BEL = 6 code units + if seq.len() < 6 { + return false; + } + if seq[0] != ESC || seq[1] != b']' as u16 || seq[2] != b'8' as u16 || seq[3] != b';' as u16 { + return false; + } + // Find the second semicolon that separates params from URI + let mut second_semi = None; + for i in 4..seq.len() { + if seq[i] == b';' as u16 { + second_semi = Some(i); + break; + } + } + let second_semi = match second_semi { + Some(i) => i, + None => return false, + }; + // URI is between second_semi+1 and the terminator (BEL or ST) + let uri_start = second_semi + 1; + // Terminator is at the end (BEL = 1 unit, ST = 2 units) + let terminator_len = if *seq.last().unwrap() == 0x07 { 1 } else { 2 }; + let uri_end = seq.len() - terminator_len; + if uri_start >= uri_end { + // Empty URI = close hyperlink + self.open_seq.clear(); + } else { + // Non-empty URI = open hyperlink + self.open_seq = seq.to_vec(); + } + true } } +fn is_osc_u16(seq: &[u16]) -> bool { + seq.len() >= 3 && seq[0] == ESC && seq[1] == b']' as u16 +} + #[inline] -fn write_line_end_reset(state: &AnsiState, out: &mut Vec) { +fn write_active_codes(state: &AnsiState, osc8: &Osc8State, out: &mut Vec) { + if !state.is_empty() { + state.write_restore_u16(out); + } + osc8.write_open(out); +} + +#[inline] +fn write_line_end_reset(state: &AnsiState, osc8: &Osc8State, out: &mut Vec) { + if osc8.is_active() { + Osc8State::write_close(out); + } let has_underline = state.attrs & ATTR_UNDERLINE != 0; let has_strike = state.attrs & ATTR_STRIKE != 0; if !has_underline && !has_strike { @@ -526,7 +602,7 @@ fn write_line_end_reset(state: &AnsiState, out: &mut Vec) { out.push(b'm' as u16); } -fn update_state_from_text(data: &[u16], state: &mut AnsiState) { +fn update_state_from_text(data: &[u16], state: &mut AnsiState, osc8: &mut Osc8State) { let mut i = 0usize; while i < data.len() { if data[i] == ESC { @@ -534,6 +610,8 @@ fn update_state_from_text(data: &[u16], state: &mut AnsiState) { let seq = &data[i..i + seq_len]; if is_sgr_u16(seq) { state.apply_sgr_u16(&seq[2..seq_len - 1]); + } else if is_osc_u16(seq) { + osc8.update_from_osc(seq); } i += seq_len; continue; @@ -619,10 +697,11 @@ fn break_long_word( width: usize, tab_width: usize, state: &mut AnsiState, + osc8: &mut Osc8State, ) -> SmallVec<[Vec; 4]> { let mut lines = SmallVec::<[Vec; 4]>::new(); let mut current_line = Vec::::new(); - write_active_codes(state, &mut current_line); + write_active_codes(state, osc8, &mut current_line); let mut current_width = 0usize; let mut i = 0usize; @@ -633,6 +712,8 @@ fn break_long_word( current_line.extend_from_slice(seq); if is_sgr_u16(seq) { state.apply_sgr_u16(&seq[2..seq_len - 1]); + } else if is_osc_u16(seq) { + osc8.update_from_osc(seq); } i += seq_len; continue; @@ -653,10 +734,10 @@ fn break_long_word( for &u in seg { let gw = ascii_cell_width_u16(u, tab_width); if current_width + gw > width { - write_line_end_reset(state, &mut current_line); + write_line_end_reset(state, osc8, &mut current_line); lines.push(current_line); current_line = Vec::new(); - write_active_codes(state, &mut current_line); + write_active_codes(state, osc8, &mut current_line); current_width = 0; } current_line.push(u); @@ -665,9 +746,9 @@ fn break_long_word( } else { let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { if current_width + gw > width { - write_line_end_reset(state, &mut current_line); + write_line_end_reset(state, osc8, &mut current_line); lines.push(std::mem::take(&mut current_line)); - write_active_codes(state, &mut current_line); + write_active_codes(state, osc8, &mut current_line); current_width = 0; } current_line.extend_from_slice(gu16); @@ -698,6 +779,7 @@ fn wrap_single_line(line: &[u16], width: usize, tab_width: usize) -> SmallVec<[V let mut current_line = Vec::::new(); let mut current_width = 0usize; let mut state = AnsiState::new(); + let mut osc8 = Osc8State::new(); for token in tokens { let token_width = visible_width_u16(&token, tab_width); @@ -705,13 +787,13 @@ fn wrap_single_line(line: &[u16], width: usize, tab_width: usize) -> SmallVec<[V if token_width > width && !is_whitespace { if !current_line.is_empty() { - write_line_end_reset(&state, &mut current_line); + write_line_end_reset(&state, &osc8, &mut current_line); wrapped.push(current_line); current_line = Vec::new(); current_width = 0; } - let mut broken = break_long_word(&token, width, tab_width, &mut state); + let mut broken = break_long_word(&token, width, tab_width, &mut state, &mut osc8); if let Some(last) = broken.pop() { wrapped.extend(broken); current_line = last; @@ -724,11 +806,11 @@ fn wrap_single_line(line: &[u16], width: usize, tab_width: usize) -> SmallVec<[V if total_needed > width && current_width > 0 { let mut line_to_wrap = current_line; trim_end_spaces_in_place(&mut line_to_wrap); - write_line_end_reset(&state, &mut line_to_wrap); + write_line_end_reset(&state, &osc8, &mut line_to_wrap); wrapped.push(line_to_wrap); current_line = Vec::new(); - write_active_codes(&state, &mut current_line); + write_active_codes(&state, &osc8, &mut current_line); if is_whitespace { current_width = 0; } else { @@ -740,7 +822,7 @@ fn wrap_single_line(line: &[u16], width: usize, tab_width: usize) -> SmallVec<[V current_width += token_width; } - update_state_from_text(&token, &mut state); + update_state_from_text(&token, &mut state, &mut osc8); } if !current_line.is_empty() { @@ -769,6 +851,7 @@ fn wrap_text_with_ansi_impl( let mut result = SmallVec::<[Vec; 4]>::new(); let mut state = AnsiState::new(); + let mut osc8 = Osc8State::new(); let mut line_start = 0usize; for i in 0..=text.len() { @@ -776,13 +859,13 @@ fn wrap_text_with_ansi_impl( let line = &text[line_start..i]; let mut line_with_prefix: Vec = Vec::new(); if !result.is_empty() { - write_active_codes(&state, &mut line_with_prefix); + write_active_codes(&state, &osc8, &mut line_with_prefix); } line_with_prefix.extend_from_slice(line); let wrapped = wrap_single_line(&line_with_prefix, width, tab_width); result.extend(wrapped); - update_state_from_text(line, &mut state); + update_state_from_text(line, &mut state, &mut osc8); line_start = i + 1; } } @@ -1526,6 +1609,53 @@ mod tests { assert_eq!(state.fg, 0x1000000 | (255 << 16) | (128 << 8) | 0); } + #[test] + fn test_wrap_text_osc8_hyperlink_carried_across_lines() { + // OSC 8 hyperlink wrapping: \x1b]8;;https://example.com\x07click here please\x1b]8;;\x07 + let url = "https://example.com"; + let open = format!("\x1b]8;;{}\x07", url); + let close = "\x1b]8;;\x07"; + let text = format!("{}click here please{}", open, close); + let data = to_u16(&text); + // Width 10 forces "click here please" (18 chars) to wrap + let lines = wrap_text_with_ansi_impl(&data, 10, DEFAULT_TAB_WIDTH); + assert!(lines.len() >= 2, "Expected wrapping, got {} lines", lines.len()); + + let first = String::from_utf16_lossy(&lines[0]); + let second = String::from_utf16_lossy(&lines[1]); + + // First line should open the hyperlink and close it at the end + assert!(first.starts_with(&open), "First line should start with OSC 8 open: {:?}", first); + assert!(first.ends_with(close), "First line should end with OSC 8 close: {:?}", first); + + // Second line should re-open the hyperlink + assert!(second.starts_with(&open), "Second line should re-open OSC 8: {:?}", second); + } + + #[test] + fn test_wrap_text_osc8_long_url_break() { + // A long URL wrapped inside an OSC 8 hyperlink + let url = "https://accounts.google.com/o/oauth2/v2/auth?client_id=abc&redirect_uri=http://localhost:9004&scope=email&state=xyz"; + let open = format!("\x1b]8;;{}\x07", url); + let close = "\x1b]8;;\x07"; + let text = format!("{}{}{}", open, url, close); + let data = to_u16(&text); + let lines = wrap_text_with_ansi_impl(&data, 40, DEFAULT_TAB_WIDTH); + assert!(lines.len() >= 2, "Expected wrapping, got {} lines", lines.len()); + + for (i, line) in lines.iter().enumerate() { + let s = String::from_utf16_lossy(line); + // Every line except possibly the last (which has the close) should + // have the OSC 8 open sequence + assert!(s.contains(&open) || s.contains(close), + "Line {} should contain OSC 8 open or close: {:?}", i, s); + } + + // Last line should contain the close + let last = String::from_utf16_lossy(lines.last().unwrap()); + assert!(last.contains(close), "Last line should contain OSC 8 close: {:?}", last); + } + #[test] fn test_clamp_u32_helper() { assert_eq!(clamp_u32(0), 0); diff --git a/packages/native/src/__tests__/text.test.mjs b/packages/native/src/__tests__/text.test.mjs index 1c101a7e6..1ca4f2783 100644 --- a/packages/native/src/__tests__/text.test.mjs +++ b/packages/native/src/__tests__/text.test.mjs @@ -130,6 +130,39 @@ describe("wrapTextWithAnsi", () => { assert.equal(lines[0], "abcde"); assert.equal(lines[1], "fghij"); }); + + test("carries OSC 8 hyperlink across word-boundary wrap", () => { + const url = "https://example.com"; + const open = `\x1b]8;;${url}\x07`; + const close = `\x1b]8;;\x07`; + const text = `${open}click here please${close}`; + const lines = native.wrapTextWithAnsi(text, 10); + assert.ok(lines.length >= 2, `Expected wrapping, got ${lines.length} lines`); + + // First line should open the hyperlink and close it at the end + assert.ok(lines[0].startsWith(open), `First line should start with OSC 8 open: ${JSON.stringify(lines[0])}`); + assert.ok(lines[0].endsWith(close), `First line should end with OSC 8 close: ${JSON.stringify(lines[0])}`); + + // Second line should re-open the hyperlink + assert.ok(lines[1].startsWith(open), `Second line should re-open OSC 8: ${JSON.stringify(lines[1])}`); + }); + + test("carries OSC 8 hyperlink across long-word break", () => { + const url = "https://accounts.google.com/o/oauth2/v2/auth?client_id=abc&redirect_uri=http://localhost:9004&scope=email&state=xyz"; + const open = `\x1b]8;;${url}\x07`; + const close = `\x1b]8;;\x07`; + const text = `${open}${url}${close}`; + const lines = native.wrapTextWithAnsi(text, 40); + assert.ok(lines.length >= 2, `Expected wrapping, got ${lines.length} lines`); + + // Every line except the last should end with close and re-open on next + for (let i = 0; i < lines.length - 1; i++) { + assert.ok(lines[i].includes(open), `Line ${i} should contain OSC 8 open`); + assert.ok(lines[i].endsWith(close), `Line ${i} should end with OSC 8 close`); + } + // Last line should contain close + assert.ok(lines[lines.length - 1].includes(close), `Last line should contain OSC 8 close`); + }); }); // ── truncateToWidth ──────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index 4f84e973e..97ee888fb 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -54,9 +54,11 @@ export type DispatchAction = unitId: string; prompt: string; pauseAfterDispatch?: boolean; + /** Name of the matched dispatch rule from the unified registry (journal provenance). */ + matchedRule?: string; } - | { action: "stop"; reason: string; level: "info" | "warning" | "error" } - | { action: "skip" }; + | { action: "stop"; reason: string; level: "info" | "warning" | "error"; matchedRule?: string } + | { action: "skip"; matchedRule?: string }; export interface DispatchContext { basePath: string; @@ -67,7 +69,7 @@ export interface DispatchContext { session?: import("./auto/session.js").AutoSession; } -interface DispatchRule { +export interface DispatchRule { /** Human-readable name for debugging and test identification */ name: string; /** Return a DispatchAction if this rule matches, null to fall through */ @@ -88,7 +90,7 @@ const MAX_REWRITE_ATTEMPTS = 3; // ─── Rules ──────────────────────────────────────────────────────────────── -const DISPATCH_RULES: DispatchRule[] = [ +export const DISPATCH_RULES: DispatchRule[] = [ { name: "rewrite-docs (override gate)", match: async ({ mid, midTitle, state, basePath, session }) => { @@ -608,18 +610,35 @@ const DISPATCH_RULES: DispatchRule[] = [ }, ]; +import { getRegistry } from "./rule-registry.js"; + // ─── Resolver ───────────────────────────────────────────────────────────── /** * Evaluate dispatch rules in order. Returns the first matching action, * or a "stop" action if no rule matches (unhandled phase). + * + * Delegates to the RuleRegistry when initialized; falls back to inline + * loop over DISPATCH_RULES for backward compatibility (tests that import + * resolveDispatch directly without registry initialization). */ export async function resolveDispatch( ctx: DispatchContext, ): Promise { + // Delegate to registry when available + try { + const registry = getRegistry(); + return await registry.evaluateDispatch(ctx); + } catch { + // Registry not initialized — fall back to inline loop + } + for (const rule of DISPATCH_RULES) { const result = await rule.match(ctx); - if (result) return result; + if (result) { + if (result.action !== "skip") result.matchedRule = rule.name; + return result; + } } // No rule matched — unhandled phase @@ -627,6 +646,7 @@ export async function resolveDispatch( action: "stop", reason: `Unhandled phase "${ctx.state.phase}" — run /gsd doctor to diagnose.`, level: "info", + matchedRule: "", }; } diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 4ad5fa11c..281acf440 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -167,7 +167,9 @@ import { buildLoopRemediationSteps, reconcileMergeState, } from "./auto-recovery.js"; -import { resolveDispatch } from "./auto-dispatch.js"; +import { resolveDispatch, DISPATCH_RULES } from "./auto-dispatch.js"; +import { initRegistry, convertDispatchRules } from "./rule-registry.js"; +import { emitJournalEvent as _emitJournalEvent, type JournalEntry } from "./journal.js"; import { type AutoDashboardData, updateProgressWidget as _updateProgressWidget, @@ -876,6 +878,11 @@ function buildResolver(): WorktreeResolver { * This bundles all private functions that autoLoop needs without exporting them. */ function buildLoopDeps(): LoopDeps { + // Initialize the unified rule registry with converted dispatch rules. + // Must happen before LoopDeps is assembled so facade functions + // (resolveDispatch, runPreDispatchHooks, etc.) delegate to the registry. + initRegistry(convertDispatchRules(DISPATCH_RULES)); + return { lockBase, buildSnapshotOpts, @@ -986,6 +993,9 @@ function buildLoopDeps(): LoopDeps { return ""; } }, + + // Journal + emitJournalEvent: (entry: JournalEntry) => _emitJournalEvent(s.basePath, entry), } as unknown as LoopDeps; } diff --git a/src/resources/extensions/gsd/auto/loop-deps.ts b/src/resources/extensions/gsd/auto/loop-deps.ts index e6a47b911..a7ac10bb1 100644 --- a/src/resources/extensions/gsd/auto/loop-deps.ts +++ b/src/resources/extensions/gsd/auto/loop-deps.ts @@ -19,6 +19,7 @@ import type { import type { DispatchAction } from "../auto-dispatch.js"; import type { WorktreeResolver } from "../worktree-resolver.js"; import type { CmuxLogLevel } from "../../cmux/index.js"; +import type { JournalEntry } from "../journal.js"; /** * Dependencies injected by the caller (auto.ts startAuto) so autoLoop @@ -285,4 +286,7 @@ export interface LoopDeps { // Session manager getSessionFile: (ctx: ExtensionContext) => string; + + // Journal + emitJournalEvent: (entry: JournalEntry) => void; } diff --git a/src/resources/extensions/gsd/auto/loop.ts b/src/resources/extensions/gsd/auto/loop.ts index c2e545851..1287f9770 100644 --- a/src/resources/extensions/gsd/auto/loop.ts +++ b/src/resources/extensions/gsd/auto/loop.ts @@ -9,6 +9,7 @@ import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent"; +import { randomUUID } from "node:crypto"; import type { AutoSession, SidecarItem } from "./session.js"; import type { LoopDeps } from "./loop-deps.js"; import { @@ -51,6 +52,11 @@ export async function autoLoop( iteration++; debugLog("autoLoop", { phase: "loop-top", iteration }); + // ── Journal: per-iteration flow grouping ── + const flowId = randomUUID(); + let seqCounter = 0; + const nextSeq = () => ++seqCounter; + if (iteration > MAX_LOOP_ITERATIONS) { debugLog("autoLoop", { phase: "exit", @@ -84,6 +90,7 @@ export async function autoLoop( unitType: sidecarItem.unitType, unitId: sidecarItem.unitId, }); + deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "sidecar-dequeue", data: { kind: sidecarItem.kind, unitType: sidecarItem.unitType, unitId: sidecarItem.unitId } }); } const sessionLockBase = deps.lockBase(); @@ -106,7 +113,8 @@ export async function autoLoop( } } - const ic: IterationContext = { ctx, pi, s, deps, prefs, iteration }; + const ic: IterationContext = { ctx, pi, s, deps, prefs, iteration, flowId, nextSeq }; + deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-start", data: { iteration } }); let iterData: IterationData; if (!sidecarItem) { @@ -153,6 +161,7 @@ export async function autoLoop( if (finalizeResult.action === "continue") continue; consecutiveErrors = 0; // Iteration completed successfully + deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-end", data: { iteration } }); debugLog("autoLoop", { phase: "iteration-complete", iteration }); } catch (loopErr) { // ── Blanket catch: absorb unexpected exceptions, apply graduated recovery ── diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index b82f7e560..9776fecb6 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -193,6 +193,7 @@ export async function runPreDispatch( // ── Milestone transition ──────────────────────────────────────────── if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) { + deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "milestone-transition", data: { from: s.currentMilestoneId, to: mid } }); ctx.ui.notify( `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, "info", @@ -387,6 +388,7 @@ export async function runPreDispatch( ); } debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" }); + deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "terminal", data: { reason: "no-active-milestone" } }); return { action: "break", reason: "no-active-milestone" }; } @@ -455,6 +457,7 @@ export async function runPreDispatch( ); await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`); debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" }); + deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "terminal", data: { reason: "milestone-complete", milestoneId: mid } }); return { action: "break", reason: "milestone-complete" }; } @@ -466,6 +469,7 @@ export async function runPreDispatch( deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention"); deps.logCmuxEvent(prefs, blockerMsg, "error"); debugLog("autoLoop", { phase: "exit", reason: "blocked" }); + deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "terminal", data: { reason: "blocked", blockers: state.blockers } }); return { action: "break", reason: "blocked" }; } @@ -498,6 +502,7 @@ export async function runDispatch( }); if (dispatchResult.action === "stop") { + deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "dispatch-stop", rule: dispatchResult.matchedRule, data: { reason: dispatchResult.reason } }); await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason); debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" }); return { action: "break", reason: "dispatch-stop" }; @@ -509,6 +514,8 @@ export async function runDispatch( return { action: "continue" }; } + deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "dispatch-match", rule: dispatchResult.matchedRule, data: { unitType: dispatchResult.unitType, unitId: dispatchResult.unitId } }); + let unitType = dispatchResult.unitType; let unitId = dispatchResult.unitId; let prompt = dispatchResult.prompt; @@ -601,6 +608,7 @@ export async function runDispatch( `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, "info", ); + deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "pre-dispatch-hook", data: { firedHooks: preDispatchResult.firedHooks, action: preDispatchResult.action } }); } if (preDispatchResult.action === "skip") { ctx.ui.notify( @@ -846,6 +854,8 @@ export async function runUnitPhase( const previousTier = s.currentUnitRouting?.tier; s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; + const unitStartSeq = ic.nextSeq(); + deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: unitStartSeq, eventType: "unit-start", data: { unitType, unitId } }); deps.captureAvailableSkills(); deps.writeUnitRuntimeRecord( s.basePath, @@ -1149,6 +1159,8 @@ export async function runUnitPhase( s.unitRecoveryCount.delete(`${unitType}/${unitId}`); } + deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "unit-end", data: { unitType, unitId, status: unitResult.status, artifactVerified }, causedBy: { flowId: ic.flowId, seq: unitStartSeq } }); + return { action: "next", data: { unitStartedAt: s.currentUnit.startedAt } }; } diff --git a/src/resources/extensions/gsd/auto/types.ts b/src/resources/extensions/gsd/auto/types.ts index 0fadf7119..748d5a1c7 100644 --- a/src/resources/extensions/gsd/auto/types.ts +++ b/src/resources/extensions/gsd/auto/types.ts @@ -69,6 +69,10 @@ export interface IterationContext { deps: LoopDeps; prefs: GSDPreferences | undefined; iteration: number; + /** UUID grouping all journal events for this iteration. */ + flowId: string; + /** Returns the next monotonically increasing sequence number (1-based, reset per iteration). */ + nextSeq: () => number; } export interface LoopState { diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index ade6cc996..d73401a14 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -5,16 +5,67 @@ import { findMilestoneIds, nextMilestoneId, claimReservedId, getReservedMileston import { loadEffectiveGSDPreferences } from "../preferences.js"; import { ensureDbOpen } from "./dynamic-tools.js"; -export function registerDbTools(pi: ExtensionAPI): void { +/** + * Register an alias tool that shares the same execute function as its canonical counterpart. + * The alias description and promptGuidelines direct the LLM to prefer the canonical name. + */ +function registerAlias(pi: ExtensionAPI, toolDef: any, aliasName: string, canonicalName: string): void { pi.registerTool({ - name: "gsd_save_decision", + ...toolDef, + name: aliasName, + description: toolDef.description + ` (alias for ${canonicalName} — prefer the canonical name)`, + promptGuidelines: [`Alias for ${canonicalName} — prefer the canonical name.`], + }); +} + +export function registerDbTools(pi: ExtensionAPI): void { + // ─── gsd_decision_save (formerly gsd_save_decision) ───────────────────── + + const decisionSaveExecute = async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) { + return { + content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot save decision." }], + details: { operation: "save_decision", error: "db_unavailable" } as any, + }; + } + try { + const { saveDecisionToDb } = await import("../db-writer.js"); + const { id } = await saveDecisionToDb( + { + scope: params.scope, + decision: params.decision, + choice: params.choice, + rationale: params.rationale, + revisable: params.revisable, + when_context: params.when_context, + made_by: params.made_by, + }, + process.cwd(), + ); + return { + content: [{ type: "text" as const, text: `Saved decision ${id}` }], + details: { operation: "save_decision", id } as any, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`gsd-db: gsd_decision_save tool failed: ${msg}\n`); + return { + content: [{ type: "text" as const, text: `Error saving decision: ${msg}` }], + details: { operation: "save_decision", error: msg } as any, + }; + } + }; + + const decisionSaveTool = { + name: "gsd_decision_save", label: "Save Decision", description: "Record a project decision to the GSD database and regenerate DECISIONS.md. " + "Decision IDs are auto-assigned — never provide an ID manually.", promptSnippet: "Record a project decision to the GSD database (auto-assigns ID, regenerates DECISIONS.md)", promptGuidelines: [ - "Use gsd_save_decision when recording an architectural, pattern, library, or observability decision.", + "Use gsd_decision_save when recording an architectural, pattern, library, or observability decision.", "Decision IDs are auto-assigned (D001, D002, ...) — never guess or provide an ID.", "All fields except revisable, when_context, and made_by are required.", "The tool writes to the DB and regenerates .gsd/DECISIONS.md automatically.", @@ -33,52 +84,63 @@ export function registerDbTools(pi: ExtensionAPI): void { Type.Literal("collaborative"), ], { description: "Who made this decision: 'human' (user directed), 'agent' (LLM decided autonomously), or 'collaborative' (discussed and agreed). Default: 'agent'" })), }), - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) { - return { - content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot save decision." }], - details: { operation: "save_decision", error: "db_unavailable" } as any, - }; - } - try { - const { saveDecisionToDb } = await import("../db-writer.js"); - const { id } = await saveDecisionToDb( - { - scope: params.scope, - decision: params.decision, - choice: params.choice, - rationale: params.rationale, - revisable: params.revisable, - when_context: params.when_context, - made_by: params.made_by, - }, - process.cwd(), - ); - return { - content: [{ type: "text" as const, text: `Saved decision ${id}` }], - details: { operation: "save_decision", id } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`gsd-db: gsd_save_decision tool failed: ${msg}\n`); - return { - content: [{ type: "text" as const, text: `Error saving decision: ${msg}` }], - details: { operation: "save_decision", error: msg } as any, - }; - } - }, - }); + execute: decisionSaveExecute, + }; - pi.registerTool({ - name: "gsd_update_requirement", + pi.registerTool(decisionSaveTool); + registerAlias(pi, decisionSaveTool, "gsd_save_decision", "gsd_decision_save"); + + // ─── gsd_requirement_update (formerly gsd_update_requirement) ─────────── + + const requirementUpdateExecute = async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) { + return { + content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot update requirement." }], + details: { operation: "update_requirement", id: params.id, error: "db_unavailable" } as any, + }; + } + try { + const db = await import("../gsd-db.js"); + const existing = db.getRequirementById(params.id); + if (!existing) { + return { + content: [{ type: "text" as const, text: `Error: Requirement ${params.id} not found.` }], + details: { operation: "update_requirement", id: params.id, error: "not_found" } as any, + }; + } + const { updateRequirementInDb } = await import("../db-writer.js"); + const updates: Record = {}; + if (params.status !== undefined) updates.status = params.status; + if (params.validation !== undefined) updates.validation = params.validation; + if (params.notes !== undefined) updates.notes = params.notes; + if (params.description !== undefined) updates.description = params.description; + if (params.primary_owner !== undefined) updates.primary_owner = params.primary_owner; + if (params.supporting_slices !== undefined) updates.supporting_slices = params.supporting_slices; + await updateRequirementInDb(params.id, updates, process.cwd()); + return { + content: [{ type: "text" as const, text: `Updated requirement ${params.id}` }], + details: { operation: "update_requirement", id: params.id } as any, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`gsd-db: gsd_requirement_update tool failed: ${msg}\n`); + return { + content: [{ type: "text" as const, text: `Error updating requirement: ${msg}` }], + details: { operation: "update_requirement", id: params.id, error: msg } as any, + }; + } + }; + + const requirementUpdateTool = { + name: "gsd_requirement_update", label: "Update Requirement", description: "Update an existing requirement in the GSD database and regenerate REQUIREMENTS.md. " + "Provide the requirement ID (e.g. R001) and any fields to update.", promptSnippet: "Update an existing GSD requirement by ID (regenerates REQUIREMENTS.md)", promptGuidelines: [ - "Use gsd_update_requirement to change status, validation, notes, or other fields on an existing requirement.", + "Use gsd_requirement_update to change status, validation, notes, or other fields on an existing requirement.", "The id parameter is required — it must be an existing RXXX identifier.", "All other fields are optional — only provided fields are updated.", "The tool verifies the requirement exists before updating.", @@ -92,56 +154,73 @@ export function registerDbTools(pi: ExtensionAPI): void { primary_owner: Type.Optional(Type.String({ description: "Primary owning slice" })), supporting_slices: Type.Optional(Type.String({ description: "Supporting slices" })), }), - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) { - return { - content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot update requirement." }], - details: { operation: "update_requirement", id: params.id, error: "db_unavailable" } as any, - }; - } - try { - const db = await import("../gsd-db.js"); - const existing = db.getRequirementById(params.id); - if (!existing) { - return { - content: [{ type: "text" as const, text: `Error: Requirement ${params.id} not found.` }], - details: { operation: "update_requirement", id: params.id, error: "not_found" } as any, - }; - } - const { updateRequirementInDb } = await import("../db-writer.js"); - const updates: Record = {}; - if (params.status !== undefined) updates.status = params.status; - if (params.validation !== undefined) updates.validation = params.validation; - if (params.notes !== undefined) updates.notes = params.notes; - if (params.description !== undefined) updates.description = params.description; - if (params.primary_owner !== undefined) updates.primary_owner = params.primary_owner; - if (params.supporting_slices !== undefined) updates.supporting_slices = params.supporting_slices; - await updateRequirementInDb(params.id, updates, process.cwd()); - return { - content: [{ type: "text" as const, text: `Updated requirement ${params.id}` }], - details: { operation: "update_requirement", id: params.id } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`gsd-db: gsd_update_requirement tool failed: ${msg}\n`); - return { - content: [{ type: "text" as const, text: `Error updating requirement: ${msg}` }], - details: { operation: "update_requirement", id: params.id, error: msg } as any, - }; - } - }, - }); + execute: requirementUpdateExecute, + }; - pi.registerTool({ - name: "gsd_save_summary", + pi.registerTool(requirementUpdateTool); + registerAlias(pi, requirementUpdateTool, "gsd_update_requirement", "gsd_requirement_update"); + + // ─── gsd_summary_save (formerly gsd_save_summary) ────────────────────── + + const summarySaveExecute = async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) { + return { + content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot save artifact." }], + details: { operation: "save_summary", error: "db_unavailable" } as any, + }; + } + const validTypes = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT"]; + if (!validTypes.includes(params.artifact_type)) { + return { + content: [{ type: "text" as const, text: `Error: Invalid artifact_type "${params.artifact_type}". Must be one of: ${validTypes.join(", ")}` }], + details: { operation: "save_summary", error: "invalid_artifact_type" } as any, + }; + } + try { + let relativePath: string; + if (params.task_id && params.slice_id) { + relativePath = `milestones/${params.milestone_id}/slices/${params.slice_id}/tasks/${params.task_id}-${params.artifact_type}.md`; + } else if (params.slice_id) { + relativePath = `milestones/${params.milestone_id}/slices/${params.slice_id}/${params.slice_id}-${params.artifact_type}.md`; + } else { + relativePath = `milestones/${params.milestone_id}/${params.milestone_id}-${params.artifact_type}.md`; + } + const { saveArtifactToDb } = await import("../db-writer.js"); + await saveArtifactToDb( + { + path: relativePath, + artifact_type: params.artifact_type, + content: params.content, + milestone_id: params.milestone_id, + slice_id: params.slice_id, + task_id: params.task_id, + }, + process.cwd(), + ); + return { + content: [{ type: "text" as const, text: `Saved ${params.artifact_type} artifact to ${relativePath}` }], + details: { operation: "save_summary", path: relativePath, artifact_type: params.artifact_type } as any, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`gsd-db: gsd_summary_save tool failed: ${msg}\n`); + return { + content: [{ type: "text" as const, text: `Error saving artifact: ${msg}` }], + details: { operation: "save_summary", error: msg } as any, + }; + } + }; + + const summarySaveTool = { + name: "gsd_summary_save", label: "Save Summary", description: "Save a summary, research, context, or assessment artifact to the GSD database and write it to disk. " + "Computes the file path from milestone/slice/task IDs automatically.", promptSnippet: "Save a GSD artifact (summary/research/context/assessment) to DB and disk", promptGuidelines: [ - "Use gsd_save_summary to persist structured artifacts (SUMMARY, RESEARCH, CONTEXT, ASSESSMENT).", + "Use gsd_summary_save to persist structured artifacts (SUMMARY, RESEARCH, CONTEXT, ASSESSMENT).", "milestone_id is required. slice_id and task_id are optional — they determine the file path.", "The tool computes the relative path automatically: milestones/M001/M001-SUMMARY.md, milestones/M001/slices/S01/S01-SUMMARY.md, etc.", "artifact_type must be one of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT.", @@ -153,59 +232,46 @@ export function registerDbTools(pi: ExtensionAPI): void { artifact_type: Type.String({ description: "One of: SUMMARY, RESEARCH, CONTEXT, ASSESSMENT" }), content: Type.String({ description: "The full markdown content of the artifact" }), }), - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) { - return { - content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot save artifact." }], - details: { operation: "save_summary", error: "db_unavailable" } as any, - }; - } - const validTypes = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT"]; - if (!validTypes.includes(params.artifact_type)) { - return { - content: [{ type: "text" as const, text: `Error: Invalid artifact_type "${params.artifact_type}". Must be one of: ${validTypes.join(", ")}` }], - details: { operation: "save_summary", error: "invalid_artifact_type" } as any, - }; - } - try { - let relativePath: string; - if (params.task_id && params.slice_id) { - relativePath = `milestones/${params.milestone_id}/slices/${params.slice_id}/tasks/${params.task_id}-${params.artifact_type}.md`; - } else if (params.slice_id) { - relativePath = `milestones/${params.milestone_id}/slices/${params.slice_id}/${params.slice_id}-${params.artifact_type}.md`; - } else { - relativePath = `milestones/${params.milestone_id}/${params.milestone_id}-${params.artifact_type}.md`; - } - const { saveArtifactToDb } = await import("../db-writer.js"); - await saveArtifactToDb( - { - path: relativePath, - artifact_type: params.artifact_type, - content: params.content, - milestone_id: params.milestone_id, - slice_id: params.slice_id, - task_id: params.task_id, - }, - process.cwd(), - ); - return { - content: [{ type: "text" as const, text: `Saved ${params.artifact_type} artifact to ${relativePath}` }], - details: { operation: "save_summary", path: relativePath, artifact_type: params.artifact_type } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`gsd-db: gsd_save_summary tool failed: ${msg}\n`); - return { - content: [{ type: "text" as const, text: `Error saving artifact: ${msg}` }], - details: { operation: "save_summary", error: msg } as any, - }; - } - }, - }); + execute: summarySaveExecute, + }; - pi.registerTool({ - name: "gsd_generate_milestone_id", + pi.registerTool(summarySaveTool); + registerAlias(pi, summarySaveTool, "gsd_save_summary", "gsd_summary_save"); + + // ─── gsd_milestone_generate_id (formerly gsd_generate_milestone_id) ──── + + const milestoneGenerateIdExecute = async (_toolCallId: any, _params: any, _signal: any, _onUpdate: any, _ctx: any) => { + try { + // Claim a reserved ID if the guided-flow already previewed one to the user. + // This guarantees the ID shown in the UI matches the one materialised on disk. + const reserved = claimReservedId(); + if (reserved) { + return { + content: [{ type: "text" as const, text: reserved }], + details: { operation: "generate_milestone_id", id: reserved, source: "reserved" } as any, + }; + } + + const basePath = process.cwd(); + const existingIds = findMilestoneIds(basePath); + const uniqueEnabled = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; + const allIds = [...new Set([...existingIds, ...getReservedMilestoneIds()])]; + const newId = nextMilestoneId(allIds, uniqueEnabled); + return { + content: [{ type: "text" as const, text: newId }], + details: { operation: "generate_milestone_id", id: newId, existingCount: existingIds.length, uniqueEnabled } as any, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + content: [{ type: "text" as const, text: `Error generating milestone ID: ${msg}` }], + details: { operation: "generate_milestone_id", error: msg } as any, + }; + } + }; + + const milestoneGenerateIdTool = { + name: "gsd_milestone_generate_id", label: "Generate Milestone ID", description: "Generate the next milestone ID for a new GSD milestone. " + @@ -213,41 +279,15 @@ export function registerDbTools(pi: ExtensionAPI): void { "Always use this tool when creating a new milestone — never invent milestone IDs manually.", promptSnippet: "Generate a valid milestone ID (respects unique_milestone_ids preference)", promptGuidelines: [ - "ALWAYS call gsd_generate_milestone_id before creating a new milestone directory or writing milestone files.", + "ALWAYS call gsd_milestone_generate_id before creating a new milestone directory or writing milestone files.", "Never invent or hardcode milestone IDs like M001, M002 — always use this tool.", "Call it once per milestone you need to create. For multi-milestone projects, call it once for each milestone in sequence.", "The tool returns the correct format based on project preferences (e.g. M001 or M001-r5jzab).", ], parameters: Type.Object({}), - async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { - try { - // Claim a reserved ID if the guided-flow already previewed one to the user. - // This guarantees the ID shown in the UI matches the one materialised on disk. - const reserved = claimReservedId(); - if (reserved) { - return { - content: [{ type: "text" as const, text: reserved }], - details: { operation: "generate_milestone_id", id: reserved, source: "reserved" } as any, - }; - } + execute: milestoneGenerateIdExecute, + }; - const basePath = process.cwd(); - const existingIds = findMilestoneIds(basePath); - const uniqueEnabled = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; - const allIds = [...new Set([...existingIds, ...getReservedMilestoneIds()])]; - const newId = nextMilestoneId(allIds, uniqueEnabled); - return { - content: [{ type: "text" as const, text: newId }], - details: { operation: "generate_milestone_id", id: newId, existingCount: existingIds.length, uniqueEnabled } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { - content: [{ type: "text" as const, text: `Error generating milestone ID: ${msg}` }], - details: { operation: "generate_milestone_id", error: msg } as any, - }; - } - }, - }); + pi.registerTool(milestoneGenerateIdTool); + registerAlias(pi, milestoneGenerateIdTool, "gsd_generate_milestone_id", "gsd_milestone_generate_id"); } - diff --git a/src/resources/extensions/gsd/bootstrap/journal-tools.ts b/src/resources/extensions/gsd/bootstrap/journal-tools.ts new file mode 100644 index 000000000..7262d0b6d --- /dev/null +++ b/src/resources/extensions/gsd/bootstrap/journal-tools.ts @@ -0,0 +1,62 @@ +import { Type } from "@sinclair/typebox"; +import type { ExtensionAPI } from "@gsd/pi-coding-agent"; + +import { queryJournal } from "../journal.js"; + +export function registerJournalTools(pi: ExtensionAPI): void { + pi.registerTool({ + name: "gsd_journal_query", + label: "Query Journal", + description: + "Query the structured event journal for auto-mode iterations. " + + "Returns matching journal entries filtered by flow ID, unit ID, rule name, event type, or time range.", + promptSnippet: "Query the GSD event journal with filters (flowId, unitId, rule, eventType, time range, limit)", + promptGuidelines: [ + "Filter by flowId to trace all events from a single auto-mode iteration.", + "Filter by unitId to reconstruct the causal chain for a specific milestone/slice/task.", + "Use limit to control context size — default is 100 entries.", + ], + parameters: Type.Object({ + flowId: Type.Optional(Type.String({ description: "Filter by flow ID (UUID grouping one iteration)" })), + unitId: Type.Optional(Type.String({ description: "Filter by unit ID (e.g. M001/S01/T01) from event data" })), + rule: Type.Optional(Type.String({ description: "Filter by rule name from the unified registry" })), + eventType: Type.Optional(Type.String({ description: "Filter by event type (e.g. dispatch-match, unit-start)" })), + after: Type.Optional(Type.String({ description: "ISO-8601 lower bound (inclusive)" })), + before: Type.Optional(Type.String({ description: "ISO-8601 upper bound (inclusive)" })), + limit: Type.Optional(Type.Number({ description: "Maximum entries to return (default: 100)", default: 100 })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + try { + const filters: Record = {}; + if (params.flowId !== undefined) filters.flowId = params.flowId; + if (params.unitId !== undefined) filters.unitId = params.unitId; + if (params.rule !== undefined) filters.rule = params.rule; + if (params.eventType !== undefined) filters.eventType = params.eventType; + if (params.after !== undefined) filters.after = params.after; + if (params.before !== undefined) filters.before = params.before; + + const entries = queryJournal(process.cwd(), filters); + const limited = entries.slice(0, params.limit ?? 100); + + if (limited.length === 0) { + return { + content: [{ type: "text" as const, text: "No matching journal entries found." }], + details: { operation: "journal_query", count: 0 } as any, + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(limited, null, 2) }], + details: { operation: "journal_query", count: limited.length } as any, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`gsd-journal: gsd_journal_query tool failed: ${msg}\n`); + return { + content: [{ type: "text" as const, text: `Error querying journal: ${msg}` }], + details: { operation: "journal_query", error: msg } as any, + }; + } + }, + }); +} diff --git a/src/resources/extensions/gsd/bootstrap/register-extension.ts b/src/resources/extensions/gsd/bootstrap/register-extension.ts index 0f5b5ea42..166d227ad 100644 --- a/src/resources/extensions/gsd/bootstrap/register-extension.ts +++ b/src/resources/extensions/gsd/bootstrap/register-extension.ts @@ -5,6 +5,7 @@ import { registerExitCommand } from "../exit-command.js"; import { registerWorktreeCommand } from "../worktree-command.js"; import { registerDbTools } from "./db-tools.js"; import { registerDynamicTools } from "./dynamic-tools.js"; +import { registerJournalTools } from "./journal-tools.js"; import { registerHooks } from "./register-hooks.js"; import { registerShortcuts } from "./register-shortcuts.js"; @@ -40,6 +41,7 @@ export function registerGsdExtension(pi: ExtensionAPI): void { registerDynamicTools(pi); registerDbTools(pi); + registerJournalTools(pi); registerShortcuts(pi); registerHooks(pi); } diff --git a/src/resources/extensions/gsd/extension-manifest.json b/src/resources/extensions/gsd/extension-manifest.json index efeb7bfbe..a1b2877be 100644 --- a/src/resources/extensions/gsd/extension-manifest.json +++ b/src/resources/extensions/gsd/extension-manifest.json @@ -8,8 +8,8 @@ "provides": { "tools": [ "bash", "write", "read", "edit", - "gsd_save_decision", "gsd_save_summary", - "gsd_update_requirement", "gsd_generate_milestone_id" + "gsd_decision_save", "gsd_summary_save", + "gsd_requirement_update", "gsd_milestone_generate_id" ], "commands": ["gsd", "kill", "worktree", "exit"], "hooks": ["session_start"], diff --git a/src/resources/extensions/gsd/guided-flow-queue.ts b/src/resources/extensions/gsd/guided-flow-queue.ts index 929a74428..5b0b21e94 100644 --- a/src/resources/extensions/gsd/guided-flow-queue.ts +++ b/src/resources/extensions/gsd/guided-flow-queue.ts @@ -170,7 +170,7 @@ export async function showQueueAdd( const existingContext = await buildExistingMilestonesContext(basePath, milestoneIds, state); // ── Determine next milestone ID ───────────────────────────────────── - // Note: the LLM will use the gsd_generate_milestone_id tool to get IDs + // Note: the LLM will use the gsd_milestone_generate_id tool to get IDs // at creation time, but we still mention the next ID in the preamble // for context about where the sequence is. const uniqueEnabled = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 24514d1c2..af5711c01 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -55,7 +55,7 @@ import { getErrorMessage } from "./error-utils.js"; /** * Generate the next milestone ID, accounting for reserved IDs, and reserve it. - * Ensures any preview ID shown in the UI matches what `gsd_generate_milestone_id` + * Ensures any preview ID shown in the UI matches what `gsd_milestone_generate_id` * will later return. */ function nextMilestoneIdReserved(existingIds: string[], uniqueEnabled: boolean): string { diff --git a/src/resources/extensions/gsd/journal.ts b/src/resources/extensions/gsd/journal.ts new file mode 100644 index 000000000..9b1fa9487 --- /dev/null +++ b/src/resources/extensions/gsd/journal.ts @@ -0,0 +1,134 @@ +/** + * GSD Event Journal — structured JSONL event log for auto-mode iterations. + * + * Writes daily-rotated JSONL files to `.gsd/journal/YYYY-MM-DD.jsonl`. + * Zero imports from `auto/` — depends only on node:fs, node:path, and paths.ts. + * + * Observability: + * - Each line in the JSONL file is a self-contained JournalEntry + * - Events are grouped by flowId (one per iteration) with monotonic seq numbers + * - causedBy references enable causal chain reconstruction + * - queryJournal() enables programmatic filtering by flowId, eventType, unitId, time range + * - Silent failure: journal writes never throw — absence of events is the failure signal + */ + +import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { gsdRoot } from "./paths.js"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** Event types emitted by the auto-mode loop and phases. */ +export type JournalEventType = + | "iteration-start" + | "dispatch-match" + | "dispatch-stop" + | "pre-dispatch-hook" + | "unit-start" + | "unit-end" + | "post-unit-hook" + | "terminal" + | "guard-block" + | "milestone-transition" + | "stuck-detected" + | "sidecar-dequeue" + | "iteration-end"; + +/** A single structured event in the journal. */ +export interface JournalEntry { + /** ISO-8601 timestamp */ + ts: string; + /** UUID grouping all events from one iteration */ + flowId: string; + /** Monotonically increasing sequence number within a flow */ + seq: number; + /** The kind of event */ + eventType: JournalEventType; + /** Name of the matched rule (from the unified registry), if applicable */ + rule?: string; + /** Causal reference to a prior event in this or another flow */ + causedBy?: { flowId: string; seq: number }; + /** Arbitrary structured payload (e.g. unitId, status, action details) */ + data?: Record; +} + +/** Filters for querying journal entries. */ +export interface JournalQueryFilters { + flowId?: string; + eventType?: string; + unitId?: string; + /** Filter by the rule name that produced the event */ + rule?: string; + /** ISO-8601 lower bound (inclusive) */ + after?: string; + /** ISO-8601 upper bound (inclusive) */ + before?: string; +} + +// ─── Emit ───────────────────────────────────────────────────────────────────── + +/** + * Append a journal event to the daily JSONL file. + * + * File path: `/journal/.jsonl` + * where the date is extracted from `entry.ts.slice(0, 10)`. + * + * Never throws — all errors are silently caught. + */ +export function emitJournalEvent(basePath: string, entry: JournalEntry): void { + try { + const journalDir = join(gsdRoot(basePath), "journal"); + mkdirSync(journalDir, { recursive: true }); + const dateStr = entry.ts.slice(0, 10); + const filePath = join(journalDir, `${dateStr}.jsonl`); + appendFileSync(filePath, JSON.stringify(entry) + "\n"); + } catch { + // Silent failure — journal must never break auto-mode + } +} + +// ─── Query ──────────────────────────────────────────────────────────────────── + +/** + * Read and filter journal entries from all daily JSONL files. + * + * Returns an empty array on any error (missing directory, corrupt files, etc.). + */ +export function queryJournal( + basePath: string, + filters?: JournalQueryFilters, +): JournalEntry[] { + try { + const journalDir = join(gsdRoot(basePath), "journal"); + const files = readdirSync(journalDir).filter(f => f.endsWith(".jsonl")).sort(); + + const entries: JournalEntry[] = []; + for (const file of files) { + const raw = readFileSync(join(journalDir, file), "utf-8"); + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line) as JournalEntry; + entries.push(entry); + } catch { + // Skip malformed lines + } + } + } + + if (!filters) return entries; + + return entries.filter(e => { + if (filters.flowId && e.flowId !== filters.flowId) return false; + if (filters.eventType && e.eventType !== filters.eventType) return false; + if (filters.rule && e.rule !== filters.rule) return false; + if (filters.unitId && (e.data as Record | undefined)?.unitId !== filters.unitId) return false; + if (filters.after && e.ts < filters.after) return false; + if (filters.before && e.ts > filters.before) return false; + return true; + }); + } catch { + // Missing directory, permission errors, etc. — return empty + return []; + } +} diff --git a/src/resources/extensions/gsd/milestone-ids.ts b/src/resources/extensions/gsd/milestone-ids.ts index 286f16809..aa44c8f87 100644 --- a/src/resources/extensions/gsd/milestone-ids.ts +++ b/src/resources/extensions/gsd/milestone-ids.ts @@ -75,7 +75,7 @@ export function nextMilestoneId(milestoneIds: string[], uniqueEnabled?: boolean) /** * Module-level set of milestone IDs that have been previewed/promised to the * user but not yet materialised on disk. Both guided-flow (preview) and - * gsd_generate_milestone_id (tool) share this set so the ID shown in the UI + * gsd_milestone_generate_id (tool) share this set so the ID shown in the UI * matches the one the tool returns. */ const reservedMilestoneIds = new Set(); diff --git a/src/resources/extensions/gsd/post-unit-hooks.ts b/src/resources/extensions/gsd/post-unit-hooks.ts index 1c1964a2a..4425a3f19 100644 --- a/src/resources/extensions/gsd/post-unit-hooks.ts +++ b/src/resources/extensions/gsd/post-unit-hooks.ts @@ -1,524 +1,86 @@ -// GSD Extension — Hook Engine (Post-Unit, Pre-Dispatch, State Persistence) -// Manages hook queue, cycle tracking, artifact verification, pre-dispatch -// interception, and durable hook state for user-configured extensibility. +// GSD Extension — Hook Engine Facade +// +// Thin facade over RuleRegistry. All mutable state and logic lives in the +// registry instance; these exported functions delegate through getOrCreateRegistry() +// so existing call-sites and tests work without modification. import type { - PostUnitHookConfig, - PreDispatchHookConfig, HookExecutionState, HookDispatchResult, PreDispatchResult, - PersistedHookState, HookStatusEntry, } from "./types.js"; -import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js"; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; +import { getOrCreateRegistry, resolveHookArtifactPath } from "./rule-registry.js"; -// ─── Hook Queue State ────────────────────────────────────────────────────── +// Re-export resolveHookArtifactPath so existing importers still work. +export { resolveHookArtifactPath } from "./rule-registry.js"; -/** Currently executing hook, or null if in normal dispatch flow. */ -let activeHook: HookExecutionState | null = null; +// ─── Post-Unit Hooks ─────────────────────────────────────────────────────── -/** Queue of hooks remaining for the current trigger unit. */ -let hookQueue: Array<{ - config: PostUnitHookConfig; - triggerUnitType: string; - triggerUnitId: string; -}> = []; - -/** Cycle counts per hook+trigger, keyed as "hookName/triggerUnitType/triggerUnitId". */ -const cycleCounts = new Map(); - -/** Set when a hook completes with retry_on artifact present — signals caller to re-run trigger. */ -let retryPending = false; - -/** Stores the trigger unit info for pending retries so caller knows what to re-run. */ -let retryTrigger: { unitType: string; unitId: string; retryArtifact: string } | null = null; - -// ─── Public API ──────────────────────────────────────────────────────────── - -/** - * Called after a unit completes. Returns the next hook unit to dispatch, - * or null if no hooks apply (normal dispatch should proceed). - * - * Call flow: - * 1. A core unit (e.g. execute-task) completes → handleAgentEnd calls this - * 2. If hooks match, returns first hook to dispatch. Caller sends the prompt. - * 3. Hook unit completes → handleAgentEnd calls this again (activeHook is set) - * 4. Checks retry_on / next hook / done → returns next action or null - */ export function checkPostUnitHooks( completedUnitType: string, completedUnitId: string, basePath: string, ): HookDispatchResult | null { - // If we just completed a hook unit, handle its result - if (activeHook) { - return handleHookCompletion(basePath); - } - - // 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) - // 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 => - h.after.includes(completedUnitType), - ); - if (hooks.length === 0) return null; - - // Build hook queue for this trigger - hookQueue = hooks.map(config => ({ - config, - triggerUnitType: completedUnitType, - triggerUnitId: completedUnitId, - })); - - return dequeueNextHook(basePath); + return getOrCreateRegistry().evaluatePostUnit(completedUnitType, completedUnitId, basePath); } -/** - * Returns whether a hook is currently active (for progress display). - */ export function getActiveHook(): HookExecutionState | null { - return activeHook; + return getOrCreateRegistry().getActiveHook(); } -/** - * Returns true if a retry of the trigger unit was requested by a hook. - * Caller should re-dispatch the original trigger unit, then hooks will - * fire again on its next completion. - */ export function isRetryPending(): boolean { - return retryPending; + return getOrCreateRegistry().isRetryPending(); } -/** - * Returns the trigger unit info for a pending retry, or null. - * Clears the retry state after reading. - */ export function consumeRetryTrigger(): { unitType: string; unitId: string; retryArtifact: string } | null { - if (!retryPending || !retryTrigger) return null; - const trigger = { ...retryTrigger }; - retryPending = false; - retryTrigger = null; - return trigger; + return getOrCreateRegistry().consumeRetryTrigger(); } -/** - * Reset all hook state. Called on auto-mode start/stop. - */ export function resetHookState(): void { - activeHook = null; - hookQueue = []; - cycleCounts.clear(); - retryPending = false; - retryTrigger = null; + getOrCreateRegistry().resetState(); } -// ─── Internal ────────────────────────────────────────────────────────────── +// ─── Pre-Dispatch Hooks ──────────────────────────────────────────────────── -function dequeueNextHook(basePath: string): HookDispatchResult | null { - while (hookQueue.length > 0) { - const entry = hookQueue.shift()!; - const { config, triggerUnitType, triggerUnitId } = entry; - - // Check idempotency — if artifact already exists, skip this hook - if (config.artifact) { - const artifactPath = resolveHookArtifactPath(basePath, triggerUnitId, config.artifact); - if (existsSync(artifactPath)) continue; - } - - // Check cycle limit - const cycleKey = `${config.name}/${triggerUnitType}/${triggerUnitId}`; - const currentCycle = (cycleCounts.get(cycleKey) ?? 0) + 1; - const maxCycles = config.max_cycles ?? 1; - if (currentCycle > maxCycles) continue; - - cycleCounts.set(cycleKey, currentCycle); - - activeHook = { - hookName: config.name, - triggerUnitType, - triggerUnitId, - cycle: currentCycle, - pendingRetry: false, - }; - - // Build the prompt with variable substitution - const [mid, sid, tid] = triggerUnitId.split("/"); - let prompt = config.prompt - .replace(/\{milestoneId\}/g, mid ?? "") - .replace(/\{sliceId\}/g, sid ?? "") - .replace(/\{taskId\}/g, tid ?? ""); - - // Inject browser safety instruction for hooks that may use browser tools (#1345). - // Vite HMR and other persistent connections prevent networkidle from resolving. - prompt += "\n\n**Browser tool safety:** Do NOT use `browser_wait_for` with `condition: \"network_idle\"` — it hangs indefinitely when dev servers keep persistent connections (Vite HMR, WebSocket). Use `selector_visible`, `text_visible`, or `delay` instead."; - - return { - hookName: config.name, - prompt, - model: config.model, - unitType: `hook/${config.name}`, - unitId: triggerUnitId, - }; - } - - // No more hooks — clear active state and return null for normal dispatch - activeHook = null; - return null; -} - -function handleHookCompletion(basePath: string): HookDispatchResult | null { - const hook = activeHook!; - const hooks = resolvePostUnitHooks(); - const config = hooks.find(h => h.name === hook.hookName); - - // Check if retry was requested via retry_on artifact - if (config?.retry_on) { - const retryArtifactPath = resolveHookArtifactPath(basePath, hook.triggerUnitId, config.retry_on); - if (existsSync(retryArtifactPath)) { - // Check cycle limit before allowing retry - const cycleKey = `${config.name}/${hook.triggerUnitType}/${hook.triggerUnitId}`; - const currentCycle = cycleCounts.get(cycleKey) ?? 1; - const maxCycles = config.max_cycles ?? 1; - - if (currentCycle < maxCycles) { - // Signal retry — caller will re-dispatch the trigger unit - activeHook = null; - hookQueue = []; - retryPending = true; - retryTrigger = { unitType: hook.triggerUnitType, unitId: hook.triggerUnitId, retryArtifact: config.retry_on }; - return null; - } - // Max cycles reached — fall through to normal completion - } - } - - // Hook completed normally — try next hook in queue - activeHook = null; - return dequeueNextHook(basePath); -} - -/** - * Resolve the path where a hook artifact is expected to be written. - * Uses the trigger unit's directory context: - * - Task-level (M001/S01/T01): .gsd/milestones/M001/slices/S01/tasks/T01-{artifact} - * - Slice-level (M001/S01): .gsd/milestones/M001/slices/S01/{artifact} - * - Milestone-level (M001): .gsd/milestones/M001/{artifact} - */ -export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string { - const parts = unitId.split("/"); - if (parts.length === 3) { - const [mid, sid, tid] = parts; - return join(basePath, ".gsd", "milestones", mid, "slices", sid, "tasks", `${tid}-${artifactName}`); - } - if (parts.length === 2) { - const [mid, sid] = parts; - return join(basePath, ".gsd", "milestones", mid, "slices", sid, artifactName); - } - return join(basePath, ".gsd", "milestones", parts[0], artifactName); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Phase 2: Pre-Dispatch Hooks -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Run pre-dispatch hooks for a unit about to be dispatched. - * Returns a result indicating whether the unit should proceed (with optional - * prompt modifications), be skipped, or be replaced entirely. - * - * Multiple hooks can fire for the same unit type. They compose: - * - "modify" hooks stack (all prepend/append applied in order) - * - "skip" short-circuits (first matching skip wins) - * - "replace" short-circuits (first matching replace wins) - * - Skip/replace hooks take precedence over modify hooks - */ export function runPreDispatchHooks( unitType: string, unitId: string, prompt: string, basePath: string, ): PreDispatchResult { - // Don't intercept hook units - if (unitType.startsWith("hook/")) { - return { action: "proceed", prompt, firedHooks: [] }; - } - - const hooks = resolvePreDispatchHooks().filter(h => - h.before.includes(unitType), - ); - if (hooks.length === 0) { - return { action: "proceed", prompt, firedHooks: [] }; - } - - const [mid, sid, tid] = unitId.split("/"); - const substitute = (text: string): string => - text - .replace(/\{milestoneId\}/g, mid ?? "") - .replace(/\{sliceId\}/g, sid ?? "") - .replace(/\{taskId\}/g, tid ?? ""); - - const firedHooks: string[] = []; - let currentPrompt = prompt; - - for (const hook of hooks) { - if (hook.action === "skip") { - // Check optional skip condition - if (hook.skip_if) { - const conditionPath = resolveHookArtifactPath(basePath, unitId, hook.skip_if); - if (!existsSync(conditionPath)) continue; // Condition not met, don't skip - } - firedHooks.push(hook.name); - return { action: "skip", firedHooks }; - } - - if (hook.action === "replace") { - firedHooks.push(hook.name); - return { - action: "replace", - prompt: substitute(hook.prompt ?? ""), - unitType: hook.unit_type, - model: hook.model, - firedHooks, - }; - } - - if (hook.action === "modify") { - firedHooks.push(hook.name); - if (hook.prepend) { - currentPrompt = `${substitute(hook.prepend)}\n\n${currentPrompt}`; - } - if (hook.append) { - currentPrompt = `${currentPrompt}\n\n${substitute(hook.append)}`; - } - } - } - - return { - action: "proceed", - prompt: currentPrompt, - model: hooks.find(h => h.action === "modify" && h.model)?.model, - firedHooks, - }; + return getOrCreateRegistry().evaluatePreDispatch(unitType, unitId, prompt, basePath); } -// ═══════════════════════════════════════════════════════════════════════════ -// Phase 3: Hook State Persistence -// ═══════════════════════════════════════════════════════════════════════════ +// ─── State Persistence ───────────────────────────────────────────────────── -const HOOK_STATE_FILE = "hook-state.json"; - -function hookStatePath(basePath: string): string { - return join(basePath, ".gsd", HOOK_STATE_FILE); -} - -/** - * Persist current hook cycle counts to disk so they survive crashes/restarts. - * Called after each hook dispatch and on auto-mode pause. - */ export function persistHookState(basePath: string): void { - const state: PersistedHookState = { - cycleCounts: Object.fromEntries(cycleCounts), - savedAt: new Date().toISOString(), - }; - try { - const dir = join(basePath, ".gsd"); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(hookStatePath(basePath), JSON.stringify(state, null, 2), "utf-8"); - } catch { - // Non-fatal — state is recreatable from artifacts - } + getOrCreateRegistry().persistState(basePath); } -/** - * Restore hook cycle counts from disk after a crash/restart. - * Called during auto-mode resume. - */ export function restoreHookState(basePath: string): void { - try { - const filePath = hookStatePath(basePath); - if (!existsSync(filePath)) return; - const raw = readFileSync(filePath, "utf-8"); - const state: PersistedHookState = JSON.parse(raw); - if (state.cycleCounts && typeof state.cycleCounts === "object") { - cycleCounts.clear(); - for (const [key, value] of Object.entries(state.cycleCounts)) { - if (typeof value === "number") { - cycleCounts.set(key, value); - } - } - } - } catch { - // Non-fatal — fresh state is fine - } + getOrCreateRegistry().restoreState(basePath); } -/** - * Clear persisted hook state file from disk. - * Called on clean auto-mode stop. - */ export function clearPersistedHookState(basePath: string): void { - try { - const filePath = hookStatePath(basePath); - if (existsSync(filePath)) { - writeFileSync(filePath, JSON.stringify({ cycleCounts: {}, savedAt: new Date().toISOString() }, null, 2), "utf-8"); - } - } catch { - // Non-fatal - } + getOrCreateRegistry().clearPersistedState(basePath); } -// ═══════════════════════════════════════════════════════════════════════════ -// Phase 3: Hook Status Reporting -// ═══════════════════════════════════════════════════════════════════════════ +// ─── Status & Manual Trigger ─────────────────────────────────────────────── -/** - * Get status of all configured hooks for display by /gsd hooks. - */ export function getHookStatus(): HookStatusEntry[] { - const entries: HookStatusEntry[] = []; - - // Post-unit hooks - const postHooks = resolvePostUnitHooks(); - for (const hook of postHooks) { - const activeCycles: Record = {}; - for (const [key, count] of cycleCounts) { - if (key.startsWith(`${hook.name}/`)) { - activeCycles[key] = count; - } - } - entries.push({ - name: hook.name, - type: "post", - enabled: hook.enabled !== false, - targets: hook.after, - activeCycles, - }); - } - - // Pre-dispatch hooks - const preHooks = resolvePreDispatchHooks(); - for (const hook of preHooks) { - entries.push({ - name: hook.name, - type: "pre", - enabled: hook.enabled !== false, - targets: hook.before, - activeCycles: {}, - }); - } - - return entries; + return getOrCreateRegistry().getHookStatus(); } -/** - * Manually trigger a specific hook for a unit. - * This bypasses the normal flow and forces the hook to run even if its artifact exists. - * - * @param hookName - The name of the hook to trigger (e.g., "code-review") - * @param unitType - The type of unit that triggered the hook (e.g., "execute-task") - * @param unitId - The unit ID (e.g., "M001/S01/T01") - * @param basePath - The project base path - * @returns The hook dispatch result or null if hook not found - */ export function triggerHookManually( hookName: string, unitType: string, unitId: string, basePath: string, ): HookDispatchResult | null { - // Find the hook configuration - const hook = resolvePostUnitHooks().find(h => h.name === hookName); - if (!hook) { - console.error(`[triggerHookManually] Hook "${hookName}" not found in post_unit_hooks`); - return null; - } - - if (!hook.prompt || typeof hook.prompt !== 'string' || hook.prompt.trim().length === 0) { - console.error(`[triggerHookManually] Hook "${hookName}" has empty prompt`); - return null; - } - - // Reset any active hook state to allow manual triggering - activeHook = { - hookName: hook.name, - triggerUnitType: unitType, - triggerUnitId: unitId, - cycle: 1, - pendingRetry: false, - }; - - // Build the hook queue with just this hook - hookQueue = [{ - config: hook, - triggerUnitType: unitType, - triggerUnitId: unitId, - }]; - - // Set the cycle count for this specific hook+trigger - const cycleKey = `${hook.name}/${unitType}/${unitId}`; - const currentCycle = (cycleCounts.get(cycleKey) ?? 0) + 1; - cycleCounts.set(cycleKey, currentCycle); - - // Update active hook with the cycle count - activeHook.cycle = currentCycle; - - // Build the prompt with variable substitution - const [mid, sid, tid] = unitId.split("/"); - const prompt = hook.prompt - .replace(/\{milestoneId\}/g, mid ?? "") - .replace(/\{sliceId\}/g, sid ?? "") - .replace(/\{taskId\}/g, tid ?? ""); - - console.log(`[triggerHookManually] Built prompt for ${hookName}, length: ${prompt.length}`); - - return { - hookName: hook.name, - prompt, - model: hook.model, - unitType: `hook/${hook.name}`, - unitId, - }; + return getOrCreateRegistry().triggerHookManually(hookName, unitType, unitId, basePath); } -/** - * Format hook status for terminal display. - */ export function formatHookStatus(): string { - const entries = getHookStatus(); - if (entries.length === 0) { - return "No hooks configured. Add post_unit_hooks or pre_dispatch_hooks to .gsd/preferences.md"; - } - - const lines: string[] = ["Configured Hooks:", ""]; - - const postHooks = entries.filter(e => e.type === "post"); - const preHooks = entries.filter(e => e.type === "pre"); - - if (postHooks.length > 0) { - lines.push("Post-Unit Hooks (run after unit completes):"); - for (const hook of postHooks) { - const status = hook.enabled ? "enabled" : "disabled"; - const cycles = Object.keys(hook.activeCycles).length; - const cycleInfo = cycles > 0 ? ` (${cycles} active cycle${cycles === 1 ? "" : "s"})` : ""; - lines.push(` ${hook.name} [${status}] → after: ${hook.targets.join(", ")}${cycleInfo}`); - } - lines.push(""); - } - - if (preHooks.length > 0) { - lines.push("Pre-Dispatch Hooks (run before unit dispatches):"); - for (const hook of preHooks) { - const status = hook.enabled ? "enabled" : "disabled"; - lines.push(` ${hook.name} [${status}] → before: ${hook.targets.join(", ")}`); - } - lines.push(""); - } - - return lines.join("\n"); + return getOrCreateRegistry().formatHookStatus(); } diff --git a/src/resources/extensions/gsd/prompts/discuss-headless.md b/src/resources/extensions/gsd/prompts/discuss-headless.md index b6b814064..9de3bcd2a 100644 --- a/src/resources/extensions/gsd/prompts/discuss-headless.md +++ b/src/resources/extensions/gsd/prompts/discuss-headless.md @@ -56,7 +56,7 @@ Use these templates exactly: 9. Say exactly: "Milestone {{milestoneId}} ready." **For multi-milestone**, write in this order: -1. For each milestone, call `gsd_generate_milestone_id` to get its ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones//slices` for each. +1. For each milestone, call `gsd_milestone_generate_id` to get its ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones//slices` for each. 2. Write `.gsd/PROJECT.md` — full vision across ALL milestones (using Project template) 3. Write `.gsd/REQUIREMENTS.md` — full capability contract (using Requirements template) 4. Seed `.gsd/DECISIONS.md` (using Decisions template) @@ -82,5 +82,5 @@ Use these templates exactly: - **Investigate before writing** — always scout the codebase first - **Use depends_on frontmatter** for multi-milestone sequences (the state machine reads this field to determine execution order) - **Anti-reduction rule** — if the spec describes a big vision, plan the big vision. Do not ask "what's the minimum viable version?" or reduce scope. Phase complex/risky work into later milestones — do not cut it. -- **Naming convention** — always use `gsd_generate_milestone_id` to get milestone IDs. Directories use bare IDs (e.g. `M001/` or `M001-r5jzab/`), files use ID-SUFFIX format (e.g. `M001-CONTEXT.md` or `M001-r5jzab-CONTEXT.md`). Never invent milestone IDs manually. +- **Naming convention** — always use `gsd_milestone_generate_id` to get milestone IDs. Directories use bare IDs (e.g. `M001/` or `M001-r5jzab/`), files use ID-SUFFIX format (e.g. `M001-CONTEXT.md` or `M001-r5jzab-CONTEXT.md`). Never invent milestone IDs manually. - **End with "Milestone {{milestoneId}} ready."** — this triggers auto-start detection diff --git a/src/resources/extensions/gsd/prompts/discuss.md b/src/resources/extensions/gsd/prompts/discuss.md index bf4574435..38c71647d 100644 --- a/src/resources/extensions/gsd/prompts/discuss.md +++ b/src/resources/extensions/gsd/prompts/discuss.md @@ -214,7 +214,7 @@ Once the user confirms the milestone split: #### Phase 1: Shared artifacts -1. For each milestone, call `gsd_generate_milestone_id` to get its ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones//slices`. +1. For each milestone, call `gsd_milestone_generate_id` to get its ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones//slices`. 2. Write `.gsd/PROJECT.md` — use the **Project** output template below. 3. Write `.gsd/REQUIREMENTS.md` — use the **Requirements** output template below. Capture Active, Deferred, Out of Scope, and any already Validated requirements. Later milestones may have provisional ownership where slice plans do not exist yet. 4. Seed `.gsd/DECISIONS.md` — use the **Decisions** output template below. diff --git a/src/resources/extensions/gsd/prompts/queue.md b/src/resources/extensions/gsd/prompts/queue.md index c97b9a3d1..15d8deb08 100644 --- a/src/resources/extensions/gsd/prompts/queue.md +++ b/src/resources/extensions/gsd/prompts/queue.md @@ -107,7 +107,7 @@ The user confirms or corrects before you write. One depth verification per miles Once the user is satisfied, in a single pass for **each** new milestone: -1. Call `gsd_generate_milestone_id` to get the milestone ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones//slices`. +1. Call `gsd_milestone_generate_id` to get the milestone ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones//slices`. 2. Write `.gsd/milestones//-CONTEXT.md` — use the **Context** output template below. Capture intent, scope, risks, constraints, integration points, and relevant requirements. Mark the status as "Queued — pending auto-mode execution." **If this milestone depends on other milestones, add YAML frontmatter with `depends_on`:** ```yaml --- diff --git a/src/resources/extensions/gsd/rule-registry.ts b/src/resources/extensions/gsd/rule-registry.ts new file mode 100644 index 000000000..6f818080f --- /dev/null +++ b/src/resources/extensions/gsd/rule-registry.ts @@ -0,0 +1,599 @@ +// GSD Extension — Unified Rule Registry +// +// Holds all dispatch rules and hooks as a flat list of UnifiedRule objects. +// Provides evaluation methods for each phase (dispatch, post-unit, pre-dispatch) +// and encapsulates mutable hook state as instance fields. +// +// A module-level singleton accessor allows existing code to migrate incrementally. + +import type { UnifiedRule, RulePhase } from "./rule-types.js"; +import type { DispatchAction, DispatchContext, DispatchRule } from "./auto-dispatch.js"; +import type { + PostUnitHookConfig, + PreDispatchHookConfig, + HookDispatchResult, + PreDispatchResult, + HookExecutionState, + PersistedHookState, + HookStatusEntry, +} from "./types.js"; +import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +// ─── Artifact Path Resolution ────────────────────────────────────────────── + +export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string { + const parts = unitId.split("/"); + if (parts.length === 3) { + const [mid, sid, tid] = parts; + return join(basePath, ".gsd", "milestones", mid, "slices", sid, "tasks", `${tid}-${artifactName}`); + } + if (parts.length === 2) { + const [mid, sid] = parts; + return join(basePath, ".gsd", "milestones", mid, "slices", sid, artifactName); + } + return join(basePath, ".gsd", "milestones", parts[0], artifactName); +} + +// ─── Dispatch Rule Conversion ────────────────────────────────────────────── + +/** + * Convert an array of DispatchRule objects to UnifiedRule[] format. + * Preserves exact array order — dispatch is order-dependent (first-match-wins). + */ +export function convertDispatchRules(rules: DispatchRule[]): UnifiedRule[] { + return rules.map((rule) => ({ + name: rule.name, + when: "dispatch" as const, + evaluation: "first-match" as const, + where: rule.match, + then: (result: any) => result, + description: `Dispatch rule: ${rule.name}`, + })); +} + +// ─── RuleRegistry ───────────────────────────────────────────────────────── + +const HOOK_STATE_FILE = "hook-state.json"; + +export class RuleRegistry { + /** Static dispatch rules provided at construction time. */ + private readonly dispatchRules: UnifiedRule[]; + + // ── Mutable hook state (encapsulated, not module-level) ────────────── + + activeHook: HookExecutionState | null = null; + hookQueue: Array<{ + config: PostUnitHookConfig; + triggerUnitType: string; + triggerUnitId: string; + }> = []; + cycleCounts: Map = new Map(); + retryPending: boolean = false; + retryTrigger: { unitType: string; unitId: string; retryArtifact: string } | null = null; + + constructor(dispatchRules: UnifiedRule[]) { + this.dispatchRules = dispatchRules; + } + + // ── Core query ─────────────────────────────────────────────────────── + + /** + * Returns all rules: static dispatch rules + dynamically loaded hook rules. + * Hook rules are loaded fresh from preferences on each call (not cached). + */ + listRules(): UnifiedRule[] { + const rules: UnifiedRule[] = [...this.dispatchRules]; + + // Convert post-unit hooks to unified rules + const postHooks = resolvePostUnitHooks(); + for (const hook of postHooks) { + rules.push({ + name: hook.name, + when: "post-unit", + evaluation: "all-matching", + where: (unitType: string) => hook.after.includes(unitType), + then: () => hook, + description: `Post-unit hook: fires after ${hook.after.join(", ")}`, + lifecycle: { + artifact: hook.artifact, + retry_on: hook.retry_on, + max_cycles: hook.max_cycles, + }, + }); + } + + // Convert pre-dispatch hooks to unified rules + const preHooks = resolvePreDispatchHooks(); + for (const hook of preHooks) { + rules.push({ + name: hook.name, + when: "pre-dispatch", + evaluation: "all-matching", + where: (unitType: string) => hook.before.includes(unitType), + then: () => hook, + description: `Pre-dispatch hook: fires before ${hook.before.join(", ")}`, + }); + } + + return rules; + } + + // ── Dispatch evaluation (async, first-match-wins) ─────────────────── + + /** + * Iterate dispatch rules in order. First match wins. + * Returns stop action if no rule matches (unhandled phase). + */ + async evaluateDispatch(ctx: DispatchContext): Promise { + for (const rule of this.dispatchRules) { + const result = await rule.where(ctx); + if (result) { + if (result.action !== "skip") result.matchedRule = rule.name; + return result; + } + } + return { + action: "stop", + reason: `Unhandled phase "${ctx.state.phase}" — run /gsd doctor to diagnose.`, + level: "info", + matchedRule: "", + }; + } + + // ── Post-unit hook evaluation (sync, all-matching with lifecycle) ──── + + /** + * Replicate exact semantics of checkPostUnitHooks from post-unit-hooks.ts: + * hook-on-hook prevention, idempotency, cycle limits, retry_on, dequeue. + */ + evaluatePostUnit( + completedUnitType: string, + completedUnitId: string, + basePath: string, + ): HookDispatchResult | null { + // If we just completed a hook unit, handle its result + if (this.activeHook) { + return this._handleHookCompletion(basePath); + } + + // Don't trigger hooks for other hook units (prevent hook-on-hook chains) + // Don't trigger hooks for triage units or quick-task units + 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 => + h.after.includes(completedUnitType), + ); + if (hooks.length === 0) return null; + + // Build hook queue for this trigger + this.hookQueue = hooks.map(config => ({ + config, + triggerUnitType: completedUnitType, + triggerUnitId: completedUnitId, + })); + + return this._dequeueNextHook(basePath); + } + + private _dequeueNextHook(basePath: string): HookDispatchResult | null { + while (this.hookQueue.length > 0) { + const entry = this.hookQueue.shift()!; + const { config, triggerUnitType, triggerUnitId } = entry; + + // Check idempotency — if artifact already exists, skip + if (config.artifact) { + const artifactPath = resolveHookArtifactPath(basePath, triggerUnitId, config.artifact); + if (existsSync(artifactPath)) continue; + } + + // Check cycle limit + const cycleKey = `${config.name}/${triggerUnitType}/${triggerUnitId}`; + const currentCycle = (this.cycleCounts.get(cycleKey) ?? 0) + 1; + const maxCycles = config.max_cycles ?? 1; + if (currentCycle > maxCycles) continue; + + this.cycleCounts.set(cycleKey, currentCycle); + + this.activeHook = { + hookName: config.name, + triggerUnitType, + triggerUnitId, + cycle: currentCycle, + pendingRetry: false, + }; + + // Build prompt with variable substitution + const [mid, sid, tid] = triggerUnitId.split("/"); + let prompt = config.prompt + .replace(/\{milestoneId\}/g, mid ?? "") + .replace(/\{sliceId\}/g, sid ?? "") + .replace(/\{taskId\}/g, tid ?? ""); + + // Inject browser safety instruction + prompt += "\n\n**Browser tool safety:** Do NOT use `browser_wait_for` with `condition: \"network_idle\"` — it hangs indefinitely when dev servers keep persistent connections (Vite HMR, WebSocket). Use `selector_visible`, `text_visible`, or `delay` instead."; + + return { + hookName: config.name, + prompt, + model: config.model, + unitType: `hook/${config.name}`, + unitId: triggerUnitId, + }; + } + + // No more hooks — clear active state + this.activeHook = null; + return null; + } + + private _handleHookCompletion(basePath: string): HookDispatchResult | null { + const hook = this.activeHook!; + const hooks = resolvePostUnitHooks(); + const config = hooks.find(h => h.name === hook.hookName); + + // Check if retry was requested via retry_on artifact + if (config?.retry_on) { + const retryArtifactPath = resolveHookArtifactPath(basePath, hook.triggerUnitId, config.retry_on); + if (existsSync(retryArtifactPath)) { + const cycleKey = `${config.name}/${hook.triggerUnitType}/${hook.triggerUnitId}`; + const currentCycle = this.cycleCounts.get(cycleKey) ?? 1; + const maxCycles = config.max_cycles ?? 1; + + if (currentCycle < maxCycles) { + this.activeHook = null; + this.hookQueue = []; + this.retryPending = true; + this.retryTrigger = { + unitType: hook.triggerUnitType, + unitId: hook.triggerUnitId, + retryArtifact: config.retry_on, + }; + return null; + } + } + } + + // Hook completed normally — try next hook in queue + this.activeHook = null; + return this._dequeueNextHook(basePath); + } + + // ── Pre-dispatch hook evaluation (sync, all-matching with compose) ── + + /** + * Replicate exact semantics of runPreDispatchHooks from post-unit-hooks.ts: + * modify/skip/replace compose semantics. + */ + evaluatePreDispatch( + unitType: string, + unitId: string, + prompt: string, + basePath: string, + ): PreDispatchResult { + // Don't intercept hook units + if (unitType.startsWith("hook/")) { + return { action: "proceed", prompt, firedHooks: [] }; + } + + const hooks = resolvePreDispatchHooks().filter(h => + h.before.includes(unitType), + ); + if (hooks.length === 0) { + return { action: "proceed", prompt, firedHooks: [] }; + } + + const [mid, sid, tid] = unitId.split("/"); + const substitute = (text: string): string => + text + .replace(/\{milestoneId\}/g, mid ?? "") + .replace(/\{sliceId\}/g, sid ?? "") + .replace(/\{taskId\}/g, tid ?? ""); + + const firedHooks: string[] = []; + let currentPrompt = prompt; + + for (const hook of hooks) { + if (hook.action === "skip") { + if (hook.skip_if) { + const conditionPath = resolveHookArtifactPath(basePath, unitId, hook.skip_if); + if (!existsSync(conditionPath)) continue; + } + firedHooks.push(hook.name); + return { action: "skip", firedHooks }; + } + + if (hook.action === "replace") { + firedHooks.push(hook.name); + return { + action: "replace", + prompt: substitute(hook.prompt ?? ""), + unitType: hook.unit_type, + model: hook.model, + firedHooks, + }; + } + + if (hook.action === "modify") { + firedHooks.push(hook.name); + if (hook.prepend) { + currentPrompt = `${substitute(hook.prepend)}\n\n${currentPrompt}`; + } + if (hook.append) { + currentPrompt = `${currentPrompt}\n\n${substitute(hook.append)}`; + } + } + } + + return { + action: "proceed", + prompt: currentPrompt, + model: hooks.find(h => h.action === "modify" && h.model)?.model, + firedHooks, + }; + } + + // ── State accessors ───────────────────────────────────────────────── + + getActiveHook(): HookExecutionState | null { + return this.activeHook; + } + + isRetryPending(): boolean { + return this.retryPending; + } + + /** + * Returns the trigger unit info for a pending retry, or null. + * Clears the retry state after reading. + */ + consumeRetryTrigger(): { unitType: string; unitId: string; retryArtifact: string } | null { + if (!this.retryPending || !this.retryTrigger) return null; + const trigger = { ...this.retryTrigger }; + this.retryPending = false; + this.retryTrigger = null; + return trigger; + } + + /** Clear all mutable state (activeHook, hookQueue, cycleCounts, retryPending, retryTrigger). */ + resetState(): void { + this.activeHook = null; + this.hookQueue = []; + this.cycleCounts.clear(); + this.retryPending = false; + this.retryTrigger = null; + } + + // ── Persistence ───────────────────────────────────────────────────── + + private _hookStatePath(basePath: string): string { + return join(basePath, ".gsd", HOOK_STATE_FILE); + } + + /** Persist current hook cycle counts to disk. */ + persistState(basePath: string): void { + const state: PersistedHookState = { + cycleCounts: Object.fromEntries(this.cycleCounts), + savedAt: new Date().toISOString(), + }; + try { + const dir = join(basePath, ".gsd"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(this._hookStatePath(basePath), JSON.stringify(state, null, 2), "utf-8"); + } catch { + // Non-fatal — state is recreatable from artifacts + } + } + + /** Restore hook cycle counts from disk after a crash/restart. */ + restoreState(basePath: string): void { + try { + const filePath = this._hookStatePath(basePath); + if (!existsSync(filePath)) return; + const raw = readFileSync(filePath, "utf-8"); + const state: PersistedHookState = JSON.parse(raw); + if (state.cycleCounts && typeof state.cycleCounts === "object") { + this.cycleCounts.clear(); + for (const [key, value] of Object.entries(state.cycleCounts)) { + if (typeof value === "number") { + this.cycleCounts.set(key, value); + } + } + } + } catch { + // Non-fatal — fresh state is fine + } + } + + /** Clear persisted hook state file from disk. */ + clearPersistedState(basePath: string): void { + try { + const filePath = this._hookStatePath(basePath); + if (existsSync(filePath)) { + writeFileSync( + filePath, + JSON.stringify({ cycleCounts: {}, savedAt: new Date().toISOString() }, null, 2), + "utf-8", + ); + } + } catch { + // Non-fatal + } + } + + // ── Hook status reporting ─────────────────────────────────────────── + + /** Get status of all configured hooks for display. */ + getHookStatus(): HookStatusEntry[] { + const entries: HookStatusEntry[] = []; + + const postHooks = resolvePostUnitHooks(); + for (const hook of postHooks) { + const activeCycles: Record = {}; + for (const [key, count] of this.cycleCounts) { + if (key.startsWith(`${hook.name}/`)) { + activeCycles[key] = count; + } + } + entries.push({ + name: hook.name, + type: "post", + enabled: hook.enabled !== false, + targets: hook.after, + activeCycles, + }); + } + + const preHooks = resolvePreDispatchHooks(); + for (const hook of preHooks) { + entries.push({ + name: hook.name, + type: "pre", + enabled: hook.enabled !== false, + targets: hook.before, + activeCycles: {}, + }); + } + + return entries; + } + + /** + * Manually trigger a specific hook for a unit. + * Bypasses normal flow — forces hook to run even if artifact exists. + */ + triggerHookManually( + hookName: string, + unitType: string, + unitId: string, + basePath: string, + ): HookDispatchResult | null { + const hook = resolvePostUnitHooks().find(h => h.name === hookName); + if (!hook) { + console.error(`[triggerHookManually] Hook "${hookName}" not found in post_unit_hooks`); + return null; + } + + if (!hook.prompt || typeof hook.prompt !== "string" || hook.prompt.trim().length === 0) { + console.error(`[triggerHookManually] Hook "${hookName}" has empty prompt`); + return null; + } + + this.activeHook = { + hookName: hook.name, + triggerUnitType: unitType, + triggerUnitId: unitId, + cycle: 1, + pendingRetry: false, + }; + + this.hookQueue = [{ + config: hook, + triggerUnitType: unitType, + triggerUnitId: unitId, + }]; + + const cycleKey = `${hook.name}/${unitType}/${unitId}`; + const currentCycle = (this.cycleCounts.get(cycleKey) ?? 0) + 1; + this.cycleCounts.set(cycleKey, currentCycle); + this.activeHook.cycle = currentCycle; + + const [mid, sid, tid] = unitId.split("/"); + const prompt = hook.prompt + .replace(/\{milestoneId\}/g, mid ?? "") + .replace(/\{sliceId\}/g, sid ?? "") + .replace(/\{taskId\}/g, tid ?? ""); + + return { + hookName: hook.name, + prompt, + model: hook.model, + unitType: `hook/${hook.name}`, + unitId, + }; + } + + /** Format hook status for terminal display. */ + formatHookStatus(): string { + const entries = this.getHookStatus(); + if (entries.length === 0) { + return "No hooks configured. Add post_unit_hooks or pre_dispatch_hooks to .gsd/preferences.md"; + } + + const lines: string[] = ["Configured Hooks:", ""]; + + const postHooks = entries.filter(e => e.type === "post"); + const preHooks = entries.filter(e => e.type === "pre"); + + if (postHooks.length > 0) { + lines.push("Post-Unit Hooks (run after unit completes):"); + for (const hook of postHooks) { + const status = hook.enabled ? "enabled" : "disabled"; + const cycles = Object.keys(hook.activeCycles).length; + const cycleInfo = cycles > 0 ? ` (${cycles} active cycle${cycles === 1 ? "" : "s"})` : ""; + lines.push(` ${hook.name} [${status}] → after: ${hook.targets.join(", ")}${cycleInfo}`); + } + lines.push(""); + } + + if (preHooks.length > 0) { + lines.push("Pre-Dispatch Hooks (run before unit dispatches):"); + for (const hook of preHooks) { + const status = hook.enabled ? "enabled" : "disabled"; + lines.push(` ${hook.name} [${status}] → before: ${hook.targets.join(", ")}`); + } + lines.push(""); + } + + return lines.join("\n"); + } +} + +// ─── Module-level Singleton ───────────────────────────────────────────────── + +let _registry: RuleRegistry | null = null; + +/** Get the singleton registry. Throws if not initialized. */ +export function getRegistry(): RuleRegistry { + if (!_registry) { + throw new Error("RuleRegistry not initialized — call initRegistry() or setRegistry() first."); + } + return _registry; +} + +/** Set the singleton registry instance. */ +export function setRegistry(r: RuleRegistry): void { + _registry = r; +} + +/** Create and set the singleton registry with the given dispatch rules. */ +export function initRegistry(dispatchRules: UnifiedRule[]): RuleRegistry { + const registry = new RuleRegistry(dispatchRules); + setRegistry(registry); + return registry; +} + +/** + * Get the singleton registry, lazily creating one with empty dispatch rules + * if not yet initialized. This ensures facade functions work even when + * the full registry hasn't been set up (e.g. during testing). + */ +export function getOrCreateRegistry(): RuleRegistry { + if (!_registry) { + _registry = new RuleRegistry([]); + } + return _registry; +} + +/** Reset the singleton (for testing). */ +export function resetRegistry(): void { + _registry = null; +} diff --git a/src/resources/extensions/gsd/rule-types.ts b/src/resources/extensions/gsd/rule-types.ts new file mode 100644 index 000000000..37478053c --- /dev/null +++ b/src/resources/extensions/gsd/rule-types.ts @@ -0,0 +1,68 @@ +// GSD Extension — Unified Rule Type Definitions +// +// Every dispatch rule and hook is expressed as a `UnifiedRule` with a +// consistent when/where/then shape. This file defines the type system; +// the `RuleRegistry` class in rule-registry.ts holds instances at runtime. + +import type { DispatchAction, DispatchContext } from "./auto-dispatch.js"; +import type { + PostUnitHookConfig, + PreDispatchHookConfig, + HookDispatchResult, + PreDispatchResult, + HookExecutionState, + HookStatusEntry, +} from "./types.js"; + +// ─── Phase & Evaluation Strategy ──────────────────────────────────────────── + +/** Which phase/event a rule responds to. */ +export type RulePhase = "dispatch" | "post-unit" | "pre-dispatch"; + +/** How a rule is evaluated relative to peers in the same phase. */ +export type RuleEvaluation = "first-match" | "all-matching"; + +// ─── Lifecycle Metadata (hooks only) ──────────────────────────────────────── + +/** Optional lifecycle metadata attached to hook-derived rules. */ +export interface RuleLifecycle { + /** Expected output file name (relative to unit dir). Used for idempotency. */ + artifact?: string; + /** If this file is produced instead of artifact, re-run the trigger unit. */ + retry_on?: string; + /** Max times this hook can fire for the same trigger unit. */ + max_cycles?: number; + /** Idempotency key pattern for this hook. */ + idempotency_key?: string; +} + +// ─── Unified Rule ─────────────────────────────────────────────────────────── + +/** + * A single entry in the rule registry. Dispatch rules, post-unit hooks, + * and pre-dispatch hooks all share this shape. + */ +export interface UnifiedRule { + /** Stable human-readable identifier (existing names preserved per D005). */ + name: string; + /** Which phase/event this rule responds to. */ + when: RulePhase; + /** How this rule is evaluated relative to peers. */ + evaluation: RuleEvaluation; + /** + * Predicate/match function. + * - Dispatch rules: async, receives DispatchContext, returns DispatchAction | null. + * - Post-unit hooks: sync, receives (unitType, unitId, basePath). + * - Pre-dispatch hooks: sync, receives (unitType, unitId, prompt, basePath). + */ + where: (...args: any[]) => Promise | any; + /** + * Action builder. May be merged with `where` for dispatch rules where + * the match function returns the action directly. + */ + then: (...args: any[]) => any; + /** Optional human-readable summary for LLM inspection. */ + description?: string; + /** Optional hook lifecycle metadata. */ + lifecycle?: RuleLifecycle; +} diff --git a/src/resources/extensions/gsd/tests/auto-loop.test.ts b/src/resources/extensions/gsd/tests/auto-loop.test.ts index 56dee17bd..9cc2877e5 100644 --- a/src/resources/extensions/gsd/tests/auto-loop.test.ts +++ b/src/resources/extensions/gsd/tests/auto-loop.test.ts @@ -416,6 +416,7 @@ function makeMockDeps( getSessionFile: () => "/tmp/session.json", rebuildState: async () => {}, resolveModelId: (id: string, models: any[]) => models.find((m: any) => m.id === id), + emitJournalEvent: () => {}, }; const merged = { ...baseDeps, ...overrides, callLog }; diff --git a/src/resources/extensions/gsd/tests/gsd-tools.test.ts b/src/resources/extensions/gsd/tests/gsd-tools.test.ts index bb068cf02..12f8b4168 100644 --- a/src/resources/extensions/gsd/tests/gsd-tools.test.ts +++ b/src/resources/extensions/gsd/tests/gsd-tools.test.ts @@ -1,6 +1,6 @@ // gsd-tools — Structured LLM tool tests // -// Tests the three registered tools: gsd_save_decision, gsd_update_requirement, gsd_save_summary. +// Tests the three registered tools: gsd_decision_save, gsd_requirement_update, gsd_summary_save. // Each tool is tested via direct function invocation against an in-memory DB. import { createTestContext } from './test-helpers.ts'; @@ -50,10 +50,10 @@ function cleanupDir(dir: string): void { */ // ═══════════════════════════════════════════════════════════════════════════ -// gsd_save_decision tool tests +// gsd_decision_save tool tests // ═══════════════════════════════════════════════════════════════════════════ -console.log('\n── gsd_save_decision ──'); +console.log('\n── gsd_decision_save ──'); { const tmpDir = makeTmpDir(); @@ -121,10 +121,10 @@ console.log('\n── gsd_save_decision ──'); } // ═══════════════════════════════════════════════════════════════════════════ -// gsd_update_requirement tool tests +// gsd_requirement_update tool tests // ═══════════════════════════════════════════════════════════════════════════ -console.log('\n── gsd_update_requirement ──'); +console.log('\n── gsd_requirement_update ──'); { const tmpDir = makeTmpDir(); @@ -192,10 +192,10 @@ console.log('\n── gsd_update_requirement ──'); } // ═══════════════════════════════════════════════════════════════════════════ -// gsd_save_summary tool tests +// gsd_summary_save tool tests // ═══════════════════════════════════════════════════════════════════════════ -console.log('\n── gsd_save_summary ──'); +console.log('\n── gsd_summary_save ──'); { const tmpDir = makeTmpDir(); diff --git a/src/resources/extensions/gsd/tests/journal-integration.test.ts b/src/resources/extensions/gsd/tests/journal-integration.test.ts new file mode 100644 index 000000000..e2124f7f6 --- /dev/null +++ b/src/resources/extensions/gsd/tests/journal-integration.test.ts @@ -0,0 +1,513 @@ +/** + * journal-integration.test.ts — Integration tests proving that phase functions + * emit correct journal event sequences with flowId threading, rule provenance, + * and causedBy references. + * + * These tests call the real runDispatch / runUnitPhase / runPreDispatch + * functions with mock LoopDeps that capture emitJournalEvent calls. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; + +import type { JournalEntry } from "../journal.js"; +import type { LoopDeps } from "../auto/loop-deps.js"; +import type { IterationContext, LoopState, PreDispatchData, IterationData } from "../auto/types.js"; +import type { SessionLockStatus } from "../session-lock.js"; +import { runDispatch, runUnitPhase, runPreDispatch } from "../auto/phases.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Captured journal events from the mock deps. */ +function createEventCapture() { + const events: JournalEntry[] = []; + return { + events, + emitJournalEvent: (entry: JournalEntry) => { events.push(entry); }, + }; +} + +/** Minimal mock LoopDeps with journal event capture. */ +function makeMockDeps( + capture: ReturnType, + overrides?: Partial, +): LoopDeps { + const baseDeps: LoopDeps = { + lockBase: () => "/tmp/test-lock", + buildSnapshotOpts: () => ({}), + stopAuto: async () => {}, + pauseAuto: async () => {}, + clearUnitTimeout: () => {}, + updateProgressWidget: () => {}, + syncCmuxSidebar: () => {}, + logCmuxEvent: () => {}, + invalidateAllCaches: () => {}, + deriveState: async () => ({ + phase: "executing", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: { id: "S01", title: "Slice 1" }, + activeTask: { id: "T01" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + }) as any, + loadEffectiveGSDPreferences: () => ({ preferences: {} }), + preDispatchHealthGate: async () => ({ proceed: true, fixesApplied: [] }), + syncProjectRootToWorktree: () => {}, + checkResourcesStale: () => null, + validateSessionLock: () => ({ valid: true }) as SessionLockStatus, + updateSessionLock: () => {}, + handleLostSessionLock: () => {}, + sendDesktopNotification: () => {}, + setActiveMilestoneId: () => {}, + pruneQueueOrder: () => {}, + isInAutoWorktree: () => false, + shouldUseWorktreeIsolation: () => false, + mergeMilestoneToMain: () => ({ pushed: false }), + teardownAutoWorktree: () => {}, + createAutoWorktree: () => "/tmp/wt", + captureIntegrationBranch: () => {}, + getIsolationMode: () => "none", + getCurrentBranch: () => "main", + autoWorktreeBranch: () => "auto/M001", + resolveMilestoneFile: () => null, + reconcileMergeState: () => false, + getLedger: () => ({ units: [] }), + getProjectTotals: () => ({ cost: 0 }), + formatCost: (c: number) => `$${c.toFixed(2)}`, + getBudgetAlertLevel: () => 0, + getNewBudgetAlertLevel: () => 0, + getBudgetEnforcementAction: () => "none", + getManifestStatus: async () => null, + collectSecretsFromManifest: async () => null, + resolveDispatch: async () => ({ + action: "dispatch" as const, + unitType: "execute-task", + unitId: "M001/S01/T01", + prompt: "do the thing", + matchedRule: "test-rule-alpha", + }), + runPreDispatchHooks: () => ({ firedHooks: [], action: "proceed" }), + getPriorSliceCompletionBlocker: () => null, + getMainBranch: () => "main", + collectObservabilityWarnings: async () => [], + buildObservabilityRepairBlock: () => null, + closeoutUnit: async () => {}, + verifyExpectedArtifact: () => true, + clearUnitRuntimeRecord: () => {}, + writeUnitRuntimeRecord: () => {}, + recordOutcome: () => {}, + writeLock: () => {}, + captureAvailableSkills: () => {}, + ensurePreconditions: () => {}, + updateSliceProgressCache: () => {}, + selectAndApplyModel: async () => ({ routing: null }), + startUnitSupervision: () => {}, + getDeepDiagnostic: () => null, + isDbAvailable: () => false, + reorderForCaching: (p: string) => p, + existsSync: (p: string) => p.endsWith(".git") || p.endsWith("package.json"), + readFileSync: () => "", + atomicWriteSync: () => {}, + GitServiceImpl: class {} as any, + resolver: { + get workPath() { return "/tmp/project"; }, + get projectRoot() { return "/tmp/project"; }, + get lockPath() { return "/tmp/project"; }, + enterMilestone: () => {}, + exitMilestone: () => {}, + mergeAndExit: () => {}, + mergeAndEnterNext: () => {}, + } as any, + postUnitPreVerification: async () => "continue" as const, + runPostUnitVerification: async () => "continue" as const, + postUnitPostVerification: async () => "continue" as const, + getSessionFile: () => "/tmp/session.json", + rebuildState: async () => {}, + resolveModelId: (id: string, models: any[]) => models.find((m: any) => m.id === id), + emitJournalEvent: capture.emitJournalEvent, + }; + + return { ...baseDeps, ...overrides }; +} + +/** Build a mock IterationContext with real flowId and seqCounter. */ +function makeIC( + deps: LoopDeps, + overrides?: Partial, +): IterationContext { + const flowId = randomUUID(); + let seqCounter = 0; + return { + ctx: { + ui: { notify: () => {}, setStatus: () => {} }, + model: { id: "test-model" }, + modelRegistry: { getAvailable: () => [] }, + } as any, + pi: { + sendMessage: () => {}, + setModel: async () => true, + } as any, + s: makeSession(), + deps, + prefs: undefined, + iteration: 1, + flowId, + nextSeq: () => ++seqCounter, + ...overrides, + }; +} + +/** Minimal mock session for phase calls. */ +function makeSession() { + return { + active: true, + verbose: false, + stepMode: false, + paused: false, + basePath: "/tmp/project", + originalBasePath: "", + currentMilestoneId: "M001", + currentUnit: null, + currentUnitRouting: null, + completedUnits: [], + resourceVersionOnStart: null, + lastPromptCharCount: undefined, + lastBaselineCharCount: undefined, + lastBudgetAlertLevel: 0, + pendingVerificationRetry: null, + pendingCrashRecovery: null, + pendingQuickTasks: [], + sidecarQueue: [], + autoModeStartModel: null, + unitDispatchCount: new Map(), + unitLifetimeDispatches: new Map(), + unitRecoveryCount: new Map(), + verificationRetryCount: new Map(), + gitService: null, + autoStartTime: Date.now(), + cmdCtx: { + newSession: () => Promise.resolve({ cancelled: false }), + getContextUsage: () => ({ percent: 10, tokens: 1000, limit: 10000 }), + }, + clearTimers: () => {}, + } as any; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +test("runDispatch emits dispatch-match with correct rule and flowId", async () => { + const capture = createEventCapture(); + const deps = makeMockDeps(capture, { + resolveDispatch: async () => ({ + action: "dispatch" as const, + unitType: "execute-task", + unitId: "M001/S01/T01", + prompt: "do the thing", + matchedRule: "slice-task-rule", + }), + }); + const ic = makeIC(deps); + const preData: PreDispatchData = { + state: { + phase: "executing", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: { id: "S01", title: "Slice 1" }, + activeTask: { id: "T01" }, + registry: [{ id: "M001", status: "active" }], + blockers: [], + } as any, + mid: "M001", + midTitle: "Test Milestone", + }; + const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 }; + + const result = await runDispatch(ic, preData, loopState); + + assert.equal(result.action, "next", "runDispatch should return next for dispatch action"); + + const matchEvents = capture.events.filter(e => e.eventType === "dispatch-match"); + assert.equal(matchEvents.length, 1, "should emit exactly one dispatch-match event"); + + const ev = matchEvents[0]; + assert.equal(ev.flowId, ic.flowId, "dispatch-match event should share the iteration flowId"); + assert.equal(ev.rule, "slice-task-rule", "dispatch-match should carry the matched rule name"); + assert.equal((ev.data as any).unitType, "execute-task"); + assert.equal((ev.data as any).unitId, "M001/S01/T01"); +}); + +test("runDispatch emits dispatch-stop when dispatch returns stop action", async () => { + const capture = createEventCapture(); + const deps = makeMockDeps(capture, { + resolveDispatch: async () => ({ + action: "stop" as const, + reason: "no eligible units", + level: "info" as const, + matchedRule: "", + }), + }); + const ic = makeIC(deps); + const preData: PreDispatchData = { + state: { phase: "executing", activeMilestone: { id: "M001" }, registry: [{ id: "M001", status: "active" }], blockers: [] } as any, + mid: "M001", + midTitle: "Test", + }; + const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 }; + + const result = await runDispatch(ic, preData, loopState); + assert.equal(result.action, "break"); + + const stopEvents = capture.events.filter(e => e.eventType === "dispatch-stop"); + assert.equal(stopEvents.length, 1); + assert.equal(stopEvents[0].rule, ""); + assert.equal((stopEvents[0].data as any).reason, "no eligible units"); + assert.equal(stopEvents[0].flowId, ic.flowId); +}); + +test("runUnitPhase emits unit-start and unit-end with causedBy reference", async () => { + const capture = createEventCapture(); + + // We need runUnit to return immediately — mock it by providing a session + // whose cmdCtx.newSession resolves immediately and the result is completed. + // Actually, runUnitPhase calls the real runUnit which creates a pending + // promise and blocks. We need a different approach. + // + // Instead, we test that unit-start is emitted at the right point by examining + // the event immediately after calling runUnitPhase with a session where + // newSession resolves quickly, and we resolve the agent_end externally. + const { resolveAgentEnd, _resetPendingResolve } = await import("../auto-loop.js"); + _resetPendingResolve(); + + const deps = makeMockDeps(capture); + const ic = makeIC(deps); + const iterData: IterationData = { + unitType: "execute-task", + unitId: "M001/S01/T01", + prompt: "do stuff", + finalPrompt: "do stuff", + pauseAfterUatDispatch: false, + observabilityIssues: [], + state: { phase: "executing", activeMilestone: { id: "M001" }, activeSlice: { id: "S01" }, registry: [], blockers: [] } as any, + mid: "M001", + midTitle: "Test", + isRetry: false, + previousTier: undefined, + }; + const loopState: LoopState = { recentUnits: [{ key: "execute-task/M001/S01/T01" }], stuckRecoveryAttempts: 0 }; + + // Start runUnitPhase (it will block on runUnit internally) + const unitPromise = runUnitPhase(ic, iterData, loopState); + + // Give it time to reach the await inside runUnit + await new Promise(r => setTimeout(r, 50)); + + // Resolve the agent_end + resolveAgentEnd({ messages: [{ role: "assistant" }] }); + + const result = await unitPromise; + assert.equal(result.action, "next"); + + // Check unit-start + const startEvents = capture.events.filter(e => e.eventType === "unit-start"); + assert.equal(startEvents.length, 1, "should emit exactly one unit-start"); + assert.equal(startEvents[0].flowId, ic.flowId); + assert.equal((startEvents[0].data as any).unitType, "execute-task"); + assert.equal((startEvents[0].data as any).unitId, "M001/S01/T01"); + + // Check unit-end + const endEvents = capture.events.filter(e => e.eventType === "unit-end"); + assert.equal(endEvents.length, 1, "should emit exactly one unit-end"); + assert.equal(endEvents[0].flowId, ic.flowId); + assert.equal((endEvents[0].data as any).unitType, "execute-task"); + assert.equal((endEvents[0].data as any).unitId, "M001/S01/T01"); + assert.equal((endEvents[0].data as any).status, "completed"); + + // Verify causedBy: unit-end references unit-start's seq + assert.ok(endEvents[0].causedBy, "unit-end must have a causedBy reference"); + assert.equal(endEvents[0].causedBy!.flowId, ic.flowId); + assert.equal(endEvents[0].causedBy!.seq, startEvents[0].seq, "unit-end causedBy.seq must match unit-start.seq"); +}); + +test("all events from a mock iteration have monotonically increasing seq and same flowId", async () => { + const capture = createEventCapture(); + const { resolveAgentEnd, _resetPendingResolve } = await import("../auto-loop.js"); + _resetPendingResolve(); + + const deps = makeMockDeps(capture, { + resolveDispatch: async () => ({ + action: "dispatch" as const, + unitType: "execute-task", + unitId: "M001/S01/T01", + prompt: "do the thing", + matchedRule: "my-rule", + }), + }); + const ic = makeIC(deps); + + // Phase 1: Dispatch + const preData: PreDispatchData = { + state: { phase: "executing", activeMilestone: { id: "M001", title: "T", status: "active" }, activeSlice: { id: "S01" }, activeTask: { id: "T01" }, registry: [{ id: "M001", status: "active" }], blockers: [] } as any, + mid: "M001", + midTitle: "Test", + }; + const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 }; + const dispatchResult = await runDispatch(ic, preData, loopState); + assert.equal(dispatchResult.action, "next"); + + // Phase 2: Unit execution + const iterData = (dispatchResult as { action: "next"; data: IterationData }).data; + const unitPromise = runUnitPhase(ic, iterData, loopState); + await new Promise(r => setTimeout(r, 50)); + resolveAgentEnd({ messages: [{ role: "assistant" }] }); + await unitPromise; + + // Verify all events share the same flowId + assert.ok(capture.events.length >= 3, `expected at least 3 events (dispatch-match, unit-start, unit-end), got ${capture.events.length}`); + const flowId = ic.flowId; + for (const ev of capture.events) { + assert.equal(ev.flowId, flowId, `all events must share flowId=${flowId}, found event ${ev.eventType} with flowId=${ev.flowId}`); + } + + // Verify monotonically increasing seq numbers + for (let i = 1; i < capture.events.length; i++) { + assert.ok( + capture.events[i].seq > capture.events[i - 1].seq, + `seq must be monotonically increasing: event[${i - 1}].seq=${capture.events[i - 1].seq} (${capture.events[i - 1].eventType}) should be less than event[${i}].seq=${capture.events[i].seq} (${capture.events[i].eventType})`, + ); + } +}); + +test("dispatch-match events include matchedRule field matching the rule name", async () => { + const capture = createEventCapture(); + const RULE_NAME = "priority-execution-rule"; + const deps = makeMockDeps(capture, { + resolveDispatch: async () => ({ + action: "dispatch" as const, + unitType: "execute-task", + unitId: "M001/S01/T01", + prompt: "test", + matchedRule: RULE_NAME, + }), + }); + const ic = makeIC(deps); + const preData: PreDispatchData = { + state: { phase: "executing", activeMilestone: { id: "M001", title: "T", status: "active" }, activeSlice: { id: "S01" }, activeTask: { id: "T01" }, registry: [{ id: "M001", status: "active" }], blockers: [] } as any, + mid: "M001", + midTitle: "Test", + }; + + await runDispatch(ic, preData, { recentUnits: [], stuckRecoveryAttempts: 0 }); + + const matchEvents = capture.events.filter(e => e.eventType === "dispatch-match"); + assert.equal(matchEvents.length, 1); + assert.equal(matchEvents[0].rule, RULE_NAME, "dispatch-match event.rule must equal the matchedRule from dispatch result"); +}); + +test("pre-dispatch-hook event is emitted when hooks fire", async () => { + const capture = createEventCapture(); + const deps = makeMockDeps(capture, { + resolveDispatch: async () => ({ + action: "dispatch" as const, + unitType: "execute-task", + unitId: "M001/S01/T01", + prompt: "test", + matchedRule: "some-rule", + }), + runPreDispatchHooks: () => ({ + firedHooks: ["observability-check", "lint-gate"], + action: "proceed", + }), + }); + const ic = makeIC(deps); + const preData: PreDispatchData = { + state: { phase: "executing", activeMilestone: { id: "M001", title: "T", status: "active" }, activeSlice: { id: "S01" }, activeTask: { id: "T01" }, registry: [{ id: "M001", status: "active" }], blockers: [] } as any, + mid: "M001", + midTitle: "Test", + }; + + await runDispatch(ic, preData, { recentUnits: [], stuckRecoveryAttempts: 0 }); + + const hookEvents = capture.events.filter(e => e.eventType === "pre-dispatch-hook"); + assert.equal(hookEvents.length, 1, "should emit one pre-dispatch-hook event"); + assert.deepEqual((hookEvents[0].data as any).firedHooks, ["observability-check", "lint-gate"]); + assert.equal((hookEvents[0].data as any).action, "proceed"); + assert.equal(hookEvents[0].flowId, ic.flowId); +}); + +test("terminal event is emitted on milestone-complete", async () => { + const capture = createEventCapture(); + const deps = makeMockDeps(capture, { + deriveState: async () => ({ + phase: "complete", + activeMilestone: { id: "M001", title: "Test", status: "complete" }, + activeSlice: null, + activeTask: null, + registry: [{ id: "M001", status: "complete" }], + blockers: [], + }) as any, + }); + const ic = makeIC(deps); + const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 }; + + const result = await runPreDispatch(ic, loopState); + assert.equal(result.action, "break"); + + const terminalEvents = capture.events.filter(e => e.eventType === "terminal"); + assert.equal(terminalEvents.length, 1, "should emit one terminal event"); + assert.equal((terminalEvents[0].data as any).reason, "milestone-complete"); + assert.equal(terminalEvents[0].flowId, ic.flowId); +}); + +test("terminal event is emitted on blocked state", async () => { + const capture = createEventCapture(); + const deps = makeMockDeps(capture, { + deriveState: async () => ({ + phase: "blocked", + activeMilestone: { id: "M001", title: "Test", status: "active" }, + activeSlice: null, + activeTask: null, + registry: [{ id: "M001", status: "active" }], + blockers: ["Missing API key"], + }) as any, + }); + const ic = makeIC(deps); + const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 }; + + const result = await runPreDispatch(ic, loopState); + assert.equal(result.action, "break"); + + const terminalEvents = capture.events.filter(e => e.eventType === "terminal"); + assert.equal(terminalEvents.length, 1); + assert.equal((terminalEvents[0].data as any).reason, "blocked"); + assert.deepEqual((terminalEvents[0].data as any).blockers, ["Missing API key"]); +}); + +test("milestone-transition event is emitted when milestone changes", async () => { + const capture = createEventCapture(); + const deps = makeMockDeps(capture, { + deriveState: async () => ({ + phase: "executing", + activeMilestone: { id: "M002", title: "Next Milestone", status: "active" }, + activeSlice: { id: "S01" }, + activeTask: { id: "T01" }, + registry: [ + { id: "M001", status: "complete" }, + { id: "M002", status: "active" }, + ], + blockers: [], + }) as any, + }); + const ic = makeIC(deps); + // Session says current milestone is M001, but state will return M002 + ic.s.currentMilestoneId = "M001"; + const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 }; + + await runPreDispatch(ic, loopState); + + const transitionEvents = capture.events.filter(e => e.eventType === "milestone-transition"); + assert.equal(transitionEvents.length, 1, "should emit one milestone-transition event"); + assert.equal((transitionEvents[0].data as any).from, "M001"); + assert.equal((transitionEvents[0].data as any).to, "M002"); + assert.equal(transitionEvents[0].flowId, ic.flowId); +}); diff --git a/src/resources/extensions/gsd/tests/journal-query-tool.test.ts b/src/resources/extensions/gsd/tests/journal-query-tool.test.ts new file mode 100644 index 000000000..97ed0a7d2 --- /dev/null +++ b/src/resources/extensions/gsd/tests/journal-query-tool.test.ts @@ -0,0 +1,147 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +import { registerJournalTools } from "../bootstrap/journal-tools.ts"; +import { emitJournalEvent, type JournalEntry } from "../journal.ts"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeMockPi() { + const tools: any[] = []; + return { + registerTool: (tool: any) => tools.push(tool), + tools, + } as any; +} + +function makeTmpBase(): string { + const base = join(tmpdir(), `gsd-journal-tool-test-${randomUUID()}`); + mkdirSync(join(base, ".gsd"), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + try { + rmSync(base, { recursive: true, force: true }); + } catch { + /* */ + } +} + +function makeEntry(overrides: Partial = {}): JournalEntry { + return { + ts: "2025-03-21T10:00:00.000Z", + flowId: "flow-aaa", + seq: 0, + eventType: "iteration-start", + ...overrides, + }; +} + +async function executeToolInDir(tool: any, params: Record, dir: string) { + const originalCwd = process.cwd(); + try { + process.chdir(dir); + return await tool.execute("test-call-id", params, undefined, undefined, undefined); + } finally { + process.chdir(originalCwd); + } +} + +// ─── Registration ───────────────────────────────────────────────────────────── + +test("registerJournalTools registers gsd_journal_query tool", () => { + const pi = makeMockPi(); + registerJournalTools(pi); + assert.equal(pi.tools.length, 1, "Should register exactly one tool"); + assert.equal(pi.tools[0].name, "gsd_journal_query"); +}); + +// ─── Filtering ──────────────────────────────────────────────────────────────── + +test("gsd_journal_query returns filtered entries", async () => { + const base = makeTmpBase(); + try { + emitJournalEvent(base, makeEntry({ seq: 0, flowId: "flow-aaa", data: { unitId: "M001/S01/T01" } })); + emitJournalEvent(base, makeEntry({ seq: 1, flowId: "flow-bbb", data: { unitId: "M001/S01/T02" } })); + emitJournalEvent(base, makeEntry({ seq: 2, flowId: "flow-aaa", data: { unitId: "M001/S01/T01" } })); + + const pi = makeMockPi(); + registerJournalTools(pi); + const tool = pi.tools[0]; + + const result = await executeToolInDir(tool, { unitId: "M001/S01/T01" }, base); + const entries = JSON.parse(result.content[0].text) as JournalEntry[]; + + assert.equal(entries.length, 2, "Should return 2 entries matching unitId"); + assert.ok( + entries.every((e: any) => e.data?.unitId === "M001/S01/T01"), + "All entries should have matching unitId", + ); + } finally { + cleanup(base); + } +}); + +// ─── Empty Results ──────────────────────────────────────────────────────────── + +test("gsd_journal_query returns 'no entries' message for empty results", async () => { + const base = makeTmpBase(); + try { + emitJournalEvent(base, makeEntry({ seq: 0, flowId: "flow-aaa" })); + + const pi = makeMockPi(); + registerJournalTools(pi); + const tool = pi.tools[0]; + + const result = await executeToolInDir(tool, { flowId: "nonexistent-flow" }, base); + assert.equal(result.content[0].text, "No matching journal entries found."); + } finally { + cleanup(base); + } +}); + +// ─── Limit ──────────────────────────────────────────────────────────────────── + +test("gsd_journal_query respects limit parameter", async () => { + const base = makeTmpBase(); + try { + for (let i = 0; i < 5; i++) { + emitJournalEvent(base, makeEntry({ seq: i })); + } + + const pi = makeMockPi(); + registerJournalTools(pi); + const tool = pi.tools[0]; + + const result = await executeToolInDir(tool, { limit: 2 }, base); + const entries = JSON.parse(result.content[0].text) as JournalEntry[]; + assert.equal(entries.length, 2, "Should return only 2 entries"); + } finally { + cleanup(base); + } +}); + +// ─── Error Handling ─────────────────────────────────────────────────────────── + +test("gsd_journal_query handles errors gracefully", async () => { + const pi = makeMockPi(); + registerJournalTools(pi); + const tool = pi.tools[0]; + + // queryJournal returns [] for missing journal dirs (never throws), so empty + // result is the expected behavior. This confirms the tool doesn't crash and + // returns the "no entries" message when there's no journal data. + const base = join(tmpdir(), `gsd-journal-tool-test-${randomUUID()}`); + mkdirSync(base, { recursive: true }); // dir must exist for process.chdir + try { + const result = await executeToolInDir(tool, {}, base); + assert.equal(result.content[0].text, "No matching journal entries found."); + } finally { + cleanup(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/journal.test.ts b/src/resources/extensions/gsd/tests/journal.test.ts new file mode 100644 index 000000000..5808b67bb --- /dev/null +++ b/src/resources/extensions/gsd/tests/journal.test.ts @@ -0,0 +1,386 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + mkdirSync, + readFileSync, + existsSync, + rmSync, + chmodSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +import { + emitJournalEvent, + queryJournal, + type JournalEntry, +} from "../journal.ts"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeTmpBase(): string { + const base = join(tmpdir(), `gsd-journal-test-${randomUUID()}`); + mkdirSync(join(base, ".gsd"), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + try { + rmSync(base, { recursive: true, force: true }); + } catch { + /* */ + } +} + +function makeEntry(overrides: Partial = {}): JournalEntry { + return { + ts: "2025-03-21T10:00:00.000Z", + flowId: "flow-aaa", + seq: 0, + eventType: "iteration-start", + ...overrides, + }; +} + +// ─── emitJournalEvent ───────────────────────────────────────────────────────── + +test("emitJournalEvent creates journal directory and JSONL file", () => { + const base = makeTmpBase(); + try { + const entry = makeEntry(); + emitJournalEvent(base, entry); + + const filePath = join(base, ".gsd", "journal", "2025-03-21.jsonl"); + assert.ok(existsSync(filePath), "JSONL file should exist"); + + const raw = readFileSync(filePath, "utf-8").trim(); + const parsed = JSON.parse(raw); + assert.equal(parsed.ts, entry.ts); + assert.equal(parsed.flowId, entry.flowId); + assert.equal(parsed.seq, entry.seq); + assert.equal(parsed.eventType, entry.eventType); + } finally { + cleanup(base); + } +}); + +test("emitJournalEvent appends multiple lines to the same file", () => { + const base = makeTmpBase(); + try { + emitJournalEvent(base, makeEntry({ seq: 0 })); + emitJournalEvent(base, makeEntry({ seq: 1, eventType: "dispatch-match" })); + emitJournalEvent(base, makeEntry({ seq: 2, eventType: "unit-start" })); + + const filePath = join(base, ".gsd", "journal", "2025-03-21.jsonl"); + const lines = readFileSync(filePath, "utf-8").trim().split("\n"); + assert.equal(lines.length, 3, "Should have 3 lines"); + + const parsed = lines.map(l => JSON.parse(l)); + assert.equal(parsed[0].seq, 0); + assert.equal(parsed[1].seq, 1); + assert.equal(parsed[2].seq, 2); + assert.equal(parsed[1].eventType, "dispatch-match"); + } finally { + cleanup(base); + } +}); + +test("emitJournalEvent auto-creates nonexistent parent directory", () => { + const base = join(tmpdir(), `gsd-journal-test-${randomUUID()}`); + // Don't create .gsd/ — emitJournalEvent should handle it via mkdirSync recursive + try { + emitJournalEvent(base, makeEntry()); + const filePath = join(base, ".gsd", "journal", "2025-03-21.jsonl"); + assert.ok(existsSync(filePath), "File should exist even when parent dirs did not"); + } finally { + cleanup(base); + } +}); + +test("emitJournalEvent preserves optional fields (rule, causedBy, data)", () => { + const base = makeTmpBase(); + try { + const entry = makeEntry({ + rule: "my-dispatch-rule", + causedBy: { flowId: "flow-prior", seq: 3 }, + data: { unitId: "M001/S01/T01", status: "ok" }, + }); + emitJournalEvent(base, entry); + + const filePath = join(base, ".gsd", "journal", "2025-03-21.jsonl"); + const parsed = JSON.parse(readFileSync(filePath, "utf-8").trim()); + assert.equal(parsed.rule, "my-dispatch-rule"); + assert.deepEqual(parsed.causedBy, { flowId: "flow-prior", seq: 3 }); + assert.equal(parsed.data.unitId, "M001/S01/T01"); + assert.equal(parsed.data.status, "ok"); + } finally { + cleanup(base); + } +}); + +test("emitJournalEvent silently catches write errors (no throw)", () => { + // Use a path that can't be created — null bytes in path + assert.doesNotThrow(() => { + emitJournalEvent("/dev/null/impossible\0path", makeEntry()); + }); +}); + +test("emitJournalEvent silently catches read-only directory errors", () => { + const base = makeTmpBase(); + const journalDir = join(base, ".gsd", "journal"); + mkdirSync(journalDir, { recursive: true }); + + try { + // Make the journal directory read-only + chmodSync(journalDir, 0o444); + + // Should not throw + assert.doesNotThrow(() => { + emitJournalEvent(base, makeEntry()); + }); + } finally { + // Restore permissions for cleanup + try { + chmodSync(journalDir, 0o755); + } catch { + /* */ + } + cleanup(base); + } +}); + +// ─── Daily Rotation ─────────────────────────────────────────────────────────── + +test("daily rotation: events with different dates go to different files", () => { + const base = makeTmpBase(); + try { + emitJournalEvent(base, makeEntry({ ts: "2025-03-20T23:59:59.000Z" })); + emitJournalEvent(base, makeEntry({ ts: "2025-03-21T00:00:01.000Z" })); + emitJournalEvent(base, makeEntry({ ts: "2025-03-22T12:00:00.000Z" })); + + const journalDir = join(base, ".gsd", "journal"); + assert.ok(existsSync(join(journalDir, "2025-03-20.jsonl"))); + assert.ok(existsSync(join(journalDir, "2025-03-21.jsonl"))); + assert.ok(existsSync(join(journalDir, "2025-03-22.jsonl"))); + + // Verify each file has exactly one line + for (const date of ["2025-03-20", "2025-03-21", "2025-03-22"]) { + const lines = readFileSync(join(journalDir, `${date}.jsonl`), "utf-8") + .trim() + .split("\n"); + assert.equal(lines.length, 1, `${date}.jsonl should have 1 line`); + } + } finally { + cleanup(base); + } +}); + +// ─── queryJournal ───────────────────────────────────────────────────────────── + +test("queryJournal returns all entries when no filters provided", () => { + const base = makeTmpBase(); + try { + emitJournalEvent(base, makeEntry({ seq: 0 })); + emitJournalEvent(base, makeEntry({ seq: 1, eventType: "dispatch-match" })); + + const results = queryJournal(base); + assert.equal(results.length, 2); + assert.equal(results[0].seq, 0); + assert.equal(results[1].seq, 1); + } finally { + cleanup(base); + } +}); + +test("queryJournal filters by flowId", () => { + const base = makeTmpBase(); + try { + emitJournalEvent(base, makeEntry({ flowId: "flow-aaa", seq: 0 })); + emitJournalEvent(base, makeEntry({ flowId: "flow-bbb", seq: 1 })); + emitJournalEvent(base, makeEntry({ flowId: "flow-aaa", seq: 2 })); + + const results = queryJournal(base, { flowId: "flow-aaa" }); + assert.equal(results.length, 2); + assert.ok(results.every(e => e.flowId === "flow-aaa")); + } finally { + cleanup(base); + } +}); + +test("queryJournal filters by eventType", () => { + const base = makeTmpBase(); + try { + emitJournalEvent(base, makeEntry({ eventType: "iteration-start", seq: 0 })); + emitJournalEvent(base, makeEntry({ eventType: "dispatch-match", seq: 1 })); + emitJournalEvent(base, makeEntry({ eventType: "unit-start", seq: 2 })); + emitJournalEvent(base, makeEntry({ eventType: "dispatch-match", seq: 3 })); + + const results = queryJournal(base, { eventType: "dispatch-match" }); + assert.equal(results.length, 2); + assert.ok(results.every(e => e.eventType === "dispatch-match")); + } finally { + cleanup(base); + } +}); + +test("queryJournal filters by unitId (from data.unitId)", () => { + const base = makeTmpBase(); + try { + emitJournalEvent( + base, + makeEntry({ seq: 0, data: { unitId: "M001/S01/T01" } }), + ); + emitJournalEvent( + base, + makeEntry({ seq: 1, data: { unitId: "M001/S01/T02" } }), + ); + emitJournalEvent( + base, + makeEntry({ seq: 2, data: { unitId: "M001/S01/T01" } }), + ); + emitJournalEvent(base, makeEntry({ seq: 3 })); // no data + + const results = queryJournal(base, { unitId: "M001/S01/T01" }); + assert.equal(results.length, 2); + assert.ok( + results.every( + e => (e.data as Record)?.unitId === "M001/S01/T01", + ), + ); + } finally { + cleanup(base); + } +}); + +test("queryJournal filters by time range (after/before)", () => { + const base = makeTmpBase(); + try { + emitJournalEvent(base, makeEntry({ ts: "2025-03-20T08:00:00.000Z", seq: 0 })); + emitJournalEvent(base, makeEntry({ ts: "2025-03-21T10:00:00.000Z", seq: 1 })); + emitJournalEvent(base, makeEntry({ ts: "2025-03-21T15:00:00.000Z", seq: 2 })); + emitJournalEvent(base, makeEntry({ ts: "2025-03-22T20:00:00.000Z", seq: 3 })); + + // After only + const afterResults = queryJournal(base, { after: "2025-03-21T00:00:00.000Z" }); + assert.equal(afterResults.length, 3, "3 entries on or after 2025-03-21"); + + // Before only + const beforeResults = queryJournal(base, { before: "2025-03-21T12:00:00.000Z" }); + assert.equal(beforeResults.length, 2, "2 entries on or before noon on 03-21"); + + // Both after and before + const rangeResults = queryJournal(base, { + after: "2025-03-21T00:00:00.000Z", + before: "2025-03-21T23:59:59.000Z", + }); + assert.equal(rangeResults.length, 2, "2 entries within 2025-03-21"); + } finally { + cleanup(base); + } +}); + +test("queryJournal combines multiple filters", () => { + const base = makeTmpBase(); + try { + emitJournalEvent( + base, + makeEntry({ flowId: "flow-aaa", eventType: "unit-start", seq: 0 }), + ); + emitJournalEvent( + base, + makeEntry({ flowId: "flow-aaa", eventType: "dispatch-match", seq: 1 }), + ); + emitJournalEvent( + base, + makeEntry({ flowId: "flow-bbb", eventType: "unit-start", seq: 2 }), + ); + + const results = queryJournal(base, { + flowId: "flow-aaa", + eventType: "unit-start", + }); + assert.equal(results.length, 1); + assert.equal(results[0].flowId, "flow-aaa"); + assert.equal(results[0].eventType, "unit-start"); + } finally { + cleanup(base); + } +}); + +test("queryJournal on nonexistent directory returns empty array", () => { + const base = join(tmpdir(), `gsd-journal-test-${randomUUID()}`); + // Don't create anything + try { + const results = queryJournal(base); + assert.deepEqual(results, []); + } finally { + cleanup(base); + } +}); + +test("queryJournal skips malformed JSON lines gracefully", () => { + const base = makeTmpBase(); + try { + const journalDir = join(base, ".gsd", "journal"); + mkdirSync(journalDir, { recursive: true }); + + // Write a file with a mix of valid and invalid lines + const validEntry = JSON.stringify(makeEntry({ seq: 0 })); + const content = `${validEntry}\n{not valid json\n${JSON.stringify(makeEntry({ seq: 1 }))}\n`; + writeFileSync(join(journalDir, "2025-03-21.jsonl"), content); + + const results = queryJournal(base); + assert.equal(results.length, 2, "Should skip the malformed line"); + assert.equal(results[0].seq, 0); + assert.equal(results[1].seq, 1); + } finally { + cleanup(base); + } +}); + +test("queryJournal reads across multiple daily files", () => { + const base = makeTmpBase(); + try { + emitJournalEvent(base, makeEntry({ ts: "2025-03-20T12:00:00.000Z", seq: 0 })); + emitJournalEvent(base, makeEntry({ ts: "2025-03-21T12:00:00.000Z", seq: 1 })); + emitJournalEvent(base, makeEntry({ ts: "2025-03-22T12:00:00.000Z", seq: 2 })); + + const results = queryJournal(base); + assert.equal(results.length, 3, "Should read from all 3 files"); + // Files are sorted, so order should be chronological + assert.equal(results[0].ts, "2025-03-20T12:00:00.000Z"); + assert.equal(results[1].ts, "2025-03-21T12:00:00.000Z"); + assert.equal(results[2].ts, "2025-03-22T12:00:00.000Z"); + } finally { + cleanup(base); + } +}); + +test("queryJournal filters by rule", () => { + const base = makeTmpBase(); + try { + emitJournalEvent( + base, + makeEntry({ seq: 0, eventType: "dispatch-match", rule: "dispatch-task" }), + ); + emitJournalEvent( + base, + makeEntry({ seq: 1, eventType: "post-unit-hook", rule: "post-unit-hook" }), + ); + emitJournalEvent( + base, + makeEntry({ seq: 2, eventType: "dispatch-match", rule: "dispatch-task" }), + ); + + const results = queryJournal(base, { rule: "dispatch-task" }); + assert.equal(results.length, 2, "Should return only dispatch-task entries"); + assert.ok( + results.every(e => e.rule === "dispatch-task"), + "All results should have rule === 'dispatch-task'", + ); + } finally { + cleanup(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/milestone-id-reservation.test.ts b/src/resources/extensions/gsd/tests/milestone-id-reservation.test.ts index 814576205..787a5a451 100644 --- a/src/resources/extensions/gsd/tests/milestone-id-reservation.test.ts +++ b/src/resources/extensions/gsd/tests/milestone-id-reservation.test.ts @@ -1,5 +1,5 @@ // milestone-id-reservation — Verifies that preview IDs from guided-flow -// match the IDs claimed by gsd_generate_milestone_id via the shared +// match the IDs claimed by gsd_milestone_generate_id via the shared // reservation mechanism in milestone-ids.ts. // // Regression test for #1569. diff --git a/src/resources/extensions/gsd/tests/rule-registry.test.ts b/src/resources/extensions/gsd/tests/rule-registry.test.ts new file mode 100644 index 000000000..027f46fe6 --- /dev/null +++ b/src/resources/extensions/gsd/tests/rule-registry.test.ts @@ -0,0 +1,413 @@ +// GSD Extension — Rule Registry Tests +// +// Tests the RuleRegistry class, UnifiedRule types, singleton accessors, +// and evaluation methods using mock rules. + +import { test, describe, beforeEach } from "node:test"; +import { createTestContext } from "./test-helpers.ts"; +import { + RuleRegistry, + getRegistry, + setRegistry, + initRegistry, + resetRegistry, + convertDispatchRules, + getOrCreateRegistry, +} from "../rule-registry.ts"; +import type { UnifiedRule } from "../rule-types.ts"; +import type { DispatchAction, DispatchContext } from "../auto-dispatch.ts"; +import { DISPATCH_RULES, getDispatchRuleNames } from "../auto-dispatch.ts"; +import type { GSDState } from "../types.ts"; + +// ─── Mock Rule Factories ────────────────────────────────────────────────── + +function mockDispatchRule(name: string, matchPhase: string): UnifiedRule { + return { + name, + when: "dispatch", + evaluation: "first-match", + where: async (ctx: DispatchContext): Promise => { + if (ctx.state.phase === matchPhase) { + return { + action: "dispatch", + unitType: `test-${matchPhase}`, + unitId: "test-id", + prompt: `Prompt for ${matchPhase}`, + }; + } + return null; + }, + then: () => {}, + description: `Mock rule for ${matchPhase}`, + }; +} + +function makeContext(phase: string): DispatchContext { + return { + basePath: "/tmp/test", + mid: "M001", + midTitle: "Test Milestone", + state: { + phase: phase as any, + activeMilestone: { id: "M001", title: "Test" }, + activeSlice: null, + activeTask: null, + recentDecisions: [], + blockers: [], + nextAction: "", + registry: [], + }, + prefs: undefined, + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────── + +describe("RuleRegistry", () => { + const { assertEq, assertTrue } = createTestContext(); + + beforeEach(() => { + resetRegistry(); + }); + + test("construct with dispatch rules, listRules returns them", () => { + const rules: UnifiedRule[] = [ + mockDispatchRule("rule-a", "planning"), + mockDispatchRule("rule-b", "executing"), + mockDispatchRule("rule-c", "complete"), + ]; + const registry = new RuleRegistry(rules); + const listed = registry.listRules(); + + // At minimum, dispatch rules are returned (hook rules depend on prefs) + const dispatchRules = listed.filter(r => r.when === "dispatch"); + assertEq(dispatchRules.length, 3, "listRules returns 3 dispatch rules"); + assertEq(dispatchRules[0].name, "rule-a", "first rule name is rule-a"); + assertEq(dispatchRules[1].name, "rule-b", "second rule name is rule-b"); + assertEq(dispatchRules[2].name, "rule-c", "third rule name is rule-c"); + }); + + test("listRules returns correct fields on each rule", () => { + const rules: UnifiedRule[] = [ + mockDispatchRule("check-fields", "planning"), + ]; + const registry = new RuleRegistry(rules); + const listed = registry.listRules(); + const rule = listed.find(r => r.name === "check-fields")!; + + assertTrue(rule !== undefined, "rule found by name"); + assertEq(rule.when, "dispatch", "when field is dispatch"); + assertEq(rule.evaluation, "first-match", "evaluation is first-match"); + assertTrue(typeof rule.where === "function", "where is a function"); + assertTrue(typeof rule.then === "function", "then is a function"); + assertEq(rule.description, "Mock rule for planning", "description is set"); + }); + + test("evaluateDispatch returns first matching rule", async () => { + const rules: UnifiedRule[] = [ + mockDispatchRule("rule-planning", "planning"), + mockDispatchRule("rule-executing", "executing"), + mockDispatchRule("rule-complete", "complete"), + ]; + const registry = new RuleRegistry(rules); + const ctx = makeContext("executing"); + const result = await registry.evaluateDispatch(ctx); + + assertEq(result.action, "dispatch", "result is a dispatch action"); + if (result.action === "dispatch") { + assertEq(result.unitType, "test-executing", "matched the executing rule"); + assertEq(result.prompt, "Prompt for executing", "prompt from matched rule"); + } + }); + + test("evaluateDispatch returns stop when no rule matches", async () => { + const rules: UnifiedRule[] = [ + mockDispatchRule("only-planning", "planning"), + ]; + const registry = new RuleRegistry(rules); + const ctx = makeContext("blocked"); + const result = await registry.evaluateDispatch(ctx); + + assertEq(result.action, "stop", "result is a stop action"); + if (result.action === "stop") { + assertTrue(result.reason.includes("blocked"), "stop reason mentions phase"); + } + }); + + test("evaluateDispatch works with async where predicate", async () => { + const asyncRule: UnifiedRule = { + name: "async-rule", + when: "dispatch", + evaluation: "first-match", + where: async (ctx: DispatchContext): Promise => { + // Simulate async work + await new Promise(resolve => setTimeout(resolve, 1)); + if (ctx.state.phase === "planning") { + return { + action: "dispatch", + unitType: "async-test", + unitId: "async-id", + prompt: "Async prompt", + }; + } + return null; + }, + then: () => {}, + }; + + const registry = new RuleRegistry([asyncRule]); + const ctx = makeContext("planning"); + const result = await registry.evaluateDispatch(ctx); + + assertEq(result.action, "dispatch", "async dispatch resolved"); + if (result.action === "dispatch") { + assertEq(result.unitType, "async-test", "async rule matched"); + } + }); + + test("resetState clears all mutable state", () => { + const registry = new RuleRegistry([]); + + // Set up some state + registry.activeHook = { + hookName: "test-hook", + triggerUnitType: "execute-task", + triggerUnitId: "M001/S01/T01", + cycle: 2, + pendingRetry: false, + }; + registry.hookQueue.push({ + config: { name: "q", after: [], prompt: "p" }, + triggerUnitType: "execute-task", + triggerUnitId: "M001/S01/T02", + }); + registry.cycleCounts.set("test/key", 3); + registry.retryPending = true; + registry.retryTrigger = { unitType: "execute-task", unitId: "M001/S01/T01", retryArtifact: "RETRY" }; + + // Reset + registry.resetState(); + + assertEq(registry.getActiveHook(), null, "activeHook cleared"); + assertEq(registry.hookQueue.length, 0, "hookQueue cleared"); + assertEq(registry.cycleCounts.size, 0, "cycleCounts cleared"); + assertEq(registry.isRetryPending(), false, "retryPending cleared"); + assertEq(registry.consumeRetryTrigger(), null, "retryTrigger cleared"); + }); + + test("singleton getRegistry throws when not initialized", () => { + let threw = false; + try { + getRegistry(); + } catch (e: any) { + threw = true; + assertTrue(e.message.includes("not initialized"), "error mentions not initialized"); + } + assertTrue(threw, "getRegistry threw"); + }); + + test("setRegistry / getRegistry round-trips", () => { + const registry = new RuleRegistry([mockDispatchRule("singleton-test", "planning")]); + setRegistry(registry); + + const retrieved = getRegistry(); + assertEq(retrieved, registry, "getRegistry returns the same instance"); + + const listed = retrieved.listRules().filter(r => r.when === "dispatch"); + assertEq(listed.length, 1, "singleton has 1 dispatch rule"); + assertEq(listed[0].name, "singleton-test", "rule name matches"); + }); + + test("initRegistry creates and sets singleton", () => { + const rules = [mockDispatchRule("init-test", "executing")]; + const registry = initRegistry(rules); + + assertEq(getRegistry(), registry, "initRegistry sets the singleton"); + const listed = getRegistry().listRules().filter(r => r.when === "dispatch"); + assertEq(listed.length, 1, "singleton has the rule"); + }); + + test("evaluateDispatch respects rule order (first match wins)", async () => { + // Both rules match "planning" but rule-first should win + const ruleFirst: UnifiedRule = { + name: "rule-first", + when: "dispatch", + evaluation: "first-match", + where: async (ctx: DispatchContext) => { + if (ctx.state.phase === "planning") { + return { action: "dispatch" as const, unitType: "first-wins", unitId: "id", prompt: "first" }; + } + return null; + }, + then: () => {}, + }; + const ruleSecond: UnifiedRule = { + name: "rule-second", + when: "dispatch", + evaluation: "first-match", + where: async (ctx: DispatchContext) => { + if (ctx.state.phase === "planning") { + return { action: "dispatch" as const, unitType: "second-loses", unitId: "id", prompt: "second" }; + } + return null; + }, + then: () => {}, + }; + + const registry = new RuleRegistry([ruleFirst, ruleSecond]); + const ctx = makeContext("planning"); + const result = await registry.evaluateDispatch(ctx); + + assertEq(result.action, "dispatch", "dispatch action returned"); + if (result.action === "dispatch") { + assertEq(result.unitType, "first-wins", "first rule won over second"); + } + }); + + // ── Dispatch rule conversion tests ───────────────────────────────── + + test("convertDispatchRules produces correct count of UnifiedRule objects", () => { + const converted = convertDispatchRules(DISPATCH_RULES); + assertEq(converted.length, DISPATCH_RULES.length, `convertDispatchRules produces ${DISPATCH_RULES.length} rules`); + }); + + test("each converted rule has correct when, evaluation, and original name", () => { + const converted = convertDispatchRules(DISPATCH_RULES); + for (let i = 0; i < converted.length; i++) { + const rule = converted[i]; + assertEq(rule.when, "dispatch", `rule ${i} has when:"dispatch"`); + assertEq(rule.evaluation, "first-match", `rule ${i} has evaluation:"first-match"`); + assertEq(rule.name, DISPATCH_RULES[i].name, `rule ${i} preserves name "${DISPATCH_RULES[i].name}"`); + assertTrue(typeof rule.where === "function", `rule ${i} has a where function`); + assertTrue(typeof rule.then === "function", `rule ${i} has a then function`); + } + }); + + test("listRules after construction with real dispatch rules returns correct count", () => { + const converted = convertDispatchRules(DISPATCH_RULES); + const registry = new RuleRegistry(converted); + const listed = registry.listRules().filter(r => r.when === "dispatch"); + assertEq(listed.length, DISPATCH_RULES.length, `listRules returns ${DISPATCH_RULES.length} dispatch rules`); + }); + + test("rule names from listRules match getDispatchRuleNames in exact order", () => { + const converted = convertDispatchRules(DISPATCH_RULES); + const registry = new RuleRegistry(converted); + const listedNames = registry.listRules() + .filter(r => r.when === "dispatch") + .map(r => r.name); + const originalNames = getDispatchRuleNames(); + + assertEq(listedNames.length, originalNames.length, "same number of names"); + for (let i = 0; i < originalNames.length; i++) { + assertEq(listedNames[i], originalNames[i], `name at index ${i} matches: "${originalNames[i]}"`); + } + }); + + // ── getOrCreateRegistry (lazy init for facades) ──────────────────── + + test("getOrCreateRegistry lazily creates a registry with empty dispatch rules", () => { + // After resetRegistry(), getRegistry() would throw. getOrCreateRegistry() should not. + const registry = getOrCreateRegistry(); + assertTrue(registry instanceof RuleRegistry, "returns a RuleRegistry instance"); + const dispatchRules = registry.listRules().filter(r => r.when === "dispatch"); + assertEq(dispatchRules.length, 0, "lazily-created registry has 0 dispatch rules"); + }); + + test("getOrCreateRegistry returns existing registry when initialized", () => { + const rules = [mockDispatchRule("explicit-init", "planning")]; + const explicit = initRegistry(rules); + const lazy = getOrCreateRegistry(); + assertEq(lazy, explicit, "getOrCreateRegistry returns the same singleton as initRegistry"); + const dispatchRules = lazy.listRules().filter(r => r.when === "dispatch"); + assertEq(dispatchRules.length, 1, "singleton has the explicitly initialized dispatch rule"); + }); + + // ── Hook-derived rules in listRules ──────────────────────────────── + + test("listRules returns only dispatch rules when no hooks are configured", () => { + const converted = convertDispatchRules(DISPATCH_RULES); + const registry = new RuleRegistry(converted); + const allRules = registry.listRules(); + const postUnitRules = allRules.filter(r => r.when === "post-unit"); + const preDispatchRules = allRules.filter(r => r.when === "pre-dispatch"); + + // No preferences file = no hooks + assertEq(postUnitRules.length, 0, "no post-unit rules when no hooks configured"); + assertEq(preDispatchRules.length, 0, "no pre-dispatch rules when no hooks configured"); + assertEq(allRules.length, DISPATCH_RULES.length, "total rules equals dispatch rules only"); + }); + + test("listRules dispatch rules appear first, hooks after", () => { + const converted = convertDispatchRules(DISPATCH_RULES); + const registry = new RuleRegistry(converted); + const allRules = registry.listRules(); + + // Verify dispatch rules come first (indices 0..N-1) + for (let i = 0; i < converted.length; i++) { + assertEq(allRules[i].when, "dispatch", `rule at index ${i} is a dispatch rule`); + assertEq(allRules[i].name, converted[i].name, `dispatch rule at index ${i} has correct name`); + } + }); + + // ── Facade delegation (post-unit-hooks.ts imports work through registry) ── + + test("evaluatePostUnit returns null for hook-on-hook prevention", () => { + const registry = new RuleRegistry([]); + const result = registry.evaluatePostUnit("hook/code-review", "M001/S01/T01", "/tmp/test"); + assertEq(result, null, "hook units don't trigger other hooks"); + }); + + test("evaluatePostUnit returns null for triage-captures", () => { + const registry = new RuleRegistry([]); + const result = registry.evaluatePostUnit("triage-captures", "M001/S01/T01", "/tmp/test"); + assertEq(result, null, "triage-captures skipped"); + }); + + test("evaluatePostUnit returns null for quick-task", () => { + const registry = new RuleRegistry([]); + const result = registry.evaluatePostUnit("quick-task", "M001/S01/T01", "/tmp/test"); + assertEq(result, null, "quick-task skipped"); + }); + + test("evaluatePreDispatch bypasses hook units", () => { + const registry = new RuleRegistry([]); + const result = registry.evaluatePreDispatch("hook/review", "M001/S01/T01", "prompt", "/tmp/test"); + assertEq(result.action, "proceed", "hook units always proceed"); + assertEq(result.prompt, "prompt", "prompt unchanged"); + assertEq(result.firedHooks.length, 0, "no hooks fired"); + }); + + test("evaluatePreDispatch proceeds with empty hooks", () => { + const registry = new RuleRegistry([]); + const result = registry.evaluatePreDispatch("execute-task", "M001/S01/T01", "original prompt", "/tmp/test"); + assertEq(result.action, "proceed", "proceeds when no hooks"); + assertEq(result.prompt, "original prompt", "prompt unchanged"); + }); + + // ── matchedRule provenance (S02 journal support) ─────────────────── + + test("evaluateDispatch result includes matchedRule on dispatch match", async () => { + const rules: UnifiedRule[] = [ + mockDispatchRule("my-planning-rule", "planning"), + ]; + const registry = new RuleRegistry(rules); + const ctx = makeContext("planning"); + const result = await registry.evaluateDispatch(ctx); + + assertEq(result.action, "dispatch", "result is a dispatch action"); + assertEq(result.matchedRule, "my-planning-rule", "matchedRule is the rule name"); + }); + + test("evaluateDispatch result includes matchedRule '' on fallback stop", async () => { + const rules: UnifiedRule[] = [ + mockDispatchRule("only-planning", "planning"), + ]; + const registry = new RuleRegistry(rules); + const ctx = makeContext("some-unknown-phase"); + const result = await registry.evaluateDispatch(ctx); + + assertEq(result.action, "stop", "result is a stop action"); + assertEq(result.matchedRule, "", "matchedRule is '' on fallback"); + }); +}); diff --git a/src/resources/extensions/gsd/tests/tool-naming.test.ts b/src/resources/extensions/gsd/tests/tool-naming.test.ts new file mode 100644 index 000000000..f8483df1a --- /dev/null +++ b/src/resources/extensions/gsd/tests/tool-naming.test.ts @@ -0,0 +1,117 @@ +// tool-naming — Verifies canonical + alias tool registration for GSD DB tools. +// +// Each of the 4 DB tools must register under its canonical gsd_concept_action name +// AND under the old gsd_action_concept name as a backward-compatible alias. +// The alias must share the exact same execute function reference as the canonical tool. + +import { createTestContext } from './test-helpers.ts'; +import { registerDbTools } from '../bootstrap/db-tools.ts'; + +const { assertEq, assertTrue, report } = createTestContext(); + +// ─── Mock PI ────────────────────────────────────────────────────────────────── + +function makeMockPi() { + const tools: any[] = []; + return { + registerTool: (tool: any) => tools.push(tool), + tools, + } as any; +} + +// ─── Rename map ─────────────────────────────────────────────────────────────── + +const RENAME_MAP: Array<{ canonical: string; alias: string }> = [ + { canonical: "gsd_decision_save", alias: "gsd_save_decision" }, + { canonical: "gsd_requirement_update", alias: "gsd_update_requirement" }, + { canonical: "gsd_summary_save", alias: "gsd_save_summary" }, + { canonical: "gsd_milestone_generate_id", alias: "gsd_generate_milestone_id" }, +]; + +// ─── Registration count ────────────────────────────────────────────────────── + +console.log('\n── Tool naming: registration count ──'); + +const pi = makeMockPi(); +registerDbTools(pi); + +assertEq(pi.tools.length, 8, 'Should register exactly 8 tools (4 canonical + 4 aliases)'); + +// ─── Both names exist for each pair ────────────────────────────────────────── + +console.log('\n── Tool naming: canonical and alias names exist ──'); + +for (const { canonical, alias } of RENAME_MAP) { + const canonicalTool = pi.tools.find((t: any) => t.name === canonical); + const aliasTool = pi.tools.find((t: any) => t.name === alias); + + assertTrue(canonicalTool !== undefined, `Canonical tool "${canonical}" should be registered`); + assertTrue(aliasTool !== undefined, `Alias tool "${alias}" should be registered`); +} + +// ─── Execute function identity ─────────────────────────────────────────────── + +console.log('\n── Tool naming: execute function identity (===) ──'); + +for (const { canonical, alias } of RENAME_MAP) { + const canonicalTool = pi.tools.find((t: any) => t.name === canonical); + const aliasTool = pi.tools.find((t: any) => t.name === alias); + + if (canonicalTool && aliasTool) { + assertTrue( + canonicalTool.execute === aliasTool.execute, + `"${canonical}" and "${alias}" should share the same execute function reference`, + ); + } +} + +// ─── Alias descriptions include "(alias for ...)" ─────────────────────────── + +console.log('\n── Tool naming: alias descriptions ──'); + +for (const { canonical, alias } of RENAME_MAP) { + const aliasTool = pi.tools.find((t: any) => t.name === alias); + + if (aliasTool) { + assertTrue( + aliasTool.description.includes(`alias for ${canonical}`), + `Alias "${alias}" description should include "alias for ${canonical}"`, + ); + } +} + +// ─── Canonical tools have proper promptGuidelines ──────────────────────────── + +console.log('\n── Tool naming: canonical promptGuidelines use canonical name ──'); + +for (const { canonical } of RENAME_MAP) { + const canonicalTool = pi.tools.find((t: any) => t.name === canonical); + + if (canonicalTool) { + const guidelinesText = canonicalTool.promptGuidelines.join(' '); + assertTrue( + guidelinesText.includes(canonical), + `Canonical tool "${canonical}" promptGuidelines should reference its own name`, + ); + } +} + +// ─── Alias promptGuidelines direct to canonical ────────────────────────────── + +console.log('\n── Tool naming: alias promptGuidelines redirect to canonical ──'); + +for (const { canonical, alias } of RENAME_MAP) { + const aliasTool = pi.tools.find((t: any) => t.name === alias); + + if (aliasTool) { + const guidelinesText = aliasTool.promptGuidelines.join(' '); + assertTrue( + guidelinesText.includes(`Alias for ${canonical}`), + `Alias "${alias}" promptGuidelines should say "Alias for ${canonical}"`, + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ + +report(); diff --git a/src/resources/extensions/gsd/tests/triage-dispatch.test.ts b/src/resources/extensions/gsd/tests/triage-dispatch.test.ts index 7ea81c020..34fd149de 100644 --- a/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +++ b/src/resources/extensions/gsd/tests/triage-dispatch.test.ts @@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const hooksPath = join(__dirname, "..", "post-unit-hooks.ts"); +const registryPath = join(__dirname, "..", "rule-registry.ts"); const autoPromptsPath = join(__dirname, "..", "auto-prompts.ts"); // After decomposition, triage/dispatch logic lives in auto-post-unit.ts @@ -25,7 +26,11 @@ const autoSrc = [ postUnitSrc, readFileSync(join(__dirname, "..", "auto-start.ts"), "utf-8"), ].join("\n"); -const hooksSrc = readFileSync(hooksPath, "utf-8"); +// Hook exclusion logic lives in the rule-registry (facade delegates there) +const hooksSrc = [ + readFileSync(hooksPath, "utf-8"), + readFileSync(registryPath, "utf-8"), +].join("\n"); const autoPromptsSrc = (() => { try { return readFileSync(autoPromptsPath, "utf-8"); } catch { return autoSrc; } })(); // ─── Hook exclusion ──────────────────────────────────────────────────────────