71e4e2a6d0
Goal content under ./.omo/ultragoal/sessions/claude-<id>/ keyed by Claude Code session_id (claude: prefix); UltragoalScope struct threaded; plan version 2 + index.json registry with read-only v1 forward-migration (never deletes v1); create_goal/get_goal/update_goal dependency dropped (file/steering-based); the PreToolUse create_goal budget guard kept compiled+unit-tested but its hooks.json registration removed (inert in CC). 26 tests pass. ultragoal removed from sync-components HANDLED set — it is a hand-fork, not patch-synced. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
187 lines
6.2 KiB
TypeScript
187 lines
6.2 KiB
TypeScript
import { makeUltragoalScope } from "./session-scope.js";
|
|
import { parseUltragoalSteeringDirective, steerUltragoal } from "./steering.js";
|
|
|
|
export interface UserPromptSubmitPayload {
|
|
readonly cwd: string;
|
|
readonly hook_event_name: "UserPromptSubmit";
|
|
readonly model?: string;
|
|
readonly permission_mode?: string;
|
|
readonly prompt: string;
|
|
readonly session_id: string;
|
|
readonly transcript_path?: string;
|
|
readonly turn_id?: string;
|
|
}
|
|
|
|
export interface PreToolUsePayload {
|
|
readonly cwd: string;
|
|
readonly hook_event_name: "PreToolUse";
|
|
readonly model?: string;
|
|
readonly permission_mode?: string;
|
|
readonly session_id: string;
|
|
readonly tool_input: unknown;
|
|
readonly tool_name: string;
|
|
readonly tool_use_id: string;
|
|
readonly transcript_path?: string | null;
|
|
readonly turn_id?: string;
|
|
}
|
|
|
|
interface PreToolUseHookOutput {
|
|
readonly hookSpecificOutput: {
|
|
readonly hookEventName: "PreToolUse";
|
|
readonly permissionDecision: "deny";
|
|
readonly permissionDecisionReason: string;
|
|
readonly additionalContext: string;
|
|
};
|
|
}
|
|
|
|
const CREATE_GOAL_TOOL_NAME = "create_goal";
|
|
const GOAL_BUDGET_WARNING =
|
|
"Do not set a budget on create_goal. Omit the budget field so the goal stays unlimited; ultrawork and ultragoal runs must always use unlimited goals.";
|
|
|
|
export function parseUserPromptSubmitPayload(raw: string): UserPromptSubmitPayload | null {
|
|
if (raw.trim().length === 0) return null;
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw);
|
|
return isUserPromptSubmitPayload(parsed) ? parsed : null;
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError) return null;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function parsePreToolUsePayload(raw: string): PreToolUsePayload | null {
|
|
if (raw.trim().length === 0) return null;
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw);
|
|
return isPreToolUsePayload(parsed) ? parsed : null;
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError) return null;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function applyUserPromptUltragoalSteering(payload: UserPromptSubmitPayload): Promise<string> {
|
|
try {
|
|
if (payload.hook_event_name !== "UserPromptSubmit") return "";
|
|
const proposal = parseUltragoalSteeringDirective(payload.prompt);
|
|
if (proposal === null) return "";
|
|
const scope = makeUltragoalScope(payload.cwd, payload.session_id);
|
|
const result = await steerUltragoal(scope, proposal);
|
|
if (!result.accepted) return "";
|
|
return JSON.stringify({
|
|
status: "accepted",
|
|
kind: result.audit.kind,
|
|
source: result.audit.source,
|
|
deduped: result.deduped,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof Error) return "";
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inert under Claude Code: the `create_goal` PreToolUse block is intentionally
|
|
* NOT registered in hooks.json (D4) because Claude Code never emits a
|
|
* `create_goal` tool call. The guard CODE is retained and unit-testable so the
|
|
* behavior is documented and exercised; it short-circuits to "" for every tool
|
|
* Claude Code actually emits.
|
|
*/
|
|
export function applyPreToolUseGoalBudgetGuard(payload: PreToolUsePayload): string {
|
|
if (payload.hook_event_name !== "PreToolUse") return "";
|
|
if (payload.tool_name !== CREATE_GOAL_TOOL_NAME) return "";
|
|
if (!hasGoalBudgetInput(payload.tool_input)) return "";
|
|
const output: PreToolUseHookOutput = {
|
|
hookSpecificOutput: {
|
|
hookEventName: "PreToolUse",
|
|
permissionDecision: "deny",
|
|
permissionDecisionReason: GOAL_BUDGET_WARNING,
|
|
additionalContext: GOAL_BUDGET_WARNING,
|
|
},
|
|
};
|
|
return `${JSON.stringify(output)}\n`;
|
|
}
|
|
|
|
export async function runUltragoalHookCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise<void> {
|
|
try {
|
|
const payload = parseUserPromptSubmitPayload(await readAll(stdin));
|
|
if (payload === null) return;
|
|
const output = await applyUserPromptUltragoalSteering(payload);
|
|
if (output.length > 0) stdout.write(output);
|
|
} catch (error) {
|
|
if (error instanceof Error) return;
|
|
return;
|
|
}
|
|
}
|
|
|
|
export async function runPreToolUseGoalBudgetGuardCli(
|
|
stdin: NodeJS.ReadableStream,
|
|
stdout: NodeJS.WritableStream,
|
|
): Promise<void> {
|
|
try {
|
|
const payload = parsePreToolUsePayload(await readAll(stdin));
|
|
if (payload === null) return;
|
|
const output = applyPreToolUseGoalBudgetGuard(payload);
|
|
if (output.length > 0) stdout.write(output);
|
|
} catch (error) {
|
|
if (error instanceof Error) return;
|
|
return;
|
|
}
|
|
}
|
|
|
|
function isUserPromptSubmitPayload(value: unknown): value is UserPromptSubmitPayload {
|
|
if (!isRecord(value)) return false;
|
|
return (
|
|
value["hook_event_name"] === "UserPromptSubmit" &&
|
|
typeof value["cwd"] === "string" &&
|
|
typeof value["prompt"] === "string" &&
|
|
typeof value["session_id"] === "string" &&
|
|
["model", "permission_mode", "transcript_path", "turn_id"].every((key) => optionalString(value[key]))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Relaxed PreToolUse validator (D4): `model` and `turn_id` are optional because
|
|
* Claude Code never sends `turn_id` and omits `model` on non-SessionStart
|
|
* events. Requiring them would make the guard untestable against CC payloads.
|
|
*/
|
|
function isPreToolUsePayload(value: unknown): value is PreToolUsePayload {
|
|
if (!isRecord(value)) return false;
|
|
return (
|
|
value["hook_event_name"] === "PreToolUse" &&
|
|
typeof value["cwd"] === "string" &&
|
|
typeof value["session_id"] === "string" &&
|
|
typeof value["tool_name"] === "string" &&
|
|
typeof value["tool_use_id"] === "string" &&
|
|
(value["transcript_path"] === null || optionalString(value["transcript_path"])) &&
|
|
optionalString(value["model"]) &&
|
|
optionalString(value["permission_mode"]) &&
|
|
optionalString(value["turn_id"]) &&
|
|
Object.hasOwn(value, "tool_input")
|
|
);
|
|
}
|
|
|
|
function hasGoalBudgetInput(value: unknown): boolean {
|
|
return isRecord(value) && (Object.hasOwn(value, "token_budget") || Object.hasOwn(value, "tokenBudget"));
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function optionalString(value: unknown): boolean {
|
|
return value === undefined || typeof value === "string";
|
|
}
|
|
|
|
function readAll(stdin: NodeJS.ReadableStream): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
let data = "";
|
|
stdin.setEncoding("utf8");
|
|
stdin.on("data", (chunk: unknown) => {
|
|
data += chunk instanceof Buffer ? chunk.toString() : String(chunk);
|
|
});
|
|
stdin.once("error", reject);
|
|
stdin.once("end", () => resolve(data));
|
|
});
|
|
}
|