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:
parent
db9f006f19
commit
1ea9163dea
8 changed files with 347 additions and 139 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue