feat: add yaml support, run-hook command, and path sanitization (#637)

* feat: allow extensions to use 'yaml' and rework frontmatter parsing

* feat: add run-hook command for manual hook execution

* fix: sanitize slashes in unitType for runtime file paths
This commit is contained in:
Gary Trakhman 2026-03-16 11:22:23 -04:00 committed by GitHub
parent db9f006f19
commit 1ea9163dea
8 changed files with 347 additions and 139 deletions

View file

@ -19,6 +19,7 @@ import * as _bundledPiTui from "@gsd/pi-tui";
// These MUST be static so Bun bundles them into the compiled binary.
// The virtualModules option then makes them available to extensions.
import * as _bundledTypebox from "@sinclair/typebox";
import * as _bundledYaml from "yaml";
import { getAgentDir, isBunBinary } from "../../config.js";
// NOTE: This import works because loader.ts exports are NOT re-exported from index.ts,
// avoiding a circular dependency. Extensions can import from @gsd/pi-coding-agent.
@ -46,6 +47,7 @@ const VIRTUAL_MODULES: Record<string, unknown> = {
"@gsd/pi-ai": _bundledPiAi,
"@gsd/pi-ai/oauth": _bundledPiAiOauth,
"@gsd/pi-coding-agent": _bundledPiCodingAgent,
"yaml": _bundledYaml,
// Aliases for external PI ecosystem packages that import from the original scope
"@mariozechner/pi-agent-core": _bundledPiAgentCore,
"@mariozechner/pi-tui": _bundledPiTui,
@ -70,6 +72,9 @@ function getAliases(): Record<string, string> {
const typeboxEntry = require.resolve("@sinclair/typebox");
const typeboxRoot = typeboxEntry.replace(/[\\/]build[\\/]cjs[\\/]index\.js$/, "");
const yamlEntry = require.resolve("yaml");
const yamlRoot = yamlEntry.replace(/[\\/]dist[\\/]index\.js$/, "");
const packagesRoot = path.resolve(__dirname, "../../../../");
const resolveWorkspaceOrImport = (workspaceRelativePath: string, specifier: string): string => {
const workspacePath = path.join(packagesRoot, workspaceRelativePath);
@ -86,6 +91,7 @@ function getAliases(): Record<string, string> {
"@gsd/pi-ai": resolveWorkspaceOrImport("ai/dist/index.js", "@gsd/pi-ai"),
"@gsd/pi-ai/oauth": resolveWorkspaceOrImport("ai/dist/oauth.js", "@gsd/pi-ai/oauth"),
"@sinclair/typebox": typeboxRoot,
"yaml": yamlRoot,
// Aliases for external PI ecosystem packages that import from the original scope
"@mariozechner/pi-coding-agent": packageIndex,
"@mariozechner/pi-agent-core": resolveWorkspaceOrImport("agent/dist/index.js", "@gsd/pi-agent-core"),

View file

@ -2830,3 +2830,108 @@ export {
skipExecuteTask,
buildLoopRemediationSteps,
} from "./auto-recovery.js";
/**
* Dispatch a hook unit directly, bypassing normal pre-dispatch hooks.
* Used for manual hook triggers via /gsd run-hook.
*/
export async function dispatchHookUnit(
ctx: ExtensionContext,
pi: ExtensionAPI,
hookName: string,
triggerUnitType: string,
triggerUnitId: string,
hookPrompt: string,
hookModel: string | undefined,
targetBasePath: string,
): Promise<boolean> {
// Ensure auto-mode is active
if (!active) {
// Initialize auto-mode state minimally
active = true;
stepMode = true;
cmdCtx = ctx as ExtensionCommandContext;
basePath = targetBasePath;
autoStartTime = Date.now();
currentUnit = null;
completedUnits = [];
}
const hookUnitType = `hook/${hookName}`;
const hookStartedAt = Date.now();
// Set up the trigger unit as the "current" unit so post-unit hooks can reference it
currentUnit = { type: triggerUnitType, id: triggerUnitId, startedAt: hookStartedAt };
// Create a new session for the hook
const result = await cmdCtx!.newSession();
if (result.cancelled) {
await stopAuto(ctx, pi);
return false;
}
// Update current unit to the hook unit
currentUnit = { type: hookUnitType, id: triggerUnitId, startedAt: hookStartedAt };
// Write runtime record
writeUnitRuntimeRecord(basePath, hookUnitType, triggerUnitId, hookStartedAt, {
phase: "dispatched",
wrapupWarningSent: false,
timeoutAt: null,
lastProgressAt: hookStartedAt,
progressCount: 0,
lastProgressKind: "dispatch",
});
// Switch model if specified
if (hookModel) {
const availableModels = ctx.modelRegistry.getAvailable();
const match = availableModels.find(m =>
m.id === hookModel || `${m.provider}/${m.id}` === hookModel,
);
if (match) {
try {
await pi.setModel(match);
} catch { /* non-fatal — use current model */ }
}
}
// Write lock
const sessionFile = ctx.sessionManager.getSessionFile();
writeLock(lockBase(), hookUnitType, triggerUnitId, completedUnits.length, sessionFile);
// Set up timeout
clearUnitTimeout();
const supervisor = resolveAutoSupervisorConfig();
const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
unitTimeoutHandle = setTimeout(async () => {
unitTimeoutHandle = null;
if (!active) return;
if (currentUnit) {
writeUnitRuntimeRecord(basePath, hookUnitType, triggerUnitId, hookStartedAt, {
phase: "timeout",
timeoutAt: Date.now(),
});
}
ctx.ui.notify(
`Hook ${hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`,
"warning",
);
resetHookState();
await pauseAuto(ctx, pi);
}, hookHardTimeoutMs);
// Update status
ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info");
// Send the hook prompt
console.log(`[dispatchHookUnit] Sending prompt of length ${hookPrompt.length}`);
console.log(`[dispatchHookUnit] Prompt preview: ${hookPrompt.substring(0, 200)}...`);
pi.sendMessage(
{ customType: "gsd-auto", content: hookPrompt, display: true },
{ triggerTurn: true },
);
return true;
}

View file

@ -66,13 +66,13 @@ function projectRoot(): string {
export function registerGSDCommand(pi: ExtensionAPI): void {
pi.registerCommand("gsd", {
description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|capture|triage|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer|knowledge",
description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|capture|triage|history|undo|skip|export|cleanup|prefs|config|hooks|run-hook|doctor|migrate|remote|steer|knowledge",
getArgumentCompletions: (prefix: string) => {
const subcommands = [
"help", "next", "auto", "stop", "pause", "status", "visualize", "queue", "discuss",
"capture", "triage",
"history", "undo", "skip", "export", "cleanup", "prefs",
"config", "hooks", "doctor", "migrate", "remote", "steer", "knowledge",
"config", "hooks", "run-hook", "doctor", "migrate", "remote", "steer", "knowledge",
];
const parts = prefix.trim().split(/\s+/);
@ -293,6 +293,26 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
return;
}
if (trimmed.startsWith("run-hook ")) {
await handleRunHook(trimmed.replace(/^run-hook\s*/, "").trim(), ctx, pi);
return;
}
if (trimmed === "run-hook") {
ctx.ui.notify(`Usage: /gsd run-hook <hook-name> <unit-type> <unit-id>
Unit types:
execute-task - Task execution (unit-id: M001/S01/T01)
plan-slice - Slice planning (unit-id: M001/S01)
research-milestone - Milestone research (unit-id: M001)
complete-slice - Slice completion (unit-id: M001/S01)
complete-milestone - Milestone completion (unit-id: M001)
Examples:
/gsd run-hook code-review execute-task M001/S01/T01
/gsd run-hook lint-check plan-slice M001/S01`, "warning");
return;
}
if (trimmed.startsWith("steer ")) {
await handleSteer(trimmed.replace(/^steer\s+/, "").trim(), ctx, pi);
return;
@ -1535,3 +1555,69 @@ async function handleSteer(change: string, ctx: ExtensionCommandContext, pi: Ext
ctx.ui.notify(`Override registered: "${change}". Update plan documents to reflect this change.`, "info");
}
}
async function handleRunHook(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
const parts = args.trim().split(/\s+/);
if (parts.length < 3) {
ctx.ui.notify(`Usage: /gsd run-hook <hook-name> <unit-type> <unit-id>
Unit types:
execute-task - Task execution (unit-id: M001/S01/T01)
plan-slice - Slice planning (unit-id: M001/S01)
research-milestone - Milestone research (unit-id: M001)
complete-slice - Slice completion (unit-id: M001/S01)
complete-milestone - Milestone completion (unit-id: M001)
Examples:
/gsd run-hook code-review execute-task M001/S01/T01
/gsd run-hook lint-check plan-slice M001/S01`, "warning");
return;
}
const [hookName, unitType, unitId] = parts;
const basePath = projectRoot();
// Import the hook trigger function
const { triggerHookManually, formatHookStatus, getHookStatus } = await import("./post-unit-hooks.js");
const { dispatchHookUnit } = await import("./auto.js");
// Check if the hook exists
const hooks = getHookStatus();
const hookExists = hooks.some(h => h.name === hookName);
if (!hookExists) {
ctx.ui.notify(`Hook "${hookName}" not found. Configured hooks:\n${formatHookStatus()}`, "error");
return;
}
// Validate unit ID format
const unitIdPattern = /^M\d{3}\/S\d{2,3}\/T\d{2,3}$/;
if (!unitIdPattern.test(unitId)) {
ctx.ui.notify(`Invalid unit ID format: "${unitId}". Expected format: M004/S04/T03`, "warning");
return;
}
// Trigger the hook manually
const hookUnit = triggerHookManually(hookName, unitType, unitId, basePath);
if (!hookUnit) {
ctx.ui.notify(`Failed to trigger hook "${hookName}". The hook may be disabled or not configured for unit type "${unitType}".`, "error");
return;
}
ctx.ui.notify(`Manually triggering hook: ${hookName} for ${unitType} ${unitId}`, "info");
// Dispatch the hook unit directly, bypassing normal pre-dispatch hooks
const success = await dispatchHookUnit(
ctx,
pi,
hookName,
unitType,
unitId,
hookUnit.prompt,
hookUnit.model,
basePath,
);
if (!success) {
ctx.ui.notify("Failed to dispatch hook. Auto-mode may have been cancelled.", "error");
}
}

View file

@ -1,7 +1,6 @@
// 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.
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import type {
PostUnitHookConfig,
@ -412,6 +411,76 @@ export function getHookStatus(): HookStatusEntry[] {
return entries;
}
/**
* 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,
};
}
/**
* Format hook status for terminal display.
*/

View file

@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "
import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
import { getAgentDir } from "@gsd/pi-coding-agent";
import { parse as parseYaml } from "yaml";
import type { GitPreferences } from "./git-service.js";
import type { PostUnitHookConfig, PreDispatchHookConfig, BudgetEnforcementMode, NotificationPreferences, TokenProfile, InlineLevel, PhaseSkipPreferences } from "./types.js";
import type { DynamicRoutingConfig } from "./model-router.js";
@ -431,142 +432,16 @@ export function parsePreferencesMarkdown(content: string): GSDPreferences | null
}
function parseFrontmatterBlock(frontmatter: string): GSDPreferences {
const root: Record<string, unknown> = {};
const stack: Array<{ indent: number; value: Record<string, unknown> }> = [{ indent: -1, value: root }];
const lines = frontmatter.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
const indent = line.match(/^\s*/)?.[0].length ?? 0;
const trimmed = line.trim();
// Skip comment lines (standalone YAML comments)
if (trimmed.startsWith("#")) continue;
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
stack.pop();
try {
const parsed = parseYaml(frontmatter);
if (typeof parsed !== 'object' || parsed === null) {
return {} as GSDPreferences;
}
const current = stack[stack.length - 1].value;
const keyMatch = trimmed.match(/^([A-Za-z0-9_]+):(.*)$/);
if (!keyMatch) continue;
const [, key, remainder] = keyMatch;
// Strip inline comments from the value portion
const valuePart = remainder.replace(/\s+#.*$/, "").trim();
if (valuePart === "") {
const nextLine = lines[i + 1] ?? "";
const nextTrimmed = nextLine.trim();
if (nextTrimmed.startsWith("- ")) {
const items: unknown[] = [];
let j = i + 1;
while (j < lines.length) {
const candidate = lines[j];
const candidateIndent = candidate.match(/^\s*/)?.[0].length ?? 0;
const candidateTrimmed = candidate.trim();
if (!candidateTrimmed) {
j++;
continue;
}
if (candidateIndent <= indent || !candidateTrimmed.startsWith("- ")) break;
const itemText = candidateTrimmed.slice(2).trim();
const nextCandidate = lines[j + 1] ?? "";
const nextCandidateIndent = nextCandidate.match(/^\s*/)?.[0].length ?? 0;
const nextCandidateTrimmed = nextCandidate.trim();
// Treat an array item as a structured object only when:
// a) It looks like a YAML key-value pair (key starts with [A-Za-z0-9_]+:), OR
// b) The next line is indented deeper (nested block under this item).
// Bare colons (e.g. "qwen/qwen3-coder:free") are NOT key-value pairs.
const looksLikeKeyValue = /^[A-Za-z0-9_]+:/.test(itemText);
if (looksLikeKeyValue || (nextCandidateTrimmed && nextCandidateIndent > candidateIndent)) {
const obj: Record<string, unknown> = {};
const firstMatch = itemText.match(/^([A-Za-z0-9_]+):(.*)$/);
if (firstMatch) {
obj[firstMatch[1]] = parseScalar(firstMatch[2].trim());
}
j++;
while (j < lines.length) {
const nested = lines[j];
const nestedIndent = nested.match(/^\s*/)?.[0].length ?? 0;
const nestedTrimmed = nested.trim();
if (!nestedTrimmed) {
j++;
continue;
}
if (nestedIndent <= candidateIndent) break;
const nestedMatch = nestedTrimmed.match(/^([A-Za-z0-9_]+):(.*)$/);
if (nestedMatch) {
const nestedValue = nestedMatch[2].trim();
if (nestedValue === "") {
const nestedItems: string[] = [];
j++;
while (j < lines.length) {
const nestedArrayLine = lines[j];
const nestedArrayIndent = nestedArrayLine.match(/^\s*/)?.[0].length ?? 0;
const nestedArrayTrimmed = nestedArrayLine.trim();
if (!nestedArrayTrimmed) {
j++;
continue;
}
if (nestedArrayIndent <= nestedIndent || !nestedArrayTrimmed.startsWith("- ")) break;
nestedItems.push(String(parseScalar(nestedArrayTrimmed.slice(2).trim())));
j++;
}
obj[nestedMatch[1]] = nestedItems;
continue;
}
obj[nestedMatch[1]] = parseScalar(nestedValue);
}
j++;
}
items.push(obj);
continue;
}
items.push(parseScalar(itemText));
j++;
}
current[key] = items;
i = j - 1;
} else {
const obj: Record<string, unknown> = {};
current[key] = obj;
stack.push({ indent, value: obj });
}
continue;
}
current[key] = parseScalar(valuePart);
return parsed as GSDPreferences;
} catch (e) {
console.error("[parseFrontmatterBlock] YAML parse error:", e);
return {} as GSDPreferences;
}
return root as GSDPreferences;
}
function parseScalar(value: string): unknown {
// Strip inline YAML comments: " # comment" (# preceded by whitespace).
// Quoted strings are returned as-is (the comment is inside quotes).
const quoteMatch = value.match(/^(['"])(.*)(\1)$/);
if (quoteMatch) return quoteMatch[2];
const stripped = value.replace(/\s+#.*$/, "");
if (stripped === "true") return true;
if (stripped === "false") return false;
// Recognize empty array/object literals (with or without surrounding quotes)
const unquoted = stripped.replace(/^['\"]|['\"]$/g, "");
if (unquoted === "[]") return [];
if (unquoted === "{}") return {};
if (/^-?\d+$/.test(stripped)) {
const n = Number(stripped);
// Keep large integers (e.g. Discord channel IDs) as strings to avoid precision loss
if (Number.isSafeInteger(n)) return n;
return stripped;
}
return unquoted;
}
/**

View file

@ -18,6 +18,7 @@ import {
clearPersistedHookState,
getHookStatus,
formatHookStatus,
triggerHookManually,
} from "../post-unit-hooks.ts";
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
@ -294,4 +295,44 @@ console.log("\n=== Hook status: no hooks ===");
assertMatch(formatted, /No hooks configured/, "status message says no hooks");
}
// ═══════════════════════════════════════════════════════════════════════════
// Phase 4: Manual Hook Trigger Tests
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== triggerHookManually: hook not found ===");
{
resetHookState();
const base = createFixtureBase();
try {
const result = triggerHookManually("nonexistent-hook", "execute-task", "M001/S01/T01", base);
assertEq(result, null, "returns null when hook not found");
} finally {
rmSync(base, { recursive: true, force: true });
}
}
console.log("\n=== triggerHookManually: with configured hook ===");
{
resetHookState();
const base = createFixtureBase();
try {
// This test will work when preferences are configured
// For now, just verify the function exists and handles missing hooks
const result = triggerHookManually("code-review", "execute-task", "M001/S01/T01", base);
// Result depends on whether code-review hook is configured in preferences
// The function should either return null or a valid HookDispatchResult
assertTrue(result === null || typeof result === "object", "returns null or object");
if (result) {
assertEq(result.hookName, "code-review", "hook name in result");
assertEq(result.unitType, "hook/code-review", "unit type is hook-prefixed");
assertEq(result.unitId, "M001/S01/T01", "unit ID preserved");
assertTrue(typeof result.prompt === "string", "prompt is a string");
}
} finally {
rmSync(base, { recursive: true, force: true });
}
}
report();

View file

@ -1,4 +1,4 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
@ -65,6 +65,30 @@ console.log("\n=== runtime record cleanup ===");
assertEq(loaded, null, "record removed");
}
console.log("\n=== hook unit type sanitization (slash in unitType) ===");
{
// Hook units have unitType like "hook/code-review" with a slash
// This should NOT create a subdirectory - the slash must be sanitized
const hookRecord = writeUnitRuntimeRecord(base, "hook/code-review", "M100/S02/T10", 2000, { phase: "dispatched" });
assertEq(hookRecord.unitType, "hook/code-review", "unitType preserved in record");
assertEq(hookRecord.unitId, "M100/S02/T10", "unitId preserved in record");
const loaded = readUnitRuntimeRecord(base, "hook/code-review", "M100/S02/T10");
assertTrue(loaded !== null, "hook record readable");
assertEq(loaded!.phase, "dispatched", "hook phase correct");
// Verify the file is in the units dir, not in a subdirectory
const unitsDir = join(base, ".gsd", "runtime", "units");
const files = readdirSync(unitsDir);
const hookFile = files.find((f: string) => f.includes("hook-code-review"));
assertTrue(hookFile !== undefined, "hook file exists with sanitized name");
assertTrue(!files.some((f: string) => f === "hook"), "no 'hook' subdirectory created");
clearUnitRuntimeRecord(base, "hook/code-review", "M100/S02/T10");
const cleared = readUnitRuntimeRecord(base, "hook/code-review", "M100/S02/T10");
assertEq(cleared, null, "hook record removed");
}
// ─── Must-have durability integration tests ───────────────────────────────
// Create a separate temp base for must-have tests to avoid interference

View file

@ -50,7 +50,9 @@ function runtimeDir(basePath: string): string {
}
function runtimePath(basePath: string, unitType: string, unitId: string): string {
return join(runtimeDir(basePath), `${unitType}-${unitId.replace(/[\/]/g, "-")}.json`);
const sanitizedUnitType = unitType.replace(/[\/]/g, "-");
const sanitizedUnitId = unitId.replace(/[\/]/g, "-");
return join(runtimeDir(basePath), `${sanitizedUnitType}-${sanitizedUnitId}.json`);
}
export function writeUnitRuntimeRecord(