feat(gsd): unified rule registry, event journal, journal query tool, and tool naming convention (#1928)
Unify dispatch rules and hooks into a flat rule registry, add structured event journal with causal tracing, expose journal query as an LLM tool, and adopt gsd_concept_action tool naming. - RuleRegistry class absorbs dispatch rules + hooks into UnifiedRule objects with common when/where/then shape - post-unit-hooks.ts refactored from 524 lines → 90-line thin facade delegating to the registry - Event journal emits structured JSONL events with per-iteration flowId grouping and causedBy chains - gsd_journal_query LLM-callable tool for AI self-debugging of autonomous runs - 4 DB tools renamed to gsd_concept_action pattern with backward-compatible aliases - 164 new tests, zero regressions Closes #1763, closes #1764, closes #1766 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bdd1e765f5
commit
60885610ac
33 changed files with 2946 additions and 674 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
1
mintlify-docs/docs
Submodule
1
mintlify-docs/docs
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 5c549fdffb1eb56cacec19d33b8157a3b1e19d3c
|
||||
|
|
@ -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<u16>) {
|
||||
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<u16>,
|
||||
}
|
||||
|
||||
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<u16>) {
|
||||
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<u16>) {
|
||||
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<u16>) {
|
||||
fn write_active_codes(state: &AnsiState, osc8: &Osc8State, out: &mut Vec<u16>) {
|
||||
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<u16>) {
|
||||
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<u16>) {
|
|||
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<u16>; 4]> {
|
||||
let mut lines = SmallVec::<[Vec<u16>; 4]>::new();
|
||||
let mut current_line = Vec::<u16>::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::<u16>::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<u16>; 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<u16> = 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);
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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<DispatchAction> {
|
||||
// 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: "<no-match>",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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 } };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string, string | undefined> = {};
|
||||
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<string, string | undefined> = {};
|
||||
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");
|
||||
}
|
||||
|
||||
|
|
|
|||
62
src/resources/extensions/gsd/bootstrap/journal-tools.ts
Normal file
62
src/resources/extensions/gsd/bootstrap/journal-tools.ts
Normal file
|
|
@ -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<string, string | undefined> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
134
src/resources/extensions/gsd/journal.ts
Normal file
134
src/resources/extensions/gsd/journal.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/** 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: `<gsdRoot>/journal/<YYYY-MM-DD>.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<string, unknown> | 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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
|
|
|
|||
|
|
@ -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<string, number>();
|
||||
|
||||
/** 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<string, number> = {};
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<ID>/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/<ID>/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
|
||||
|
|
|
|||
|
|
@ -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/<ID>/slices`.
|
||||
1. For each milestone, call `gsd_milestone_generate_id` to get its ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones/<ID>/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.
|
||||
|
|
|
|||
|
|
@ -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/<ID>/slices`.
|
||||
1. Call `gsd_milestone_generate_id` to get the milestone ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones/<ID>/slices`.
|
||||
2. Write `.gsd/milestones/<ID>/<ID>-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
|
||||
---
|
||||
|
|
|
|||
599
src/resources/extensions/gsd/rule-registry.ts
Normal file
599
src/resources/extensions/gsd/rule-registry.ts
Normal file
|
|
@ -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<string, number> = 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<DispatchAction> {
|
||||
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: "<no-match>",
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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<string, number> = {};
|
||||
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;
|
||||
}
|
||||
68
src/resources/extensions/gsd/rule-types.ts
Normal file
68
src/resources/extensions/gsd/rule-types.ts
Normal file
|
|
@ -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> | 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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
513
src/resources/extensions/gsd/tests/journal-integration.test.ts
Normal file
513
src/resources/extensions/gsd/tests/journal-integration.test.ts
Normal file
|
|
@ -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<typeof createEventCapture>,
|
||||
overrides?: Partial<LoopDeps>,
|
||||
): 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>,
|
||||
): 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<string, number>(),
|
||||
unitLifetimeDispatches: new Map<string, number>(),
|
||||
unitRecoveryCount: new Map<string, number>(),
|
||||
verificationRetryCount: new Map<string, number>(),
|
||||
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: "<no-match>",
|
||||
}),
|
||||
});
|
||||
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, "<no-match>");
|
||||
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);
|
||||
});
|
||||
147
src/resources/extensions/gsd/tests/journal-query-tool.test.ts
Normal file
147
src/resources/extensions/gsd/tests/journal-query-tool.test.ts
Normal file
|
|
@ -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> = {}): 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<string, unknown>, 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);
|
||||
}
|
||||
});
|
||||
386
src/resources/extensions/gsd/tests/journal.test.ts
Normal file
386
src/resources/extensions/gsd/tests/journal.test.ts
Normal file
|
|
@ -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> = {}): 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<string, unknown>)?.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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
413
src/resources/extensions/gsd/tests/rule-registry.test.ts
Normal file
413
src/resources/extensions/gsd/tests/rule-registry.test.ts
Normal file
|
|
@ -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<DispatchAction | null> => {
|
||||
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<DispatchAction | null> => {
|
||||
// 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 '<no-match>' 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, "<no-match>", "matchedRule is '<no-match>' on fallback");
|
||||
});
|
||||
});
|
||||
117
src/resources/extensions/gsd/tests/tool-naming.test.ts
Normal file
117
src/resources/extensions/gsd/tests/tool-naming.test.ts
Normal file
|
|
@ -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();
|
||||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue