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:
TÂCHES 2026-03-21 18:47:41 -06:00 committed by GitHub
parent bdd1e765f5
commit 60885610ac
33 changed files with 2946 additions and 674 deletions

View file

@ -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

@ -0,0 +1 @@
Subproject commit 5c549fdffb1eb56cacec19d33b8157a3b1e19d3c

View file

@ -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);

View file

@ -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 ────────────────────────────────────────────────────

View file

@ -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>",
};
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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 ──

View file

@ -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 } };
}

View file

@ -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 {

View file

@ -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");
}

View 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,
};
}
},
});
}

View file

@ -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);
}

View file

@ -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"],

View file

@ -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;

View file

@ -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 {

View 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 [];
}
}

View file

@ -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>();

View file

@ -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();
}

View file

@ -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

View file

@ -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.

View file

@ -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
---

View 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;
}

View 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;
}

View file

@ -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 };

View file

@ -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();

View 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);
});

View 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);
}
});

View 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);
}
});

View file

@ -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.

View 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");
});
});

View 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();

View file

@ -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 ──────────────────────────────────────────────────────────