diff --git a/docs/command-handbook.md b/docs/command-handbook.md index 85bd3e1e..e82aa95a 100644 --- a/docs/command-handbook.md +++ b/docs/command-handbook.md @@ -334,7 +334,7 @@ Use these directly in OpenCode: /gateway doctor /gateway watchdog status /gateway watchdog doctor -/gateway watchdog set --warning-threshold-seconds 300 --tool-call-threshold 50 +/gateway watchdog set --warning-threshold-seconds 60 --tool-call-threshold 12 --reminder-cooldown-seconds 60 /gateway watchdog disable /gateway continuation report --minutes 120 --limit 10 --json /gateway tune memory --json diff --git a/plugin/gateway-core/dist/config/schema.js b/plugin/gateway-core/dist/config/schema.js index 84f3eda0..558b3211 100644 --- a/plugin/gateway-core/dist/config/schema.js +++ b/plugin/gateway-core/dist/config/schema.js @@ -169,9 +169,9 @@ export const DEFAULT_GATEWAY_CONFIG = { }, longTurnWatchdog: { enabled: true, - warningThresholdMs: 300000, - toolCallWarningThreshold: 50, - reminderCooldownMs: 120000, + warningThresholdMs: 60000, + toolCallWarningThreshold: 12, + reminderCooldownMs: 60000, maxSessionStateEntries: 1024, prefix: "[Turn Watchdog]:", }, diff --git a/plugin/gateway-core/dist/hooks/agent-denied-tool-enforcer/index.js b/plugin/gateway-core/dist/hooks/agent-denied-tool-enforcer/index.js index fd1ced79..45a174e2 100644 --- a/plugin/gateway-core/dist/hooks/agent-denied-tool-enforcer/index.js +++ b/plugin/gateway-core/dist/hooks/agent-denied-tool-enforcer/index.js @@ -1,7 +1,7 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js"; import { loadAgentMetadata } from "../shared/agent-metadata.js"; import { resolveDelegationTraceId } from "../shared/delegation-trace.js"; -import { writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; +import { buildCompactDecisionCacheKey, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; function sessionId(payload) { return String(payload.input?.sessionID ?? payload.input?.sessionId ?? "").trim(); } @@ -171,7 +171,11 @@ export function createAgentDeniedToolEnforcerHook(options) { R: "read_only_safe", N: "unclear", }, - cacheKey: `mutation:${subagentType}:${combinedText}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "mutation", + parts: [subagentType || "none"], + text: compactDecisionText(promptText, descriptionText), + }), }); if (mutationDecision.accepted && mutationDecision.char === "M") { writeDecisionComparisonAudit({ @@ -217,7 +221,11 @@ export function createAgentDeniedToolEnforcerHook(options) { A: "allowed_or_no_issue", N: "unclear", }, - cacheKey: `tool:${subagentType}:${denied.join(",")}:${combinedText}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "tool", + parts: [subagentType || "none", denied.join(",") || "none"], + text: compactDecisionText(promptText, descriptionText), + }), }); if (toolDecision.accepted && toolDecision.char === "D") { const suggestion = suggestAllowedTool(String(denied[0]), allowed); diff --git a/plugin/gateway-core/dist/hooks/agent-model-resolver/index.js b/plugin/gateway-core/dist/hooks/agent-model-resolver/index.js index d166400c..01b5ac2b 100644 --- a/plugin/gateway-core/dist/hooks/agent-model-resolver/index.js +++ b/plugin/gateway-core/dist/hooks/agent-model-resolver/index.js @@ -1,7 +1,7 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js"; import { loadAgentMetadata } from "../shared/agent-metadata.js"; import { annotateDelegationMetadata, resolveDelegationTraceId } from "../shared/delegation-trace.js"; -import { writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; +import { buildCompactDecisionCacheKey, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; const MODEL_BY_CATEGORY = { quick: { model: "openai/gpt-5.1-codex-mini", reasoning: "low" }, balanced: { model: "openai/gpt-5.3-codex", reasoning: "medium" }, @@ -387,7 +387,11 @@ export function createAgentModelResolverHook(options) { context: buildRoutingContext(String(args.prompt ?? ""), String(args.description ?? ""), originalExplicitSubagent, aiInferred.name, aiInferred.score, explicitScore), allowedChars: alphabet, decisionMeaning: buildRoutingDecisionMeaning(aiInferred.name, originalExplicitSubagent), - cacheKey: `route:${originalExplicitSubagent || "none"}:${aiInferred.name}:${combinedText}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "route", + parts: [originalExplicitSubagent || "none", aiInferred.name], + text: buildRoutingContext(String(args.prompt ?? ""), String(args.description ?? ""), originalExplicitSubagent, aiInferred.name, aiInferred.score, explicitScore), + }), }); if (decision.accepted) { const resolvedChar = decision.char.toUpperCase(); diff --git a/plugin/gateway-core/dist/hooks/auto-slash-command/index.js b/plugin/gateway-core/dist/hooks/auto-slash-command/index.js index 2b8ef1b7..91f435f8 100644 --- a/plugin/gateway-core/dist/hooks/auto-slash-command/index.js +++ b/plugin/gateway-core/dist/hooks/auto-slash-command/index.js @@ -1,11 +1,16 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js"; -import { writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; +import { buildCompactDecisionCacheKey, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; const AUTO_SLASH_COMMAND_TAG_OPEN = ""; const AUTO_SLASH_COMMAND_TAG_CLOSE = ""; const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/; const EXCLUDED_COMMANDS = new Set(["ulw-loop"]); const INLINE_SLASH_TOKEN_PATTERN = /(^|\s)\/([a-zA-Z][\w-]*)\b/g; const HIGH_RISK_SKIP_PATTERN = /\b(install|npm\s+install|brew\s+install|setup|configure|deploy|production)\b/i; +const DETERMINISTIC_DOCTOR_PATTERN = /\b(doctor|diagnos(?:e|is|tic|tics))\b/i; +const DIAGNOSTIC_CUE_PATTERN = /\b(doctor|diagnos(?:e|is|tic|tics)|health(?:\s+check)?|debug|investigat(?:e|ion)|inspect)\b/i; +const ACTION_VERB_PATTERN = /\b(run|open|use|launch|start|check|perform|do|inspect|investigate|debug|review|analy[sz]e|look\s+into|tell\s+me|show\s+me|help\s+me\s+understand)\b/i; +const META_DISCUSSION_SKIP_PATTERN = /\b(last session|previous session|instruction command|prompt wording|prompt text|slash doctor|auto[-\s]?slash|why did|why does|routed to|route to|activated \/doctor|triggered \/doctor|command behavior)\b/i; +const INVESTIGATION_CONTEXT_PATTERN = /\b(issue|environment|state|problem|wrong|error|failure|symptom|health)\b/i; const AI_AUTO_SLASH_CHAR_TO_COMMAND = { D: "/doctor", }; @@ -151,19 +156,24 @@ function detectSlash(prompt) { return { slash: explicit.raw, excludedExplicit: false }; } const text = cleaned.toLowerCase(); - if (text.includes("doctor") || text.includes("diagnose") || text.includes("health check")) { + if (!META_DISCUSSION_SKIP_PATTERN.test(text) && DETERMINISTIC_DOCTOR_PATTERN.test(text) && ACTION_VERB_PATTERN.test(text)) { return { slash: "/doctor", excludedExplicit: false }; } return { slash: null, excludedExplicit: false }; } function shouldSkipAiAutoSlash(prompt) { - return HIGH_RISK_SKIP_PATTERN.test(prompt); + const hasInvestigativeIntent = ACTION_VERB_PATTERN.test(prompt); + const hasEligibleContext = DIAGNOSTIC_CUE_PATTERN.test(prompt) || INVESTIGATION_CONTEXT_PATTERN.test(prompt); + return (HIGH_RISK_SKIP_PATTERN.test(prompt) || + META_DISCUSSION_SKIP_PATTERN.test(prompt) || + !hasInvestigativeIntent || + !hasEligibleContext); } function shouldSkipAutoSlash(prompt) { return HIGH_RISK_SKIP_PATTERN.test(prompt); } function buildAiSlashInstruction() { - return "Classify only the sanitized user request text for diagnostics intent. D=diagnostics_or_health_check, N=not_diagnostics."; + return "Classify only the sanitized user request text for explicit diagnostics intent. Return D only when the user is clearly asking to run or perform diagnostics or health checks now. Return N for meta discussion about prompts, routing, commands, past sessions, or instruction wording."; } function buildAiSlashContext(prompt) { return `request=${normalizePromptForAi(prompt) || "(empty)"}`; @@ -210,7 +220,10 @@ export function createAutoSlashCommandHook(options) { D: "route_doctor", N: "no_slash", }, - cacheKey: `auto-slash:${prompt.trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "auto-slash", + text: normalizePromptForAi(prompt), + }), }); } catch (error) { @@ -236,27 +249,20 @@ export function createAutoSlashCommandHook(options) { deterministicValue: "none", aiValue: aiSlash ?? "none", }); + const shadowDeferred = options.decisionRuntime.config.mode === "shadow" && aiSlash; writeGatewayEventAudit(directory, { hook: "auto-slash-command", stage: "state", - reason_code: "llm_auto_slash_decision_recorded", + reason_code: shadowDeferred + ? "llm_auto_slash_shadow_deferred" + : "llm_auto_slash_decision_recorded", session_id: sessionId, llm_decision_char: decision.char, llm_decision_meaning: decision.meaning, llm_decision_mode: options.decisionRuntime.config.mode, slash_command: aiSlash ?? undefined, }); - if (options.decisionRuntime.config.mode === "shadow" && aiSlash) { - writeGatewayEventAudit(directory, { - hook: "auto-slash-command", - stage: "state", - reason_code: "llm_auto_slash_shadow_deferred", - session_id: sessionId, - llm_decision_char: decision.char, - llm_decision_meaning: decision.meaning, - llm_decision_mode: options.decisionRuntime.config.mode, - slash_command: aiSlash, - }); + if (shadowDeferred) { } else { slash = aiSlash; diff --git a/plugin/gateway-core/dist/hooks/context-window-monitor/index.js b/plugin/gateway-core/dist/hooks/context-window-monitor/index.js index 96631afe..f1a9c5d2 100644 --- a/plugin/gateway-core/dist/hooks/context-window-monitor/index.js +++ b/plugin/gateway-core/dist/hooks/context-window-monitor/index.js @@ -141,12 +141,6 @@ export function createContextWindowMonitorHook(options) { }); const actualUsage = totalInputTokens / actualLimit; if (actualUsage < options.warningThreshold) { - writeGatewayEventAudit(directory, { - hook: "context-window-monitor", - stage: "skip", - reason_code: "below_warning_threshold", - session_id: sessionId, - }); return; } const hasPriorReminder = nextState.lastWarnedAtToolCall > 0; @@ -154,21 +148,9 @@ export function createContextWindowMonitorHook(options) { const cooldownElapsed = nextState.toolCalls - nextState.lastWarnedAtToolCall >= options.reminderCooldownToolCalls; const tokenDeltaEnough = totalInputTokens - nextState.lastWarnedTokens >= options.minTokenDeltaForReminder; if (!cooldownElapsed) { - writeGatewayEventAudit(directory, { - hook: "context-window-monitor", - stage: "skip", - reason_code: "reminder_cooldown_not_elapsed", - session_id: sessionId, - }); return; } if (!tokenDeltaEnough) { - writeGatewayEventAudit(directory, { - hook: "context-window-monitor", - stage: "skip", - reason_code: "reminder_token_delta_too_small", - session_id: sessionId, - }); return; } } diff --git a/plugin/gateway-core/dist/hooks/continuation/index.js b/plugin/gateway-core/dist/hooks/continuation/index.js index af2ae3fd..cec4abb1 100644 --- a/plugin/gateway-core/dist/hooks/continuation/index.js +++ b/plugin/gateway-core/dist/hooks/continuation/index.js @@ -217,11 +217,6 @@ export function createContinuationHook(options) { : options.directory; const sessionId = resolveSessionId(eventPayload); if (!sessionId) { - writeGatewayEventAudit(directory, { - hook: "continuation", - stage: "skip", - reason_code: "missing_session_id", - }); return; } let state = loadGatewayState(directory); @@ -240,29 +235,12 @@ export function createContinuationHook(options) { } } if (!state || !active || active.active !== true) { - writeGatewayEventAudit(directory, { - hook: "continuation", - stage: "skip", - reason_code: "no_active_loop", - }); return; } if (options.stopGuard?.isStopped(sessionId)) { - writeGatewayEventAudit(directory, { - hook: "continuation", - stage: "skip", - reason_code: "stop_guard_active", - session_id: sessionId, - }); return; } if (!sessionId || sessionId !== active.sessionId) { - writeGatewayEventAudit(directory, { - hook: "continuation", - stage: "skip", - reason_code: "session_mismatch", - has_session_id: sessionId.length > 0, - }); return; } const client = options.client?.session; @@ -296,13 +274,6 @@ export function createContinuationHook(options) { } state.lastUpdatedAt = nowIso(); saveGatewayState(directory, state); - writeGatewayEventAudit(directory, { - hook: "continuation", - stage: "skip", - reason_code: REASON_CODES.LOOP_COMPLETION_IGNORED_INCOMPLETE_RUNTIME, - session_id: sessionId, - ignored_completion_cycles: ignoredCycles, - }); } else { active.active = false; @@ -360,13 +331,6 @@ export function createContinuationHook(options) { directory, }); if (!safety.safe) { - writeGatewayEventAudit(directory, { - hook: "continuation", - stage: "skip", - reason_code: `idle_prompt_${safety.reason}`, - session_id: sessionId, - iteration: active.iteration, - }); return; } const mode = options.keywordDetector?.modeForSession(sessionId) ?? null; diff --git a/plugin/gateway-core/dist/hooks/delegation-fallback-orchestrator/index.js b/plugin/gateway-core/dist/hooks/delegation-fallback-orchestrator/index.js index 0b8e30b5..db350d58 100644 --- a/plugin/gateway-core/dist/hooks/delegation-fallback-orchestrator/index.js +++ b/plugin/gateway-core/dist/hooks/delegation-fallback-orchestrator/index.js @@ -1,5 +1,5 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js"; -import { writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; +import { buildCompactDecisionCacheKey, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; import { annotateDelegationMetadata, extractDelegationTraceId, resolveDelegationTraceId, } from "../shared/delegation-trace.js"; const FAILURE_REASON_BY_CHAR = { U: "delegation_unknown_agent", @@ -171,7 +171,11 @@ export function createDelegationFallbackOrchestratorHook(options) { R: "delegation_runtime_error", N: "no_match", }, - cacheKey: `delegation-failure:${subagentType}:${category}:${String(eventPayload.output.output ?? "").trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "delegation-failure", + parts: [subagentType || "none", category || "none"], + text: buildFailureContext(String(eventPayload.output.output ?? ""), String(args?.prompt ?? ""), String(args?.description ?? "")), + }), }); if (decision.accepted) { const aiReason = FAILURE_REASON_BY_CHAR[decision.char] ?? null; diff --git a/plugin/gateway-core/dist/hooks/done-proof-enforcer/index.d.ts b/plugin/gateway-core/dist/hooks/done-proof-enforcer/index.d.ts index 8feed316..51fc9f29 100644 --- a/plugin/gateway-core/dist/hooks/done-proof-enforcer/index.d.ts +++ b/plugin/gateway-core/dist/hooks/done-proof-enforcer/index.d.ts @@ -1,5 +1,5 @@ import type { GatewayHook } from "../registry.js"; -import type { LlmDecisionRuntime } from "../shared/llm-decision-runtime.js"; +import { type LlmDecisionRuntime } from "../shared/llm-decision-runtime.js"; export declare function createDoneProofEnforcerHook(options: { enabled: boolean; requiredMarkers: string[]; diff --git a/plugin/gateway-core/dist/hooks/done-proof-enforcer/index.js b/plugin/gateway-core/dist/hooks/done-proof-enforcer/index.js index 3833420c..6bdef71c 100644 --- a/plugin/gateway-core/dist/hooks/done-proof-enforcer/index.js +++ b/plugin/gateway-core/dist/hooks/done-proof-enforcer/index.js @@ -1,4 +1,5 @@ import { markerCategory, validationEvidenceStatus } from "../validation-evidence-ledger/evidence.js"; +import { buildCompactDecisionCacheKey } from "../shared/llm-decision-runtime.js"; import { writeGatewayEventAudit } from "../../audit/event-audit.js"; import { writeDecisionComparisonAudit } from "../shared/llm-decision-runtime.js"; import { listToolAfterOutputTexts, readCombinedToolAfterOutputText, writeToolAfterOutputChannelText, } from "../shared/tool-after-output.js"; @@ -50,7 +51,10 @@ export function createDoneProofEnforcerHook(options) { context: buildMarkerContext(text), allowedChars: ["Y", "N"], decisionMeaning: { Y: `${marker}_present`, N: `${marker}_missing` }, - cacheKey: `done-proof:${marker}:${text.trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: `done-proof:${marker}`, + text: buildMarkerContext(text), + }), }); if (decision.accepted) { writeDecisionComparisonAudit({ diff --git a/plugin/gateway-core/dist/hooks/long-turn-watchdog/index.d.ts b/plugin/gateway-core/dist/hooks/long-turn-watchdog/index.d.ts index 0a86a870..00a1ca40 100644 --- a/plugin/gateway-core/dist/hooks/long-turn-watchdog/index.d.ts +++ b/plugin/gateway-core/dist/hooks/long-turn-watchdog/index.d.ts @@ -1,48 +1,7 @@ import type { GatewayHook } from "../registry.js"; -interface GatewayClient { - session?: { - messages?(args: { - path: { - id: string; - }; - query?: { - directory?: string; - }; - }): Promise<{ - data?: Array<{ - info?: { - role?: string; - error?: unknown; - time?: { - completed?: number; - }; - }; - parts?: Array<{ - type?: string; - text?: string; - synthetic?: boolean; - }>; - }>; - }>; - promptAsync(args: { - path: { - id: string; - }; - body: { - parts: Array<{ - type: string; - text: string; - }>; - }; - query?: { - directory?: string; - }; - }): Promise; - }; -} export declare function createLongTurnWatchdogHook(options: { directory: string; - client?: GatewayClient; + client?: unknown; enabled: boolean; warningThresholdMs: number; toolCallWarningThreshold: number; @@ -51,4 +10,3 @@ export declare function createLongTurnWatchdogHook(options: { prefix: string; now?: () => number; }): GatewayHook; -export {}; diff --git a/plugin/gateway-core/dist/hooks/long-turn-watchdog/index.js b/plugin/gateway-core/dist/hooks/long-turn-watchdog/index.js index e8f80eb0..c1cef345 100644 --- a/plugin/gateway-core/dist/hooks/long-turn-watchdog/index.js +++ b/plugin/gateway-core/dist/hooks/long-turn-watchdog/index.js @@ -1,5 +1,4 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js"; -import { injectHookMessage, inspectHookMessageSafety } from "../hook-message-injector/index.js"; import { inspectToolAfterOutputText, writeToolAfterOutputText, } from "../shared/tool-after-output.js"; function resolveSessionId(payload) { const candidates = [ @@ -41,47 +40,11 @@ function formatDuration(ms) { export function createLongTurnWatchdogHook(options) { const states = new Map(); const now = options.now ?? (() => Date.now()); - async function injectVisibleProgressPulse(args) { - const client = options.client?.session; - if (!client) { - return; - } - const safety = await inspectHookMessageSafety({ - session: client, - sessionId: args.sessionId, - directory: args.directory, - }); - if (!safety.safe && safety.reason !== "assistant_turn_incomplete") { - writeGatewayEventAudit(args.directory, { - hook: "long-turn-watchdog", - stage: "skip", - reason_code: `visible_progress_pulse_${safety.reason}`, - session_id: args.sessionId, - }); - return; - } - if (!safety.safe && safety.reason === "assistant_turn_incomplete") { - writeGatewayEventAudit(args.directory, { - hook: "long-turn-watchdog", - stage: "state", - reason_code: "visible_progress_pulse_forcing_incomplete_parent_recovery", - session_id: args.sessionId, - }); - } - const injected = await injectHookMessage({ - session: client, - sessionId: args.sessionId, - directory: args.directory, - content: `[runtime progress pulse]\nStill working in this turn after ${formatDuration(args.elapsedMs)} and ${args.toolCallsThisTurn} tool call${args.toolCallsThisTurn === 1 ? "" : "s"}. I will send the final result once I clear the current step.`, - }); - writeGatewayEventAudit(args.directory, { - hook: "long-turn-watchdog", - stage: injected ? "inject" : "skip", - reason_code: injected ? "visible_progress_pulse_injected" : "visible_progress_pulse_inject_failed", - session_id: args.sessionId, - elapsed_ms: args.elapsedMs, - tool_calls_this_turn: args.toolCallsThisTurn, - }); + function visibleProgressPulseText(args) { + return [ + "[runtime progress pulse]", + `Still working in this turn after ${formatDuration(args.elapsedMs)} and ${args.toolCallsThisTurn} tool call${args.toolCallsThisTurn === 1 ? "" : "s"}. I will send the final result once I clear the current step.`, + ].join("\n"); } return { id: "long-turn-watchdog", @@ -146,71 +109,38 @@ export function createLongTurnWatchdogHook(options) { state.toolCallsThisTurn += 1; const { text, channel } = inspectToolAfterOutputText(eventPayload.output?.output); if (!text) { - writeGatewayEventAudit(directory, { - hook: "long-turn-watchdog", - stage: "skip", - reason_code: "output_not_text", - session_id: sessionId, - }); return; } const elapsedMs = Math.max(0, now() - state.turnStartMs); const toolCallThreshold = Math.max(1, Math.floor(options.toolCallWarningThreshold)); if (elapsedMs < options.warningThresholdMs && state.toolCallsThisTurn < toolCallThreshold) { - writeGatewayEventAudit(directory, { - hook: "long-turn-watchdog", - stage: "skip", - reason_code: "below_threshold", - session_id: sessionId, - elapsed_ms: elapsedMs, - warning_threshold_ms: options.warningThresholdMs, - tool_calls_this_turn: state.toolCallsThisTurn, - tool_call_warning_threshold: toolCallThreshold, - }); return; } const sameTurnWarned = state.warnedTurnCounter === state.turnCounter; if (sameTurnWarned) { - writeGatewayEventAudit(directory, { - hook: "long-turn-watchdog", - stage: "skip", - reason_code: "already_warned_for_turn", - session_id: sessionId, - elapsed_ms: elapsedMs, - turn_counter: state.turnCounter, - }); return; } if (options.reminderCooldownMs > 0 && state.lastWarnedAtMs > 0 && now() - state.lastWarnedAtMs < options.reminderCooldownMs) { - writeGatewayEventAudit(directory, { - hook: "long-turn-watchdog", - stage: "skip", - reason_code: "cooldown_active", - session_id: sessionId, - elapsed_ms: elapsedMs, - reminder_cooldown_ms: options.reminderCooldownMs, - }); return; } const prefix = options.prefix.trim() || "[Turn Watchdog]:"; const warning = `${prefix} Long turn detected (${formatDuration(elapsedMs)} since last user message; threshold ${formatDuration(options.warningThresholdMs)}).`; const heartbeat = `${prefix} Still working - collecting results before the final reply.`; - const amended = `${text}\n\n${warning}\n${heartbeat}`; + const shouldAppendPulse = state.toolCallsThisTurn >= toolCallThreshold; + const pulse = visibleProgressPulseText({ + elapsedMs, + toolCallsThisTurn: state.toolCallsThisTurn, + }); + const amended = shouldAppendPulse + ? `${text}\n\n${warning}\n${heartbeat}\n\n${pulse}` + : `${text}\n\n${warning}\n${heartbeat}`; if (!writeToolAfterOutputText(eventPayload.output?.output, amended, channel)) { if (typeof eventPayload.output === "object" && eventPayload.output) { eventPayload.output.output = amended; } } - if (state.toolCallsThisTurn >= toolCallThreshold) { - await injectVisibleProgressPulse({ - sessionId, - directory, - elapsedMs, - toolCallsThisTurn: state.toolCallsThisTurn, - }); - } state.warnedTurnCounter = state.turnCounter; state.lastWarnedAtMs = now(); writeGatewayEventAudit(directory, { @@ -220,6 +150,7 @@ export function createLongTurnWatchdogHook(options) { session_id: sessionId, elapsed_ms: elapsedMs, tool_calls_this_turn: state.toolCallsThisTurn, + visible_progress_pulse: shouldAppendPulse, tool_call_warning_threshold: toolCallThreshold, warning_threshold_ms: options.warningThresholdMs, turn_started_at: new Date(state.turnStartMs).toISOString(), diff --git a/plugin/gateway-core/dist/hooks/pr-body-evidence-guard/index.js b/plugin/gateway-core/dist/hooks/pr-body-evidence-guard/index.js index 272d6d5f..39657ad8 100644 --- a/plugin/gateway-core/dist/hooks/pr-body-evidence-guard/index.js +++ b/plugin/gateway-core/dist/hooks/pr-body-evidence-guard/index.js @@ -1,5 +1,5 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js"; -import { writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; +import { buildCompactDecisionCacheKey, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; import { inspectGitHubPrCreateBody, isGitHubPrCreateCommand } from "../shared/github-pr-commands.js"; import { validationEvidenceStatus } from "../validation-evidence-ledger/evidence.js"; function buildSectionInstruction(section) { @@ -70,7 +70,10 @@ export function createPrBodyEvidenceGuardHook(options) { context: buildSectionContext(body), allowedChars: ["Y", "N"], decisionMeaning: { Y: "summary_present", N: "summary_missing" }, - cacheKey: `pr-body-summary:${body.trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "pr-body-summary", + text: buildSectionContext(body), + }), }); if (decision.accepted) { writeDecisionComparisonAudit({ @@ -117,7 +120,10 @@ export function createPrBodyEvidenceGuardHook(options) { context: buildSectionContext(body), allowedChars: ["Y", "N"], decisionMeaning: { Y: "validation_present", N: "validation_missing" }, - cacheKey: `pr-body-validation:${body.trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "pr-body-validation", + text: buildSectionContext(body), + }), }); if (decision.accepted) { writeDecisionComparisonAudit({ diff --git a/plugin/gateway-core/dist/hooks/preemptive-compaction/index.js b/plugin/gateway-core/dist/hooks/preemptive-compaction/index.js index d1d44bcf..c1f0901c 100644 --- a/plugin/gateway-core/dist/hooks/preemptive-compaction/index.js +++ b/plugin/gateway-core/dist/hooks/preemptive-compaction/index.js @@ -116,21 +116,9 @@ export function createPreemptiveCompactionHook(options) { const cooldownElapsed = nextState.toolCalls - nextState.lastCompactedAtToolCall >= options.compactionCooldownToolCalls; const tokenDeltaEnough = totalInputTokens - nextState.lastCompactedTokens >= options.minTokenDeltaForCompaction; if (!cooldownElapsed) { - writeGatewayEventAudit(directory, { - hook: "preemptive-compaction", - stage: "skip", - reason_code: "compaction_cooldown_not_elapsed", - session_id: sessionId, - }); return; } if (!tokenDeltaEnough) { - writeGatewayEventAudit(directory, { - hook: "preemptive-compaction", - stage: "skip", - reason_code: "compaction_token_delta_too_small", - session_id: sessionId, - }); return; } } diff --git a/plugin/gateway-core/dist/hooks/primary-worktree-guard/index.js b/plugin/gateway-core/dist/hooks/primary-worktree-guard/index.js index 7f14a287..b41bc718 100644 --- a/plugin/gateway-core/dist/hooks/primary-worktree-guard/index.js +++ b/plugin/gateway-core/dist/hooks/primary-worktree-guard/index.js @@ -22,6 +22,9 @@ function isPrimaryWorktree(directory) { function stripQuotes(token) { return token.replace(/^['"]|['"]$/g, ""); } +function shellQuote(value) { + return JSON.stringify(value); +} const GIT_PREFIX = String.raw `(?:^|&&|\|\||;)\s*(?:env\s+(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)*)?(?:(?:[^\s;&|]*/)?rtk\s+)?(?:[^\s;&|]*/)?git\s+`; function matchBranchTarget(command, pattern) { const match = command.match(pattern); @@ -51,6 +54,22 @@ function branchSwitchInfo(command) { } export function createPrimaryWorktreeGuardHook(options) { const allowedBranches = new Set(options.allowedBranches.map((item) => item.trim()).filter(Boolean)); + function rerouteToMaintenanceHelper(payload, directory, sessionId, reasonCode) { + const args = payload.output?.args; + const originalCommand = typeof args?.command === "string" ? args.command.trim() : ""; + if (!args || !originalCommand) { + return false; + } + args.command = `python3 scripts/worktree_helper_command.py maintenance --directory ${shellQuote(directory)} --command ${shellQuote(originalCommand)} --json`; + writeGatewayEventAudit(directory, { + hook: "primary-worktree-guard", + stage: "state", + reason_code: reasonCode, + session_id: sessionId, + blocked_command: originalCommand, + }); + return true; + } return { id: "primary-worktree-guard", priority: 689, @@ -95,12 +114,9 @@ export function createPrimaryWorktreeGuardHook(options) { if (isAllowedProtectedShellCommand(command)) { return; } - writeGatewayEventAudit(directory, { - hook: "primary-worktree-guard", - stage: "skip", - reason_code: "bash_in_primary_worktree_blocked", - session_id: sessionId, - }); + if (rerouteToMaintenanceHelper(eventPayload, directory, sessionId, "bash_in_primary_worktree_rerouted")) { + return; + } throw new Error("Bash commands in the primary project folder are limited to inspection, validation, and exact default-branch sync commands (`git fetch`, `git fetch --prune`, and `git pull --rebase`). Create or use a dedicated git worktree branch for task mutations."); }, }; diff --git a/plugin/gateway-core/dist/hooks/provider-error-classifier/index.js b/plugin/gateway-core/dist/hooks/provider-error-classifier/index.js index 11636ee2..7b218745 100644 --- a/plugin/gateway-core/dist/hooks/provider-error-classifier/index.js +++ b/plugin/gateway-core/dist/hooks/provider-error-classifier/index.js @@ -1,7 +1,7 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js"; import { injectHookMessage } from "../hook-message-injector/index.js"; import { classifyProviderRetryReason, isContextOverflowNonRetryable } from "../shared/provider-retry-reason.js"; -import { writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; +import { buildCompactDecisionCacheKey, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; const CLASSIFICATION_BY_CHAR = { F: "free_usage_exhausted", R: "rate_limited", @@ -135,7 +135,10 @@ export function createProviderErrorClassifierHook(options) { O: "provider_overloaded", N: "not_classified", }, - cacheKey: `provider-error:${text.trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "provider-error", + text: buildAiContext(text), + }), }); if (decision.skippedReason === "max_concurrency_reached" || decision.skippedReason === "runtime_cooldown") { if (session && sessionId) { @@ -171,27 +174,19 @@ export function createProviderErrorClassifierHook(options) { deterministicValue: "none", aiValue: classification, }); + const shadowDeferred = options.decisionRuntime.config.mode === "shadow"; writeGatewayEventAudit(resolveDirectory(eventPayload, options.directory), { hook: "provider-error-classifier", stage: "state", - reason_code: "llm_provider_error_decision_recorded", + reason_code: shadowDeferred + ? "llm_provider_error_shadow_deferred" + : "llm_provider_error_decision_recorded", session_id: sessionId, llm_decision_char: decision.char, llm_decision_meaning: decision.meaning, llm_decision_mode: options.decisionRuntime.config.mode, }); - if (options.decisionRuntime.config.mode === "shadow") { - writeGatewayEventAudit(resolveDirectory(eventPayload, options.directory), { - hook: "provider-error-classifier", - stage: "state", - reason_code: "llm_provider_error_shadow_deferred", - session_id: sessionId, - llm_decision_char: decision.char, - llm_decision_meaning: decision.meaning, - llm_decision_mode: options.decisionRuntime.config.mode, - }); - } - else { + if (!shadowDeferred) { outcome = { classification, reason: `llm:${decision.meaning || decision.char}`, diff --git a/plugin/gateway-core/dist/hooks/provider-token-limit-recovery/index.js b/plugin/gateway-core/dist/hooks/provider-token-limit-recovery/index.js index 43a627c8..7893d93d 100644 --- a/plugin/gateway-core/dist/hooks/provider-token-limit-recovery/index.js +++ b/plugin/gateway-core/dist/hooks/provider-token-limit-recovery/index.js @@ -123,12 +123,6 @@ export function createProviderTokenLimitRecoveryHook(options) { directory, }); if (!safety.safe) { - writeGatewayEventAudit(directory, { - hook: "provider-token-limit-recovery", - stage: "skip", - reason_code: `token_limit_recovery_${safety.reason}`, - session_id: sessionId, - }); return; } const injected = await injectHookMessage({ diff --git a/plugin/gateway-core/dist/hooks/session-recovery/index.js b/plugin/gateway-core/dist/hooks/session-recovery/index.js index 08e56438..d4f65cb6 100644 --- a/plugin/gateway-core/dist/hooks/session-recovery/index.js +++ b/plugin/gateway-core/dist/hooks/session-recovery/index.js @@ -128,6 +128,34 @@ function looksLikeSilentQuestionStallFromHistory(messages) { } return { matched: true, tool }; } +function looksLikeIncompleteAssistantTailFromHistory(messages) { + const message = messages.at(-1); + if (message?.info?.role !== "assistant") { + return { matched: false, tool: "" }; + } + const errored = message.info?.error !== undefined && message.info?.error !== null; + const completed = Number.isFinite(Number(message.info?.time?.completed ?? Number.NaN)); + if (errored || completed) { + return { matched: false, tool: "" }; + } + const parts = Array.isArray(message.parts) ? message.parts : []; + if (parts.some((part) => { + const toolName = String(part?.tool ?? "").trim().toLowerCase(); + return toolName === "question" || toolName === "askuserquestion"; + })) { + return { matched: false, tool: "" }; + } + const lastToolPart = [...parts].reverse().find((part) => part?.type === "tool"); + const tool = String(lastToolPart?.tool ?? "").trim().toLowerCase(); + if (!tool || tool === "question" || tool === "askuserquestion") { + return { matched: false, tool: "" }; + } + const hasVisibleText = parts.some((part) => part?.type === "text" && + typeof part.text === "string" && + part.text.trim() && + !part.synthetic); + return { matched: !hasVisibleText, tool }; +} async function injectRecoveryMessage(args) { const safety = await inspectHookMessageSafety({ session: args.session, @@ -264,12 +292,6 @@ export function createSessionRecoveryHook(options) { if (pendingQuestion) { const ageMs = Math.max(0, nowMs() - Math.max(pendingQuestion.startedAt, pendingQuestion.lastUpdatedAt)); if (ageMs < STALE_QUESTION_PREVENTION_MS) { - writeGatewayEventAudit(directory, { - hook: "session-recovery", - stage: "skip", - reason_code: "stale_question_tool_prevention_not_stale", - session_id: sessionId, - }); return; } } @@ -302,6 +324,25 @@ export function createSessionRecoveryHook(options) { } const silentQuestion = looksLikeSilentQuestionStallFromHistory(messages); if (!silentQuestion.matched) { + const incompleteTail = looksLikeIncompleteAssistantTailFromHistory(messages); + if (!incompleteTail.matched) { + return; + } + recoveringSessions.add(sessionId); + try { + await injectRecoveryMessage({ + session: client, + sessionId, + directory, + hook: "session-recovery", + reasonCode: "incomplete_assistant_tail_recovery", + allowIncompleteAssistantTurn: true, + content: `[incomplete assistant turn detected during idle - continuing now]\nlast_tool: ${incompleteTail.tool}`, + }); + } + finally { + recoveringSessions.delete(sessionId); + } return; } recoveringSessions.add(sessionId); diff --git a/plugin/gateway-core/dist/hooks/shared/llm-decision-runtime.js b/plugin/gateway-core/dist/hooks/shared/llm-decision-runtime.js index f53a8da7..cd5a0794 100644 --- a/plugin/gateway-core/dist/hooks/shared/llm-decision-runtime.js +++ b/plugin/gateway-core/dist/hooks/shared/llm-decision-runtime.js @@ -297,16 +297,6 @@ export function createLlmDecisionRuntime(options) { }; } if (process.env[LLM_DECISION_CHILD_ENV] === "1") { - writeGatewayEventAudit(options.directory, { - hook: request.hookId, - stage: "skip", - reason_code: "llm_decision_nested_child_skipped", - session_id: request.sessionId, - trace_id: request.traceId, - template_id: request.templateId, - decision_mode: config.mode, - model: config.model, - }); return { ...baseResult, durationMs: Date.now() - start, @@ -321,17 +311,6 @@ export function createLlmDecisionRuntime(options) { }; } if (cooldownUntil > Date.now()) { - writeGatewayEventAudit(options.directory, { - hook: request.hookId, - stage: "skip", - reason_code: "llm_decision_runtime_cooldown", - session_id: request.sessionId, - trace_id: request.traceId, - template_id: request.templateId, - decision_mode: config.mode, - model: config.model, - duration_ms: String(Date.now() - start), - }); return { ...baseResult, durationMs: Date.now() - start, @@ -339,17 +318,6 @@ export function createLlmDecisionRuntime(options) { }; } if (activeDecisions >= config.maxConcurrentDecisions) { - writeGatewayEventAudit(options.directory, { - hook: request.hookId, - stage: "skip", - reason_code: "llm_decision_max_concurrency", - session_id: request.sessionId, - trace_id: request.traceId, - template_id: request.templateId, - decision_mode: config.mode, - model: config.model, - duration_ms: String(Date.now() - start), - }); return { ...baseResult, durationMs: Date.now() - start, diff --git a/plugin/gateway-core/dist/hooks/subagent-lifecycle-supervisor/index.js b/plugin/gateway-core/dist/hooks/subagent-lifecycle-supervisor/index.js index 3893e07c..d3f19800 100644 --- a/plugin/gateway-core/dist/hooks/subagent-lifecycle-supervisor/index.js +++ b/plugin/gateway-core/dist/hooks/subagent-lifecycle-supervisor/index.js @@ -417,15 +417,6 @@ export function createSubagentLifecycleSupervisorHook(options) { } } if (ageMs < options.staleRunningMs) { - writeGatewayEventAudit(directory, { - hook: "subagent-lifecycle-supervisor", - stage: "skip", - reason_code: "subagent_lifecycle_child_idle_not_stale", - session_id: link.parentSessionId, - child_run_id: runningState.childRunId, - trace_id: runningState.traceId, - subagent_type: runningState.subagentType, - }); return; } const staleRecovered = finalizeLinkedLifecycle({ diff --git a/plugin/gateway-core/dist/hooks/task-resume-info/index.js b/plugin/gateway-core/dist/hooks/task-resume-info/index.js index aaa13de4..6ba69374 100644 --- a/plugin/gateway-core/dist/hooks/task-resume-info/index.js +++ b/plugin/gateway-core/dist/hooks/task-resume-info/index.js @@ -84,27 +84,18 @@ async function resolveSemanticHints(options) { deterministicValue: "none", aiValue: decision.char, }); + const shadowDeferred = options.decisionRuntime.config.mode === "shadow" && (addContinuation || addVerification); writeGatewayEventAudit(options.directory, { hook: "task-resume-info", stage: "state", - reason_code: "llm_task_resume_decision_recorded", + reason_code: shadowDeferred ? "llm_task_resume_shadow_deferred" : "llm_task_resume_decision_recorded", session_id: options.sessionId, llm_decision_char: decision.char, llm_decision_meaning: decision.meaning, llm_decision_mode: options.decisionRuntime.config.mode, resume_target: options.resumeTarget || undefined, }); - if (options.decisionRuntime.config.mode === "shadow" && (addContinuation || addVerification)) { - writeGatewayEventAudit(options.directory, { - hook: "task-resume-info", - stage: "state", - reason_code: "llm_task_resume_shadow_deferred", - session_id: options.sessionId, - llm_decision_char: decision.char, - llm_decision_meaning: decision.meaning, - llm_decision_mode: options.decisionRuntime.config.mode, - resume_target: options.resumeTarget || undefined, - }); + if (shadowDeferred) { return { addContinuation: false, addVerification: false }; } return { addContinuation, addVerification }; diff --git a/plugin/gateway-core/dist/hooks/todo-continuation-enforcer/index.d.ts b/plugin/gateway-core/dist/hooks/todo-continuation-enforcer/index.d.ts index 999a9602..1f10d676 100644 --- a/plugin/gateway-core/dist/hooks/todo-continuation-enforcer/index.d.ts +++ b/plugin/gateway-core/dist/hooks/todo-continuation-enforcer/index.d.ts @@ -1,5 +1,5 @@ import type { GatewayHook } from "../registry.js"; -import type { LlmDecisionRuntime } from "../shared/llm-decision-runtime.js"; +import { type LlmDecisionRuntime } from "../shared/llm-decision-runtime.js"; import type { StopContinuationGuard } from "../stop-continuation-guard/index.js"; interface GatewayClient { session?: { diff --git a/plugin/gateway-core/dist/hooks/todo-continuation-enforcer/index.js b/plugin/gateway-core/dist/hooks/todo-continuation-enforcer/index.js index 755fdc2e..e02b79d1 100644 --- a/plugin/gateway-core/dist/hooks/todo-continuation-enforcer/index.js +++ b/plugin/gateway-core/dist/hooks/todo-continuation-enforcer/index.js @@ -1,10 +1,7 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js"; import { loadGatewayState } from "../../state/storage.js"; import { injectHookMessage, inspectHookMessageSafety } from "../hook-message-injector/index.js"; -import { writeDecisionComparisonAudit } from "../shared/llm-decision-runtime.js"; -function compactDecisionCacheKey(text) { - return text.trim().toLowerCase().replace(/\s+/g, " ").slice(0, 240); -} +import { buildCompactDecisionCacheKey, writeDecisionComparisonAudit } from "../shared/llm-decision-runtime.js"; const CONTINUE_LOOP_MARKER = ""; const TODO_CONTINUATION_PROMPT = [ "[SYSTEM DIRECTIVE: TODO CONTINUATION]", @@ -219,7 +216,11 @@ async function resolvePendingContinuationDecision(options) { S: "no_pending", U: "unclear", }, - cacheKey: `todo-continuation:${options.source}:${options.continueIntentArmed ? "armed" : "unarmed"}:${compactDecisionCacheKey(options.text)}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "todo-continuation", + parts: [options.source, options.continueIntentArmed ? "armed" : "unarmed"], + text: buildContinuationContext(options.text, options.continueIntentArmed, options.source), + }), }); } catch (error) { @@ -260,10 +261,13 @@ async function resolvePendingContinuationDecision(options) { deterministicValue: "false", aiValue: decision.char === "C" ? "true" : decision.char === "U" ? "unclear" : "false", }); + const shadowDeferred = options.decisionRuntime.config.mode === "shadow" && decision.char === "C"; writeGatewayEventAudit(options.directory, { hook: "todo-continuation-enforcer", stage: "state", - reason_code: "llm_todo_continuation_decision_recorded", + reason_code: shadowDeferred + ? "llm_todo_continuation_shadow_deferred" + : "llm_todo_continuation_decision_recorded", session_id: options.sessionId, trace_id: options.traceId, llm_decision_char: decision.char, @@ -271,18 +275,7 @@ async function resolvePendingContinuationDecision(options) { llm_decision_mode: options.decisionRuntime.config.mode, decision_source: options.source, }); - if (options.decisionRuntime.config.mode === "shadow" && decision.char === "C") { - writeGatewayEventAudit(options.directory, { - hook: "todo-continuation-enforcer", - stage: "state", - reason_code: "llm_todo_continuation_shadow_deferred", - session_id: options.sessionId, - trace_id: options.traceId, - llm_decision_char: decision.char, - llm_decision_meaning: decision.meaning, - llm_decision_mode: options.decisionRuntime.config.mode, - decision_source: options.source, - }); + if (shadowDeferred) { return false; } return decision.char === "C"; @@ -584,22 +577,10 @@ export function createTodoContinuationEnforcerHook(options) { } } if (options.stopGuard?.isStopped(sessionId)) { - writeGatewayEventAudit(directory, { - hook: "todo-continuation-enforcer", - stage: "skip", - reason_code: "todo_continuation_stop_guard", - session_id: sessionId, - }); return; } const gatewayState = loadGatewayState(directory); if (gatewayState?.activeLoop?.active === true && gatewayState.activeLoop.sessionId === sessionId) { - writeGatewayEventAudit(directory, { - hook: "todo-continuation-enforcer", - stage: "skip", - reason_code: "todo_continuation_active_loop", - session_id: sessionId, - }); return; } const now = Date.now(); @@ -673,12 +654,6 @@ export function createTodoContinuationEnforcerHook(options) { } state.pendingContinuation = pending; if (!pending || !client) { - writeGatewayEventAudit(directory, { - hook: "todo-continuation-enforcer", - stage: "skip", - reason_code: "todo_continuation_no_pending", - session_id: sessionId, - }); return; } const safety = state.pendingSource === "task_output" diff --git a/plugin/gateway-core/dist/hooks/validation-evidence-ledger/index.js b/plugin/gateway-core/dist/hooks/validation-evidence-ledger/index.js index 0f3ecfaa..ca2e56e4 100644 --- a/plugin/gateway-core/dist/hooks/validation-evidence-ledger/index.js +++ b/plugin/gateway-core/dist/hooks/validation-evidence-ledger/index.js @@ -1,5 +1,5 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js"; -import { writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; +import { buildCompactDecisionCacheKey, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js"; import { clearValidationEvidence, markValidationEvidence, } from "./evidence.js"; import { classifyValidationCommand } from "../shared/validation-command-matcher.js"; const VALIDATION_INVOCATION_ID_KEY = "validationEvidenceInvocationId"; @@ -254,7 +254,10 @@ export function createValidationEvidenceLedgerHook(options) { S: "security", N: "not_validation", }, - cacheKey: `validation-command:${command.trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "validation-command", + text: buildValidationContext(command), + }), }); if (decision.accepted) { const category = VALIDATION_CATEGORY_BY_CHAR[decision.char]; @@ -269,29 +272,20 @@ export function createValidationEvidenceLedgerHook(options) { deterministicValue: "none", aiValue: category, }); + const shadowDeferred = options.decisionRuntime.config.mode === "shadow"; writeGatewayEventAudit(options.directory, { hook: "validation-evidence-ledger", stage: "state", - reason_code: "llm_validation_command_decision_recorded", + reason_code: shadowDeferred + ? "llm_validation_command_shadow_deferred" + : "llm_validation_command_decision_recorded", session_id: sid, llm_decision_char: decision.char, llm_decision_meaning: decision.meaning, llm_decision_mode: options.decisionRuntime.config.mode, evidence: category, }); - if (options.decisionRuntime.config.mode === "shadow") { - writeGatewayEventAudit(options.directory, { - hook: "validation-evidence-ledger", - stage: "state", - reason_code: "llm_validation_command_shadow_deferred", - session_id: sid, - llm_decision_char: decision.char, - llm_decision_meaning: decision.meaning, - llm_decision_mode: options.decisionRuntime.config.mode, - evidence: category, - }); - } - else { + if (!shadowDeferred) { categories = [category]; } } diff --git a/plugin/gateway-core/dist/hooks/workflow-conformance-guard/index.js b/plugin/gateway-core/dist/hooks/workflow-conformance-guard/index.js index f891ffcd..3adc8d27 100644 --- a/plugin/gateway-core/dist/hooks/workflow-conformance-guard/index.js +++ b/plugin/gateway-core/dist/hooks/workflow-conformance-guard/index.js @@ -35,6 +35,25 @@ function protectedBranchWorktreeHint(directory) { const base = basename(directory) || "repo"; return `For repo maintenance, run \`python3 scripts/worktree_helper_command.py maintenance --directory ${directory}\` or create a throwaway worktree directly, for example: \`git worktree add -b chore/ ../${base}-maint HEAD\`.`; } +function shellQuote(value) { + return JSON.stringify(value); +} +function rerouteToMaintenanceHelper(payload, directory, sessionId, reasonCode) { + const args = payload.output?.args; + const originalCommand = typeof args?.command === "string" ? args.command.trim() : ""; + if (!args || !originalCommand) { + return false; + } + args.command = `python3 scripts/worktree_helper_command.py maintenance --directory ${shellQuote(directory)} --command ${shellQuote(originalCommand)} --json`; + writeGatewayEventAudit(directory, { + hook: "workflow-conformance-guard", + stage: "state", + reason_code: reasonCode, + session_id: sessionId, + blocked_command: originalCommand, + }); + return true; +} // Creates workflow conformance guard for commit operations on protected branches. export function createWorkflowConformanceGuardHook(options) { const protectedSet = new Set(options.protectedBranches.map((item) => item.trim()).filter(Boolean)); @@ -71,24 +90,18 @@ export function createWorkflowConformanceGuardHook(options) { const command = String(eventPayload.output?.args?.command ?? "").trim(); if (isProtectedGitMutationCommand(command)) { const sessionId = String(eventPayload.input?.sessionID ?? eventPayload.input?.sessionId ?? ""); - writeGatewayEventAudit(directory, { - hook: "workflow-conformance-guard", - stage: "skip", - reason_code: "commit_on_protected_branch_blocked", - session_id: sessionId, - }); - throw new Error(`Git commits are blocked on protected branch '${branch}'. Use a worktree feature branch.`); + if (rerouteToMaintenanceHelper(eventPayload, directory, sessionId, "commit_on_protected_branch_rerouted")) { + return; + } + throw new Error(`Git commits are blocked on protected branch '${branch}'. Use a worktree feature branch. ${protectedBranchWorktreeHint(directory)}`); } if (isAllowedProtectedShellCommand(command)) { return; } const sessionId = String(eventPayload.input?.sessionID ?? eventPayload.input?.sessionId ?? ""); - writeGatewayEventAudit(directory, { - hook: "workflow-conformance-guard", - stage: "skip", - reason_code: "bash_on_protected_branch_blocked", - session_id: sessionId, - }); + if (rerouteToMaintenanceHelper(eventPayload, directory, sessionId, "bash_on_protected_branch_rerouted")) { + return; + } throw new Error(`Bash commands on protected branch '${branch}' are limited to inspection, validation, and exact sync commands (\`git fetch\`, \`git fetch --prune\`, and \`git pull --rebase\`). Use a worktree feature branch for task mutations. ${protectedBranchWorktreeHint(directory)}`); }, }; diff --git a/plugin/gateway-core/src/config/schema.ts b/plugin/gateway-core/src/config/schema.ts index 5d4c8801..abc5cdd9 100644 --- a/plugin/gateway-core/src/config/schema.ts +++ b/plugin/gateway-core/src/config/schema.ts @@ -831,9 +831,9 @@ export const DEFAULT_GATEWAY_CONFIG: GatewayConfig = { }, longTurnWatchdog: { enabled: true, - warningThresholdMs: 300000, - toolCallWarningThreshold: 50, - reminderCooldownMs: 120000, + warningThresholdMs: 60000, + toolCallWarningThreshold: 12, + reminderCooldownMs: 60000, maxSessionStateEntries: 1024, prefix: "[Turn Watchdog]:", }, diff --git a/plugin/gateway-core/src/hooks/agent-denied-tool-enforcer/index.ts b/plugin/gateway-core/src/hooks/agent-denied-tool-enforcer/index.ts index 1f7a0a0f..53abe4eb 100644 --- a/plugin/gateway-core/src/hooks/agent-denied-tool-enforcer/index.ts +++ b/plugin/gateway-core/src/hooks/agent-denied-tool-enforcer/index.ts @@ -3,6 +3,7 @@ import type { GatewayHook } from "../registry.js" import { loadAgentMetadata } from "../shared/agent-metadata.js" import { resolveDelegationTraceId } from "../shared/delegation-trace.js" import { + buildCompactDecisionCacheKey, type LlmDecisionRuntime, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js" @@ -228,7 +229,11 @@ export function createAgentDeniedToolEnforcerHook(options: { R: "read_only_safe", N: "unclear", }, - cacheKey: `mutation:${subagentType}:${combinedText}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "mutation", + parts: [subagentType || "none"], + text: compactDecisionText(promptText, descriptionText), + }), }) if (mutationDecision.accepted && mutationDecision.char === "M") { writeDecisionComparisonAudit({ @@ -282,7 +287,11 @@ export function createAgentDeniedToolEnforcerHook(options: { A: "allowed_or_no_issue", N: "unclear", }, - cacheKey: `tool:${subagentType}:${denied.join(",")}:${combinedText}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "tool", + parts: [subagentType || "none", denied.join(",") || "none"], + text: compactDecisionText(promptText, descriptionText), + }), }) if (toolDecision.accepted && toolDecision.char === "D") { const suggestion = suggestAllowedTool(String(denied[0]), allowed) diff --git a/plugin/gateway-core/src/hooks/agent-model-resolver/index.ts b/plugin/gateway-core/src/hooks/agent-model-resolver/index.ts index 2a767120..01f9a178 100644 --- a/plugin/gateway-core/src/hooks/agent-model-resolver/index.ts +++ b/plugin/gateway-core/src/hooks/agent-model-resolver/index.ts @@ -3,6 +3,7 @@ import type { GatewayHook } from "../registry.js" import { loadAgentMetadata } from "../shared/agent-metadata.js" import { annotateDelegationMetadata, resolveDelegationTraceId } from "../shared/delegation-trace.js" import { + buildCompactDecisionCacheKey, type LlmDecisionRuntime, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js" @@ -487,7 +488,18 @@ export function createAgentModelResolverHook(options: { ), allowedChars: alphabet, decisionMeaning: buildRoutingDecisionMeaning(aiInferred.name, originalExplicitSubagent), - cacheKey: `route:${originalExplicitSubagent || "none"}:${aiInferred.name}:${combinedText}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "route", + parts: [originalExplicitSubagent || "none", aiInferred.name], + text: buildRoutingContext( + String(args.prompt ?? ""), + String(args.description ?? ""), + originalExplicitSubagent, + aiInferred.name, + aiInferred.score, + explicitScore, + ), + }), }) if (decision.accepted) { const resolvedChar = decision.char.toUpperCase() diff --git a/plugin/gateway-core/src/hooks/auto-slash-command/index.ts b/plugin/gateway-core/src/hooks/auto-slash-command/index.ts index 24fa8540..796c8de6 100644 --- a/plugin/gateway-core/src/hooks/auto-slash-command/index.ts +++ b/plugin/gateway-core/src/hooks/auto-slash-command/index.ts @@ -1,6 +1,7 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js" import type { GatewayHook } from "../registry.js" import { + buildCompactDecisionCacheKey, type LlmDecisionRuntime, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js" @@ -11,6 +12,11 @@ const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/ const EXCLUDED_COMMANDS = new Set(["ulw-loop"]) const INLINE_SLASH_TOKEN_PATTERN = /(^|\s)\/([a-zA-Z][\w-]*)\b/g const HIGH_RISK_SKIP_PATTERN = /\b(install|npm\s+install|brew\s+install|setup|configure|deploy|production)\b/i +const DETERMINISTIC_DOCTOR_PATTERN = /\b(doctor|diagnos(?:e|is|tic|tics))\b/i +const DIAGNOSTIC_CUE_PATTERN = /\b(doctor|diagnos(?:e|is|tic|tics)|health(?:\s+check)?|debug|investigat(?:e|ion)|inspect)\b/i +const ACTION_VERB_PATTERN = /\b(run|open|use|launch|start|check|perform|do|inspect|investigate|debug|review|analy[sz]e|look\s+into|tell\s+me|show\s+me|help\s+me\s+understand)\b/i +const META_DISCUSSION_SKIP_PATTERN = /\b(last session|previous session|instruction command|prompt wording|prompt text|slash doctor|auto[-\s]?slash|why did|why does|routed to|route to|activated \/doctor|triggered \/doctor|command behavior)\b/i +const INVESTIGATION_CONTEXT_PATTERN = /\b(issue|environment|state|problem|wrong|error|failure|symptom|health)\b/i const AI_AUTO_SLASH_CHAR_TO_COMMAND: Record = { D: "/doctor", } @@ -212,14 +218,21 @@ function detectSlash(prompt: string): SlashDetection { } const text = cleaned.toLowerCase() - if (text.includes("doctor") || text.includes("diagnose") || text.includes("health check")) { + if (!META_DISCUSSION_SKIP_PATTERN.test(text) && DETERMINISTIC_DOCTOR_PATTERN.test(text) && ACTION_VERB_PATTERN.test(text)) { return { slash: "/doctor", excludedExplicit: false } } return { slash: null, excludedExplicit: false } } function shouldSkipAiAutoSlash(prompt: string): boolean { - return HIGH_RISK_SKIP_PATTERN.test(prompt) + const hasInvestigativeIntent = ACTION_VERB_PATTERN.test(prompt) + const hasEligibleContext = DIAGNOSTIC_CUE_PATTERN.test(prompt) || INVESTIGATION_CONTEXT_PATTERN.test(prompt) + return ( + HIGH_RISK_SKIP_PATTERN.test(prompt) || + META_DISCUSSION_SKIP_PATTERN.test(prompt) || + !hasInvestigativeIntent || + !hasEligibleContext + ) } function shouldSkipAutoSlash(prompt: string): boolean { @@ -227,7 +240,7 @@ function shouldSkipAutoSlash(prompt: string): boolean { } function buildAiSlashInstruction(): string { - return "Classify only the sanitized user request text for diagnostics intent. D=diagnostics_or_health_check, N=not_diagnostics." + return "Classify only the sanitized user request text for explicit diagnostics intent. Return D only when the user is clearly asking to run or perform diagnostics or health checks now. Return N for meta discussion about prompts, routing, commands, past sessions, or instruction wording." } function buildAiSlashContext(prompt: string): string { @@ -284,7 +297,10 @@ export function createAutoSlashCommandHook(options: { D: "route_doctor", N: "no_slash", }, - cacheKey: `auto-slash:${prompt.trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "auto-slash", + text: normalizePromptForAi(prompt), + }), }) } catch (error) { writeGatewayEventAudit(directory, { @@ -309,27 +325,20 @@ export function createAutoSlashCommandHook(options: { deterministicValue: "none", aiValue: aiSlash ?? "none", }) + const shadowDeferred = options.decisionRuntime.config.mode === "shadow" && aiSlash writeGatewayEventAudit(directory, { hook: "auto-slash-command", stage: "state", - reason_code: "llm_auto_slash_decision_recorded", + reason_code: shadowDeferred + ? "llm_auto_slash_shadow_deferred" + : "llm_auto_slash_decision_recorded", session_id: sessionId, llm_decision_char: decision.char, llm_decision_meaning: decision.meaning, llm_decision_mode: options.decisionRuntime.config.mode, slash_command: aiSlash ?? undefined, }) - if (options.decisionRuntime.config.mode === "shadow" && aiSlash) { - writeGatewayEventAudit(directory, { - hook: "auto-slash-command", - stage: "state", - reason_code: "llm_auto_slash_shadow_deferred", - session_id: sessionId, - llm_decision_char: decision.char, - llm_decision_meaning: decision.meaning, - llm_decision_mode: options.decisionRuntime.config.mode, - slash_command: aiSlash, - }) + if (shadowDeferred) { } else { slash = aiSlash } diff --git a/plugin/gateway-core/src/hooks/context-window-monitor/index.ts b/plugin/gateway-core/src/hooks/context-window-monitor/index.ts index 1c8d333f..e32b65d8 100644 --- a/plugin/gateway-core/src/hooks/context-window-monitor/index.ts +++ b/plugin/gateway-core/src/hooks/context-window-monitor/index.ts @@ -216,12 +216,6 @@ export function createContextWindowMonitorHook(options: { }) const actualUsage = totalInputTokens / actualLimit if (actualUsage < options.warningThreshold) { - writeGatewayEventAudit(directory, { - hook: "context-window-monitor", - stage: "skip", - reason_code: "below_warning_threshold", - session_id: sessionId, - }) return } const hasPriorReminder = nextState.lastWarnedAtToolCall > 0 @@ -231,21 +225,9 @@ export function createContextWindowMonitorHook(options: { const tokenDeltaEnough = totalInputTokens - nextState.lastWarnedTokens >= options.minTokenDeltaForReminder if (!cooldownElapsed) { - writeGatewayEventAudit(directory, { - hook: "context-window-monitor", - stage: "skip", - reason_code: "reminder_cooldown_not_elapsed", - session_id: sessionId, - }) return } if (!tokenDeltaEnough) { - writeGatewayEventAudit(directory, { - hook: "context-window-monitor", - stage: "skip", - reason_code: "reminder_token_delta_too_small", - session_id: sessionId, - }) return } } diff --git a/plugin/gateway-core/src/hooks/continuation/index.ts b/plugin/gateway-core/src/hooks/continuation/index.ts index aaabaf2b..398f5637 100644 --- a/plugin/gateway-core/src/hooks/continuation/index.ts +++ b/plugin/gateway-core/src/hooks/continuation/index.ts @@ -290,11 +290,6 @@ export function createContinuationHook(options: { : options.directory const sessionId = resolveSessionId(eventPayload) if (!sessionId) { - writeGatewayEventAudit(directory, { - hook: "continuation", - stage: "skip", - reason_code: "missing_session_id", - }) return } @@ -314,29 +309,12 @@ export function createContinuationHook(options: { } } if (!state || !active || active.active !== true) { - writeGatewayEventAudit(directory, { - hook: "continuation", - stage: "skip", - reason_code: "no_active_loop", - }) return } if (options.stopGuard?.isStopped(sessionId)) { - writeGatewayEventAudit(directory, { - hook: "continuation", - stage: "skip", - reason_code: "stop_guard_active", - session_id: sessionId, - }) return } if (!sessionId || sessionId !== active.sessionId) { - writeGatewayEventAudit(directory, { - hook: "continuation", - stage: "skip", - reason_code: "session_mismatch", - has_session_id: sessionId.length > 0, - }) return } @@ -372,13 +350,6 @@ export function createContinuationHook(options: { } state.lastUpdatedAt = nowIso() saveGatewayState(directory, state) - writeGatewayEventAudit(directory, { - hook: "continuation", - stage: "skip", - reason_code: REASON_CODES.LOOP_COMPLETION_IGNORED_INCOMPLETE_RUNTIME, - session_id: sessionId, - ignored_completion_cycles: ignoredCycles, - }) } else { active.active = false state.lastUpdatedAt = nowIso() @@ -437,13 +408,6 @@ export function createContinuationHook(options: { directory, }) if (!safety.safe) { - writeGatewayEventAudit(directory, { - hook: "continuation", - stage: "skip", - reason_code: `idle_prompt_${safety.reason}`, - session_id: sessionId, - iteration: active.iteration, - }) return } const mode = options.keywordDetector?.modeForSession(sessionId) ?? null diff --git a/plugin/gateway-core/src/hooks/delegation-fallback-orchestrator/index.ts b/plugin/gateway-core/src/hooks/delegation-fallback-orchestrator/index.ts index d210c089..7f0de0c9 100644 --- a/plugin/gateway-core/src/hooks/delegation-fallback-orchestrator/index.ts +++ b/plugin/gateway-core/src/hooks/delegation-fallback-orchestrator/index.ts @@ -1,6 +1,7 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js" import type { GatewayHook } from "../registry.js" import { + buildCompactDecisionCacheKey, type LlmDecisionRuntime, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js" @@ -238,7 +239,15 @@ export function createDelegationFallbackOrchestratorHook(options: { R: "delegation_runtime_error", N: "no_match", }, - cacheKey: `delegation-failure:${subagentType}:${category}:${String(eventPayload.output.output ?? "").trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "delegation-failure", + parts: [subagentType || "none", category || "none"], + text: buildFailureContext( + String(eventPayload.output.output ?? ""), + String(args?.prompt ?? ""), + String(args?.description ?? ""), + ), + }), }) if (decision.accepted) { const aiReason = FAILURE_REASON_BY_CHAR[decision.char] ?? null diff --git a/plugin/gateway-core/src/hooks/done-proof-enforcer/index.ts b/plugin/gateway-core/src/hooks/done-proof-enforcer/index.ts index 0437ee1a..34f2a5dc 100644 --- a/plugin/gateway-core/src/hooks/done-proof-enforcer/index.ts +++ b/plugin/gateway-core/src/hooks/done-proof-enforcer/index.ts @@ -1,6 +1,6 @@ import { markerCategory, validationEvidenceStatus } from "../validation-evidence-ledger/evidence.js" import type { GatewayHook } from "../registry.js" -import type { LlmDecisionRuntime } from "../shared/llm-decision-runtime.js" +import { buildCompactDecisionCacheKey, type LlmDecisionRuntime } from "../shared/llm-decision-runtime.js" import { writeGatewayEventAudit } from "../../audit/event-audit.js" import { writeDecisionComparisonAudit } from "../shared/llm-decision-runtime.js" import { @@ -74,7 +74,10 @@ export function createDoneProofEnforcerHook(options: { context: buildMarkerContext(text), allowedChars: ["Y", "N"], decisionMeaning: { Y: `${marker}_present`, N: `${marker}_missing` }, - cacheKey: `done-proof:${marker}:${text.trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: `done-proof:${marker}`, + text: buildMarkerContext(text), + }), }) if (decision.accepted) { writeDecisionComparisonAudit({ diff --git a/plugin/gateway-core/src/hooks/long-turn-watchdog/index.ts b/plugin/gateway-core/src/hooks/long-turn-watchdog/index.ts index 0226e471..3fad12fb 100644 --- a/plugin/gateway-core/src/hooks/long-turn-watchdog/index.ts +++ b/plugin/gateway-core/src/hooks/long-turn-watchdog/index.ts @@ -1,5 +1,4 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js" -import { injectHookMessage, inspectHookMessageSafety } from "../hook-message-injector/index.js" import type { GatewayHook } from "../registry.js" import { inspectToolAfterOutputText, @@ -21,25 +20,6 @@ interface SessionDeletedPayload { properties?: { sessionID?: string; sessionId?: string; info?: { id?: string } } } -interface GatewayClient { - session?: { - messages?(args: { - path: { id: string } - query?: { directory?: string } - }): Promise<{ - data?: Array<{ - info?: { role?: string; error?: unknown; time?: { completed?: number } } - parts?: Array<{ type?: string; text?: string; synthetic?: boolean }> - }> - }> - promptAsync(args: { - path: { id: string } - body: { parts: Array<{ type: string; text: string }> } - query?: { directory?: string } - }): Promise - } -} - interface TurnState { turnStartMs: number turnCounter: number @@ -94,7 +74,7 @@ function formatDuration(ms: number): string { export function createLongTurnWatchdogHook(options: { directory: string - client?: GatewayClient + client?: unknown enabled: boolean warningThresholdMs: number toolCallWarningThreshold: number @@ -106,53 +86,14 @@ export function createLongTurnWatchdogHook(options: { const states = new Map() const now = options.now ?? (() : number => Date.now()) - async function injectVisibleProgressPulse(args: { - sessionId: string - directory: string + function visibleProgressPulseText(args: { elapsedMs: number toolCallsThisTurn: number - }): Promise { - const client = options.client?.session - if (!client) { - return - } - const safety = await inspectHookMessageSafety({ - session: client, - sessionId: args.sessionId, - directory: args.directory, - }) - if (!safety.safe && safety.reason !== "assistant_turn_incomplete") { - writeGatewayEventAudit(args.directory, { - hook: "long-turn-watchdog", - stage: "skip", - reason_code: `visible_progress_pulse_${safety.reason}`, - session_id: args.sessionId, - }) - return - } - if (!safety.safe && safety.reason === "assistant_turn_incomplete") { - writeGatewayEventAudit(args.directory, { - hook: "long-turn-watchdog", - stage: "state", - reason_code: "visible_progress_pulse_forcing_incomplete_parent_recovery", - session_id: args.sessionId, - }) - } - const injected = await injectHookMessage({ - session: client, - sessionId: args.sessionId, - directory: args.directory, - content: - `[runtime progress pulse]\nStill working in this turn after ${formatDuration(args.elapsedMs)} and ${args.toolCallsThisTurn} tool call${args.toolCallsThisTurn === 1 ? "" : "s"}. I will send the final result once I clear the current step.`, - }) - writeGatewayEventAudit(args.directory, { - hook: "long-turn-watchdog", - stage: injected ? "inject" : "skip", - reason_code: injected ? "visible_progress_pulse_injected" : "visible_progress_pulse_inject_failed", - session_id: args.sessionId, - elapsed_ms: args.elapsedMs, - tool_calls_this_turn: args.toolCallsThisTurn, - }) + }): string { + return [ + "[runtime progress pulse]", + `Still working in this turn after ${formatDuration(args.elapsedMs)} and ${args.toolCallsThisTurn} tool call${args.toolCallsThisTurn === 1 ? "" : "s"}. I will send the final result once I clear the current step.`, + ].join("\n") } return { @@ -222,41 +163,17 @@ export function createLongTurnWatchdogHook(options: { const { text, channel } = inspectToolAfterOutputText(eventPayload.output?.output) if (!text) { - writeGatewayEventAudit(directory, { - hook: "long-turn-watchdog", - stage: "skip", - reason_code: "output_not_text", - session_id: sessionId, - }) return } const elapsedMs = Math.max(0, now() - state.turnStartMs) const toolCallThreshold = Math.max(1, Math.floor(options.toolCallWarningThreshold)) if (elapsedMs < options.warningThresholdMs && state.toolCallsThisTurn < toolCallThreshold) { - writeGatewayEventAudit(directory, { - hook: "long-turn-watchdog", - stage: "skip", - reason_code: "below_threshold", - session_id: sessionId, - elapsed_ms: elapsedMs, - warning_threshold_ms: options.warningThresholdMs, - tool_calls_this_turn: state.toolCallsThisTurn, - tool_call_warning_threshold: toolCallThreshold, - }) return } const sameTurnWarned = state.warnedTurnCounter === state.turnCounter if (sameTurnWarned) { - writeGatewayEventAudit(directory, { - hook: "long-turn-watchdog", - stage: "skip", - reason_code: "already_warned_for_turn", - session_id: sessionId, - elapsed_ms: elapsedMs, - turn_counter: state.turnCounter, - }) return } if ( @@ -264,34 +181,25 @@ export function createLongTurnWatchdogHook(options: { state.lastWarnedAtMs > 0 && now() - state.lastWarnedAtMs < options.reminderCooldownMs ) { - writeGatewayEventAudit(directory, { - hook: "long-turn-watchdog", - stage: "skip", - reason_code: "cooldown_active", - session_id: sessionId, - elapsed_ms: elapsedMs, - reminder_cooldown_ms: options.reminderCooldownMs, - }) return } const prefix = options.prefix.trim() || "[Turn Watchdog]:" const warning = `${prefix} Long turn detected (${formatDuration(elapsedMs)} since last user message; threshold ${formatDuration(options.warningThresholdMs)}).` const heartbeat = `${prefix} Still working - collecting results before the final reply.` - const amended = `${text}\n\n${warning}\n${heartbeat}` + const shouldAppendPulse = state.toolCallsThisTurn >= toolCallThreshold + const pulse = visibleProgressPulseText({ + elapsedMs, + toolCallsThisTurn: state.toolCallsThisTurn, + }) + const amended = shouldAppendPulse + ? `${text}\n\n${warning}\n${heartbeat}\n\n${pulse}` + : `${text}\n\n${warning}\n${heartbeat}` if (!writeToolAfterOutputText(eventPayload.output?.output, amended, channel)) { if (typeof eventPayload.output === "object" && eventPayload.output) { eventPayload.output.output = amended } } - if (state.toolCallsThisTurn >= toolCallThreshold) { - await injectVisibleProgressPulse({ - sessionId, - directory, - elapsedMs, - toolCallsThisTurn: state.toolCallsThisTurn, - }) - } state.warnedTurnCounter = state.turnCounter state.lastWarnedAtMs = now() @@ -302,6 +210,7 @@ export function createLongTurnWatchdogHook(options: { session_id: sessionId, elapsed_ms: elapsedMs, tool_calls_this_turn: state.toolCallsThisTurn, + visible_progress_pulse: shouldAppendPulse, tool_call_warning_threshold: toolCallThreshold, warning_threshold_ms: options.warningThresholdMs, turn_started_at: new Date(state.turnStartMs).toISOString(), diff --git a/plugin/gateway-core/src/hooks/pr-body-evidence-guard/index.ts b/plugin/gateway-core/src/hooks/pr-body-evidence-guard/index.ts index b72fe1ea..6a512270 100644 --- a/plugin/gateway-core/src/hooks/pr-body-evidence-guard/index.ts +++ b/plugin/gateway-core/src/hooks/pr-body-evidence-guard/index.ts @@ -1,6 +1,7 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js" import type { GatewayHook } from "../registry.js" import { + buildCompactDecisionCacheKey, type LlmDecisionRuntime, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js" @@ -109,7 +110,10 @@ export function createPrBodyEvidenceGuardHook(options: { context: buildSectionContext(body), allowedChars: ["Y", "N"], decisionMeaning: { Y: "summary_present", N: "summary_missing" }, - cacheKey: `pr-body-summary:${body.trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "pr-body-summary", + text: buildSectionContext(body), + }), }) if (decision.accepted) { writeDecisionComparisonAudit({ @@ -155,7 +159,10 @@ export function createPrBodyEvidenceGuardHook(options: { context: buildSectionContext(body), allowedChars: ["Y", "N"], decisionMeaning: { Y: "validation_present", N: "validation_missing" }, - cacheKey: `pr-body-validation:${body.trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "pr-body-validation", + text: buildSectionContext(body), + }), }) if (decision.accepted) { writeDecisionComparisonAudit({ diff --git a/plugin/gateway-core/src/hooks/preemptive-compaction/index.ts b/plugin/gateway-core/src/hooks/preemptive-compaction/index.ts index 11f97a9a..5215b1ee 100644 --- a/plugin/gateway-core/src/hooks/preemptive-compaction/index.ts +++ b/plugin/gateway-core/src/hooks/preemptive-compaction/index.ts @@ -197,21 +197,9 @@ export function createPreemptiveCompactionHook(options: { const tokenDeltaEnough = totalInputTokens - nextState.lastCompactedTokens >= options.minTokenDeltaForCompaction if (!cooldownElapsed) { - writeGatewayEventAudit(directory, { - hook: "preemptive-compaction", - stage: "skip", - reason_code: "compaction_cooldown_not_elapsed", - session_id: sessionId, - }) return } if (!tokenDeltaEnough) { - writeGatewayEventAudit(directory, { - hook: "preemptive-compaction", - stage: "skip", - reason_code: "compaction_token_delta_too_small", - session_id: sessionId, - }) return } } diff --git a/plugin/gateway-core/src/hooks/primary-worktree-guard/index.ts b/plugin/gateway-core/src/hooks/primary-worktree-guard/index.ts index e0f079e1..5b8c29c8 100644 --- a/plugin/gateway-core/src/hooks/primary-worktree-guard/index.ts +++ b/plugin/gateway-core/src/hooks/primary-worktree-guard/index.ts @@ -42,6 +42,10 @@ function stripQuotes(token: string): string { return token.replace(/^['"]|['"]$/g, "") } +function shellQuote(value: string): string { + return JSON.stringify(value) +} + const GIT_PREFIX = String.raw`(?:^|&&|\|\||;)\s*(?:env\s+(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)*)?(?:(?:[^\s;&|]*/)?rtk\s+)?(?:[^\s;&|]*/)?git\s+` function matchBranchTarget(command: string, pattern: RegExp): string | null { @@ -94,6 +98,22 @@ export function createPrimaryWorktreeGuardHook(options: { blockBranchSwitches: boolean }): GatewayHook { const allowedBranches = new Set(options.allowedBranches.map((item) => item.trim()).filter(Boolean)) + function rerouteToMaintenanceHelper(payload: ToolBeforePayload, directory: string, sessionId: string, reasonCode: string): boolean { + const args = payload.output?.args + const originalCommand = typeof args?.command === "string" ? args.command.trim() : "" + if (!args || !originalCommand) { + return false + } + args.command = `python3 scripts/worktree_helper_command.py maintenance --directory ${shellQuote(directory)} --command ${shellQuote(originalCommand)} --json` + writeGatewayEventAudit(directory, { + hook: "primary-worktree-guard", + stage: "state", + reason_code: reasonCode, + session_id: sessionId, + blocked_command: originalCommand, + }) + return true + } return { id: "primary-worktree-guard", priority: 689, @@ -142,12 +162,9 @@ export function createPrimaryWorktreeGuardHook(options: { if (isAllowedProtectedShellCommand(command)) { return } - writeGatewayEventAudit(directory, { - hook: "primary-worktree-guard", - stage: "skip", - reason_code: "bash_in_primary_worktree_blocked", - session_id: sessionId, - }) + if (rerouteToMaintenanceHelper(eventPayload, directory, sessionId, "bash_in_primary_worktree_rerouted")) { + return + } throw new Error( "Bash commands in the primary project folder are limited to inspection, validation, and exact default-branch sync commands (`git fetch`, `git fetch --prune`, and `git pull --rebase`). Create or use a dedicated git worktree branch for task mutations." ) diff --git a/plugin/gateway-core/src/hooks/provider-error-classifier/index.ts b/plugin/gateway-core/src/hooks/provider-error-classifier/index.ts index 91e5cfca..5e7e6ac9 100644 --- a/plugin/gateway-core/src/hooks/provider-error-classifier/index.ts +++ b/plugin/gateway-core/src/hooks/provider-error-classifier/index.ts @@ -3,6 +3,7 @@ import { injectHookMessage } from "../hook-message-injector/index.js" import type { GatewayHook } from "../registry.js" import { classifyProviderRetryReason, isContextOverflowNonRetryable } from "../shared/provider-retry-reason.js" import { + buildCompactDecisionCacheKey, type LlmDecisionRuntime, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js" @@ -180,7 +181,10 @@ export function createProviderErrorClassifierHook(options: { O: "provider_overloaded", N: "not_classified", }, - cacheKey: `provider-error:${text.trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "provider-error", + text: buildAiContext(text), + }), }) if (decision.skippedReason === "max_concurrency_reached" || decision.skippedReason === "runtime_cooldown") { if (session && sessionId) { @@ -216,26 +220,19 @@ export function createProviderErrorClassifierHook(options: { deterministicValue: "none", aiValue: classification, }) + const shadowDeferred = options.decisionRuntime.config.mode === "shadow" writeGatewayEventAudit(resolveDirectory(eventPayload, options.directory), { hook: "provider-error-classifier", stage: "state", - reason_code: "llm_provider_error_decision_recorded", + reason_code: shadowDeferred + ? "llm_provider_error_shadow_deferred" + : "llm_provider_error_decision_recorded", session_id: sessionId, llm_decision_char: decision.char, llm_decision_meaning: decision.meaning, llm_decision_mode: options.decisionRuntime.config.mode, }) - if (options.decisionRuntime.config.mode === "shadow") { - writeGatewayEventAudit(resolveDirectory(eventPayload, options.directory), { - hook: "provider-error-classifier", - stage: "state", - reason_code: "llm_provider_error_shadow_deferred", - session_id: sessionId, - llm_decision_char: decision.char, - llm_decision_meaning: decision.meaning, - llm_decision_mode: options.decisionRuntime.config.mode, - }) - } else { + if (!shadowDeferred) { outcome = { classification, reason: `llm:${decision.meaning || decision.char}`, diff --git a/plugin/gateway-core/src/hooks/provider-token-limit-recovery/index.ts b/plugin/gateway-core/src/hooks/provider-token-limit-recovery/index.ts index 38a6a4e2..896a480d 100644 --- a/plugin/gateway-core/src/hooks/provider-token-limit-recovery/index.ts +++ b/plugin/gateway-core/src/hooks/provider-token-limit-recovery/index.ts @@ -181,12 +181,6 @@ export function createProviderTokenLimitRecoveryHook(options: { directory, }) if (!safety.safe) { - writeGatewayEventAudit(directory, { - hook: "provider-token-limit-recovery", - stage: "skip", - reason_code: `token_limit_recovery_${safety.reason}`, - session_id: sessionId, - }) return } const injected = await injectHookMessage({ diff --git a/plugin/gateway-core/src/hooks/session-recovery/index.ts b/plugin/gateway-core/src/hooks/session-recovery/index.ts index 50cdb7af..42ea835e 100644 --- a/plugin/gateway-core/src/hooks/session-recovery/index.ts +++ b/plugin/gateway-core/src/hooks/session-recovery/index.ts @@ -276,6 +276,49 @@ function looksLikeSilentQuestionStallFromHistory(messages: Array<{ return { matched: true, tool } } +function looksLikeIncompleteAssistantTailFromHistory(messages: Array<{ + info?: { role?: string; error?: unknown; time?: { completed?: number } } + parts?: Array<{ + type?: string + text?: string + synthetic?: boolean + tool?: string + state?: { status?: string } + }> +}>): { matched: boolean; tool: string } { + const message = messages.at(-1) + if (message?.info?.role !== "assistant") { + return { matched: false, tool: "" } + } + const errored = message.info?.error !== undefined && message.info?.error !== null + const completed = Number.isFinite(Number(message.info?.time?.completed ?? Number.NaN)) + if (errored || completed) { + return { matched: false, tool: "" } + } + const parts = Array.isArray(message.parts) ? message.parts : [] + if ( + parts.some((part) => { + const toolName = String(part?.tool ?? "").trim().toLowerCase() + return toolName === "question" || toolName === "askuserquestion" + }) + ) { + return { matched: false, tool: "" } + } + const lastToolPart = [...parts].reverse().find((part) => part?.type === "tool") + const tool = String(lastToolPart?.tool ?? "").trim().toLowerCase() + if (!tool || tool === "question" || tool === "askuserquestion") { + return { matched: false, tool: "" } + } + const hasVisibleText = parts.some( + (part) => + part?.type === "text" && + typeof part.text === "string" && + part.text.trim() && + !part.synthetic, + ) + return { matched: !hasVisibleText, tool } +} + async function injectRecoveryMessage(args: { session: NonNullable sessionId: string @@ -429,12 +472,6 @@ export function createSessionRecoveryHook(options: { if (pendingQuestion) { const ageMs = Math.max(0, nowMs() - Math.max(pendingQuestion.startedAt, pendingQuestion.lastUpdatedAt)) if (ageMs < STALE_QUESTION_PREVENTION_MS) { - writeGatewayEventAudit(directory, { - hook: "session-recovery", - stage: "skip", - reason_code: "stale_question_tool_prevention_not_stale", - session_id: sessionId, - }) return } } @@ -466,6 +503,25 @@ export function createSessionRecoveryHook(options: { } const silentQuestion = looksLikeSilentQuestionStallFromHistory(messages) if (!silentQuestion.matched) { + const incompleteTail = looksLikeIncompleteAssistantTailFromHistory(messages) + if (!incompleteTail.matched) { + return + } + recoveringSessions.add(sessionId) + try { + await injectRecoveryMessage({ + session: client, + sessionId, + directory, + hook: "session-recovery", + reasonCode: "incomplete_assistant_tail_recovery", + allowIncompleteAssistantTurn: true, + content: + `[incomplete assistant turn detected during idle - continuing now]\nlast_tool: ${incompleteTail.tool}`, + }) + } finally { + recoveringSessions.delete(sessionId) + } return } recoveringSessions.add(sessionId) diff --git a/plugin/gateway-core/src/hooks/shared/llm-decision-runtime.ts b/plugin/gateway-core/src/hooks/shared/llm-decision-runtime.ts index ee5359ba..721fa0d5 100644 --- a/plugin/gateway-core/src/hooks/shared/llm-decision-runtime.ts +++ b/plugin/gateway-core/src/hooks/shared/llm-decision-runtime.ts @@ -418,16 +418,6 @@ export function createLlmDecisionRuntime(options: RuntimeOptions): LlmDecisionRu } } if (process.env[LLM_DECISION_CHILD_ENV] === "1") { - writeGatewayEventAudit(options.directory, { - hook: request.hookId, - stage: "skip", - reason_code: "llm_decision_nested_child_skipped", - session_id: request.sessionId, - trace_id: request.traceId, - template_id: request.templateId, - decision_mode: config.mode, - model: config.model, - }) return { ...baseResult, durationMs: Date.now() - start, @@ -442,17 +432,6 @@ export function createLlmDecisionRuntime(options: RuntimeOptions): LlmDecisionRu } } if (cooldownUntil > Date.now()) { - writeGatewayEventAudit(options.directory, { - hook: request.hookId, - stage: "skip", - reason_code: "llm_decision_runtime_cooldown", - session_id: request.sessionId, - trace_id: request.traceId, - template_id: request.templateId, - decision_mode: config.mode, - model: config.model, - duration_ms: String(Date.now() - start), - }) return { ...baseResult, durationMs: Date.now() - start, @@ -460,17 +439,6 @@ export function createLlmDecisionRuntime(options: RuntimeOptions): LlmDecisionRu } } if (activeDecisions >= config.maxConcurrentDecisions) { - writeGatewayEventAudit(options.directory, { - hook: request.hookId, - stage: "skip", - reason_code: "llm_decision_max_concurrency", - session_id: request.sessionId, - trace_id: request.traceId, - template_id: request.templateId, - decision_mode: config.mode, - model: config.model, - duration_ms: String(Date.now() - start), - }) return { ...baseResult, durationMs: Date.now() - start, diff --git a/plugin/gateway-core/src/hooks/subagent-lifecycle-supervisor/index.ts b/plugin/gateway-core/src/hooks/subagent-lifecycle-supervisor/index.ts index a543546b..2101fb97 100644 --- a/plugin/gateway-core/src/hooks/subagent-lifecycle-supervisor/index.ts +++ b/plugin/gateway-core/src/hooks/subagent-lifecycle-supervisor/index.ts @@ -608,15 +608,6 @@ export function createSubagentLifecycleSupervisorHook(options: { } } if (ageMs < options.staleRunningMs) { - writeGatewayEventAudit(directory, { - hook: "subagent-lifecycle-supervisor", - stage: "skip", - reason_code: "subagent_lifecycle_child_idle_not_stale", - session_id: link.parentSessionId, - child_run_id: runningState.childRunId, - trace_id: runningState.traceId, - subagent_type: runningState.subagentType, - }) return } const staleRecovered = finalizeLinkedLifecycle({ diff --git a/plugin/gateway-core/src/hooks/task-resume-info/index.ts b/plugin/gateway-core/src/hooks/task-resume-info/index.ts index 9bbcec17..e6a42aaa 100644 --- a/plugin/gateway-core/src/hooks/task-resume-info/index.ts +++ b/plugin/gateway-core/src/hooks/task-resume-info/index.ts @@ -111,27 +111,18 @@ async function resolveSemanticHints(options: { deterministicValue: "none", aiValue: decision.char, }) + const shadowDeferred = options.decisionRuntime.config.mode === "shadow" && (addContinuation || addVerification) writeGatewayEventAudit(options.directory, { hook: "task-resume-info", stage: "state", - reason_code: "llm_task_resume_decision_recorded", + reason_code: shadowDeferred ? "llm_task_resume_shadow_deferred" : "llm_task_resume_decision_recorded", session_id: options.sessionId, llm_decision_char: decision.char, llm_decision_meaning: decision.meaning, llm_decision_mode: options.decisionRuntime.config.mode, resume_target: options.resumeTarget || undefined, }) - if (options.decisionRuntime.config.mode === "shadow" && (addContinuation || addVerification)) { - writeGatewayEventAudit(options.directory, { - hook: "task-resume-info", - stage: "state", - reason_code: "llm_task_resume_shadow_deferred", - session_id: options.sessionId, - llm_decision_char: decision.char, - llm_decision_meaning: decision.meaning, - llm_decision_mode: options.decisionRuntime.config.mode, - resume_target: options.resumeTarget || undefined, - }) + if (shadowDeferred) { return { addContinuation: false, addVerification: false } } return { addContinuation, addVerification } diff --git a/plugin/gateway-core/src/hooks/todo-continuation-enforcer/index.ts b/plugin/gateway-core/src/hooks/todo-continuation-enforcer/index.ts index ef4a6eec..b4776580 100644 --- a/plugin/gateway-core/src/hooks/todo-continuation-enforcer/index.ts +++ b/plugin/gateway-core/src/hooks/todo-continuation-enforcer/index.ts @@ -2,8 +2,7 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js" import { loadGatewayState } from "../../state/storage.js" import { injectHookMessage, inspectHookMessageSafety } from "../hook-message-injector/index.js" import type { GatewayHook } from "../registry.js" -import type { LlmDecisionRuntime } from "../shared/llm-decision-runtime.js" -import { writeDecisionComparisonAudit } from "../shared/llm-decision-runtime.js" +import { buildCompactDecisionCacheKey, type LlmDecisionRuntime, writeDecisionComparisonAudit } from "../shared/llm-decision-runtime.js" import type { StopContinuationGuard } from "../stop-continuation-guard/index.js" interface GatewayClient { @@ -109,10 +108,6 @@ interface SessionState { lastTraceId?: string } -function compactDecisionCacheKey(text: string): string { - return text.trim().toLowerCase().replace(/\s+/g, " ").slice(0, 240) -} - const CONTINUE_LOOP_MARKER = "" const TODO_CONTINUATION_PROMPT = [ "[SYSTEM DIRECTIVE: TODO CONTINUATION]", @@ -369,7 +364,11 @@ async function resolvePendingContinuationDecision(options: { S: "no_pending", U: "unclear", }, - cacheKey: `todo-continuation:${options.source}:${options.continueIntentArmed ? "armed" : "unarmed"}:${compactDecisionCacheKey(options.text)}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "todo-continuation", + parts: [options.source, options.continueIntentArmed ? "armed" : "unarmed"], + text: buildContinuationContext(options.text, options.continueIntentArmed, options.source), + }), }) } catch (error) { writeGatewayEventAudit(options.directory, { @@ -409,10 +408,13 @@ async function resolvePendingContinuationDecision(options: { deterministicValue: "false", aiValue: decision.char === "C" ? "true" : decision.char === "U" ? "unclear" : "false", }) + const shadowDeferred = options.decisionRuntime.config.mode === "shadow" && decision.char === "C" writeGatewayEventAudit(options.directory, { hook: "todo-continuation-enforcer", stage: "state", - reason_code: "llm_todo_continuation_decision_recorded", + reason_code: shadowDeferred + ? "llm_todo_continuation_shadow_deferred" + : "llm_todo_continuation_decision_recorded", session_id: options.sessionId, trace_id: options.traceId, llm_decision_char: decision.char, @@ -420,18 +422,7 @@ async function resolvePendingContinuationDecision(options: { llm_decision_mode: options.decisionRuntime.config.mode, decision_source: options.source, }) - if (options.decisionRuntime.config.mode === "shadow" && decision.char === "C") { - writeGatewayEventAudit(options.directory, { - hook: "todo-continuation-enforcer", - stage: "state", - reason_code: "llm_todo_continuation_shadow_deferred", - session_id: options.sessionId, - trace_id: options.traceId, - llm_decision_char: decision.char, - llm_decision_meaning: decision.meaning, - llm_decision_mode: options.decisionRuntime.config.mode, - decision_source: options.source, - }) + if (shadowDeferred) { return false } return decision.char === "C" @@ -767,22 +758,10 @@ export function createTodoContinuationEnforcerHook(options: { } } if (options.stopGuard?.isStopped(sessionId)) { - writeGatewayEventAudit(directory, { - hook: "todo-continuation-enforcer", - stage: "skip", - reason_code: "todo_continuation_stop_guard", - session_id: sessionId, - }) return } const gatewayState = loadGatewayState(directory) if (gatewayState?.activeLoop?.active === true && gatewayState.activeLoop.sessionId === sessionId) { - writeGatewayEventAudit(directory, { - hook: "todo-continuation-enforcer", - stage: "skip", - reason_code: "todo_continuation_active_loop", - session_id: sessionId, - }) return } @@ -861,12 +840,6 @@ export function createTodoContinuationEnforcerHook(options: { } state.pendingContinuation = pending if (!pending || !client) { - writeGatewayEventAudit(directory, { - hook: "todo-continuation-enforcer", - stage: "skip", - reason_code: "todo_continuation_no_pending", - session_id: sessionId, - }) return } diff --git a/plugin/gateway-core/src/hooks/validation-evidence-ledger/index.ts b/plugin/gateway-core/src/hooks/validation-evidence-ledger/index.ts index b23a28b3..fe89e77a 100644 --- a/plugin/gateway-core/src/hooks/validation-evidence-ledger/index.ts +++ b/plugin/gateway-core/src/hooks/validation-evidence-ledger/index.ts @@ -1,6 +1,7 @@ import { writeGatewayEventAudit } from "../../audit/event-audit.js" import type { GatewayHook } from "../registry.js" import { + buildCompactDecisionCacheKey, type LlmDecisionRuntime, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js" @@ -323,7 +324,10 @@ export function createValidationEvidenceLedgerHook(options: { S: "security", N: "not_validation", }, - cacheKey: `validation-command:${command.trim().toLowerCase()}`, + cacheKey: buildCompactDecisionCacheKey({ + prefix: "validation-command", + text: buildValidationContext(command), + }), }) if (decision.accepted) { const category = VALIDATION_CATEGORY_BY_CHAR[decision.char] @@ -338,28 +342,20 @@ export function createValidationEvidenceLedgerHook(options: { deterministicValue: "none", aiValue: category, }) + const shadowDeferred = options.decisionRuntime.config.mode === "shadow" writeGatewayEventAudit(options.directory, { hook: "validation-evidence-ledger", stage: "state", - reason_code: "llm_validation_command_decision_recorded", + reason_code: shadowDeferred + ? "llm_validation_command_shadow_deferred" + : "llm_validation_command_decision_recorded", session_id: sid, llm_decision_char: decision.char, llm_decision_meaning: decision.meaning, llm_decision_mode: options.decisionRuntime.config.mode, evidence: category, }) - if (options.decisionRuntime.config.mode === "shadow") { - writeGatewayEventAudit(options.directory, { - hook: "validation-evidence-ledger", - stage: "state", - reason_code: "llm_validation_command_shadow_deferred", - session_id: sid, - llm_decision_char: decision.char, - llm_decision_meaning: decision.meaning, - llm_decision_mode: options.decisionRuntime.config.mode, - evidence: category, - }) - } else { + if (!shadowDeferred) { categories = [category] } } diff --git a/plugin/gateway-core/src/hooks/workflow-conformance-guard/index.ts b/plugin/gateway-core/src/hooks/workflow-conformance-guard/index.ts index e529e866..935f47df 100644 --- a/plugin/gateway-core/src/hooks/workflow-conformance-guard/index.ts +++ b/plugin/gateway-core/src/hooks/workflow-conformance-guard/index.ts @@ -61,6 +61,27 @@ function protectedBranchWorktreeHint(directory: string): string { return `For repo maintenance, run \`python3 scripts/worktree_helper_command.py maintenance --directory ${directory}\` or create a throwaway worktree directly, for example: \`git worktree add -b chore/ ../${base}-maint HEAD\`.` } +function shellQuote(value: string): string { + return JSON.stringify(value) +} + +function rerouteToMaintenanceHelper(payload: ToolBeforePayload, directory: string, sessionId: string, reasonCode: string): boolean { + const args = payload.output?.args + const originalCommand = typeof args?.command === "string" ? args.command.trim() : "" + if (!args || !originalCommand) { + return false + } + args.command = `python3 scripts/worktree_helper_command.py maintenance --directory ${shellQuote(directory)} --command ${shellQuote(originalCommand)} --json` + writeGatewayEventAudit(directory, { + hook: "workflow-conformance-guard", + stage: "state", + reason_code: reasonCode, + session_id: sessionId, + blocked_command: originalCommand, + }) + return true +} + // Creates workflow conformance guard for commit operations on protected branches. export function createWorkflowConformanceGuardHook(options: { directory: string @@ -102,24 +123,18 @@ export function createWorkflowConformanceGuardHook(options: { const command = String(eventPayload.output?.args?.command ?? "").trim() if (isProtectedGitMutationCommand(command)) { const sessionId = String(eventPayload.input?.sessionID ?? eventPayload.input?.sessionId ?? "") - writeGatewayEventAudit(directory, { - hook: "workflow-conformance-guard", - stage: "skip", - reason_code: "commit_on_protected_branch_blocked", - session_id: sessionId, - }) - throw new Error(`Git commits are blocked on protected branch '${branch}'. Use a worktree feature branch.`) + if (rerouteToMaintenanceHelper(eventPayload, directory, sessionId, "commit_on_protected_branch_rerouted")) { + return + } + throw new Error(`Git commits are blocked on protected branch '${branch}'. Use a worktree feature branch. ${protectedBranchWorktreeHint(directory)}`) } if (isAllowedProtectedShellCommand(command)) { return } const sessionId = String(eventPayload.input?.sessionID ?? eventPayload.input?.sessionId ?? "") - writeGatewayEventAudit(directory, { - hook: "workflow-conformance-guard", - stage: "skip", - reason_code: "bash_on_protected_branch_blocked", - session_id: sessionId, - }) + if (rerouteToMaintenanceHelper(eventPayload, directory, sessionId, "bash_on_protected_branch_rerouted")) { + return + } throw new Error( `Bash commands on protected branch '${branch}' are limited to inspection, validation, and exact sync commands (\`git fetch\`, \`git fetch --prune\`, and \`git pull --rebase\`). Use a worktree feature branch for task mutations. ${protectedBranchWorktreeHint(directory)}` ) diff --git a/plugin/gateway-core/test/auto-slash-command-hook.test.mjs b/plugin/gateway-core/test/auto-slash-command-hook.test.mjs index 1dbec2fb..69809120 100644 --- a/plugin/gateway-core/test/auto-slash-command-hook.test.mjs +++ b/plugin/gateway-core/test/auto-slash-command-hook.test.mjs @@ -223,6 +223,33 @@ test("auto-slash-command does not rewrite high-risk install prompts", async () = } }) +test("auto-slash-command skips meta discussion about doctor routing", async () => { + const hook = createAutoSlashCommandHook({ + directory: process.cwd(), + enabled: true, + decisionRuntime: { + config: { mode: "assist" }, + async decide() { + throw new Error("should not be called") + }, + }, + }) + + const output = { + parts: [{ type: "text", text: "can you review why the instruction command in the last session activated /doctor" }], + } + await hook.event("chat.message", { + properties: { + sessionID: "session-auto-slash-meta-skip", + prompt: "can you review why the instruction command in the last session activated /doctor", + }, + output, + directory: process.cwd(), + }) + + assert.equal(output.parts[0].text, "can you review why the instruction command in the last session activated /doctor") +}) + test("auto-slash-command does not fallback-map excluded explicit slash", async () => { const directory = mkdtempSync(join(tmpdir(), "gateway-auto-slash-")) try { @@ -555,17 +582,17 @@ test("auto-slash-command shadow mode records but does not rewrite ambiguous prom }, }) const output = { - parts: [{ type: "text", text: "can you inspect the environment health and tell me what's wrong" }], + parts: [{ type: "text", text: "can you inspect this issue and help me understand the environment state" }], } await hook.event("chat.message", { properties: { sessionID: "session-auto-slash-shadow-1", - prompt: "can you inspect the environment health and tell me what's wrong", + prompt: "can you inspect this issue and help me understand the environment state", }, output, directory: process.cwd(), }) - assert.equal(output.parts[0].text, "can you inspect the environment health and tell me what's wrong") + assert.equal(output.parts[0].text, "can you inspect this issue and help me understand the environment state") }) test("auto-slash-command sanitizes chat-role contamination before AI classification", async () => { diff --git a/plugin/gateway-core/test/config-load.test.mjs b/plugin/gateway-core/test/config-load.test.mjs index 5babb2a9..707e662d 100644 --- a/plugin/gateway-core/test/config-load.test.mjs +++ b/plugin/gateway-core/test/config-load.test.mjs @@ -42,9 +42,9 @@ test("loadGatewayConfig keeps defaults for new safety guard knobs", () => { assert.equal(config.globalProcessPressure.selfLowLabel, "LOW") assert.equal(config.globalProcessPressure.selfAppendMarker, true) assert.equal(config.longTurnWatchdog.enabled, true) - assert.equal(config.longTurnWatchdog.warningThresholdMs, 300000) - assert.equal(config.longTurnWatchdog.toolCallWarningThreshold, 50) - assert.equal(config.longTurnWatchdog.reminderCooldownMs, 120000) + assert.equal(config.longTurnWatchdog.warningThresholdMs, 60000) + assert.equal(config.longTurnWatchdog.toolCallWarningThreshold, 12) + assert.equal(config.longTurnWatchdog.reminderCooldownMs, 60000) assert.equal(config.longTurnWatchdog.maxSessionStateEntries, 1024) assert.equal(config.longTurnWatchdog.prefix, "[Turn Watchdog]:") assert.equal(config.notifyEvents.enabled, true) @@ -259,9 +259,9 @@ test("loadGatewayConfig normalizes invalid guard marker and verbosity values", ( assert.equal(config.globalProcessPressure.selfLowLabel, "LOW") assert.equal(config.globalProcessPressure.selfAppendMarker, true) assert.equal(config.longTurnWatchdog.enabled, true) - assert.equal(config.longTurnWatchdog.warningThresholdMs, 300000) - assert.equal(config.longTurnWatchdog.toolCallWarningThreshold, 50) - assert.equal(config.longTurnWatchdog.reminderCooldownMs, 120000) + assert.equal(config.longTurnWatchdog.warningThresholdMs, 60000) + assert.equal(config.longTurnWatchdog.toolCallWarningThreshold, 12) + assert.equal(config.longTurnWatchdog.reminderCooldownMs, 60000) assert.equal(config.longTurnWatchdog.maxSessionStateEntries, 1024) assert.equal(config.longTurnWatchdog.prefix, "[Turn Watchdog]:") assert.equal(config.notifyEvents.enabled, true) diff --git a/plugin/gateway-core/test/long-turn-watchdog-hook.test.mjs b/plugin/gateway-core/test/long-turn-watchdog-hook.test.mjs index b7ca3cdd..471f2755 100644 --- a/plugin/gateway-core/test/long-turn-watchdog-hook.test.mjs +++ b/plugin/gateway-core/test/long-turn-watchdog-hook.test.mjs @@ -173,25 +173,8 @@ test("long-turn-watchdog warns after repeated tool calls even before time thresh test("long-turn-watchdog injects visible progress pulse when tool-only turn stalls", async () => { let currentMs = 0 - let promptCalls = 0 const hook = createLongTurnWatchdogHook({ directory: process.cwd(), - client: { - session: { - async messages() { - return { - data: [ - { - info: { role: "assistant", time: {} }, - }, - ], - } - }, - async promptAsync() { - promptCalls += 1 - }, - }, - }, enabled: true, warningThresholdMs: 1000, toolCallWarningThreshold: 1, @@ -215,7 +198,8 @@ test("long-turn-watchdog injects visible progress pulse when tool-only turn stal directory: process.cwd(), }) - assert.equal(promptCalls, 1) + assert.match(output.output, /\[runtime progress pulse\]/) + assert.match(output.output, /Still working in this turn after 1\.5s and 1 tool call/) }) test("long-turn-watchdog honors tool-call threshold from plugin config", async () => { diff --git a/plugin/gateway-core/test/primary-worktree-guard-hook.test.mjs b/plugin/gateway-core/test/primary-worktree-guard-hook.test.mjs index 3343d2c9..931e208b 100644 --- a/plugin/gateway-core/test/primary-worktree-guard-hook.test.mjs +++ b/plugin/gateway-core/test/primary-worktree-guard-hook.test.mjs @@ -256,7 +256,7 @@ test("primary-worktree-guard allows apply_patch targeting a linked worktree from } }) -test("primary-worktree-guard blocks mutating bash commands in the primary worktree", async () => { +test("primary-worktree-guard reroutes mutating bash commands in the primary worktree", async () => { const directory = mkdtempSync(join(tmpdir(), "gateway-primary-worktree-")) try { execSync("git init -b main", { cwd: directory, stdio: ["ignore", "pipe", "pipe"] }) @@ -276,29 +276,26 @@ test("primary-worktree-guard blocks mutating bash commands in the primary worktr }, }) - await assert.rejects( - plugin["tool.execute.before"]( - { tool: "bash", sessionID: "session-primary-bash-mutate" }, - { args: { command: "echo hi > file.txt" } } - ), - /limited to inspection, validation, and exact default-branch sync commands/ + const mutatePayload = { args: { command: "echo hi > file.txt" } } + await plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-primary-bash-mutate" }, + mutatePayload ) + assert.match(mutatePayload.args.command, /python3 scripts\/worktree_helper_command\.py maintenance --directory/) - await assert.rejects( - plugin["tool.execute.before"]( - { tool: "bash", sessionID: "session-primary-gh-api" }, - { args: { command: "gh api -X POST repos/foo/bar/issues" } } - ), - /limited to inspection, validation, and exact default-branch sync commands/ + const ghPayload = { args: { command: "gh api -X POST repos/foo/bar/issues" } } + await plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-primary-gh-api" }, + ghPayload ) + assert.match(ghPayload.args.command, /python3 scripts\/worktree_helper_command\.py maintenance --directory/) - await assert.rejects( - plugin["tool.execute.before"]( - { tool: "bash", sessionID: "session-primary-chain" }, - { args: { command: "git status --short --branch && echo hi > file.txt" } } - ), - /limited to inspection, validation, and exact default-branch sync commands/ + const chainPayload = { args: { command: "git status --short --branch && echo hi > file.txt" } } + await plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-primary-chain" }, + chainPayload ) + assert.match(chainPayload.args.command, /python3 scripts\/worktree_helper_command\.py maintenance --directory/) await assert.rejects( plugin["tool.execute.before"]( @@ -308,13 +305,12 @@ test("primary-worktree-guard blocks mutating bash commands in the primary worktr /Branch switching to 'main' is blocked/ ) - await assert.rejects( - plugin["tool.execute.before"]( - { tool: "bash", sessionID: "session-primary-redirection" }, - { args: { command: "git status --short --branch > file.txt" } } - ), - /limited to inspection, validation, and exact default-branch sync commands/ + const redirectPayload = { args: { command: "git status --short --branch > file.txt" } } + await plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-primary-redirection" }, + redirectPayload ) + assert.match(redirectPayload.args.command, /python3 scripts\/worktree_helper_command\.py maintenance --directory/) await plugin["tool.execute.before"]( { tool: "bash", sessionID: "session-primary-bash-safe" }, diff --git a/plugin/gateway-core/test/session-recovery-hook.test.mjs b/plugin/gateway-core/test/session-recovery-hook.test.mjs index c5427402..9ae77095 100644 --- a/plugin/gateway-core/test/session-recovery-hook.test.mjs +++ b/plugin/gateway-core/test/session-recovery-hook.test.mjs @@ -690,7 +690,7 @@ test("session-recovery ignores stale question rescue when latest message is user } }) -test("session-recovery ignores stale question rescue when question is not last tool part", async () => { +test("session-recovery ignores incomplete-tail recovery when question is not last tool part", async () => { const directory = mkdtempSync(join(tmpdir(), "gateway-session-recovery-")) try { let promptCalls = 0 @@ -947,3 +947,63 @@ test("session-recovery clears proactive question tracking after user reply", asy rmSync(directory, { recursive: true, force: true }) } }) + +test("session-recovery injects fallback when idle session ends with incomplete non-text assistant tail", async () => { + const directory = mkdtempSync(join(tmpdir(), "gateway-session-recovery-")) + let lastPromptBody = null + try { + const plugin = GatewayCorePlugin({ + directory, + config: { + hooks: { + enabled: true, + order: ["session-recovery"], + disabled: [], + }, + sessionRecovery: { + enabled: true, + autoResume: true, + }, + }, + client: { + session: { + async messages() { + return { + data: [ + { + info: { role: "user", time: { completed: Date.now() } }, + parts: [{ type: "text", text: "please continue" }], + }, + { + info: { role: "assistant", time: {} }, + parts: [ + { type: "tool", tool: "read", state: { status: "completed" } }, + { type: "reasoning", text: "checking" }, + ], + }, + ], + } + }, + async promptAsync(args) { + lastPromptBody = args.body + }, + }, + }, + }) + + await plugin.event({ + event: { + type: "session.idle", + directory, + properties: { + sessionID: "session-recovery-incomplete-tail", + }, + }, + }) + + assert.match(lastPromptBody?.parts?.[0]?.text ?? "", /incomplete assistant turn detected during idle/) + assert.match(lastPromptBody?.parts?.[0]?.text ?? "", /last_tool: read/) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/plugin/gateway-core/test/workflow-conformance-guard-hook.test.mjs b/plugin/gateway-core/test/workflow-conformance-guard-hook.test.mjs index 50f8065d..49ae22f4 100644 --- a/plugin/gateway-core/test/workflow-conformance-guard-hook.test.mjs +++ b/plugin/gateway-core/test/workflow-conformance-guard-hook.test.mjs @@ -15,7 +15,7 @@ function commitAll(directory, message) { }) } -test("workflow-conformance-guard blocks git commit on protected branch", async () => { +test("workflow-conformance-guard reroutes git commit on protected branch", async () => { const directory = mkdtempSync(join(tmpdir(), "gateway-workflow-guard-")) try { execSync("git init -b main", { cwd: directory, stdio: ["ignore", "pipe", "pipe"] }) @@ -31,13 +31,13 @@ test("workflow-conformance-guard blocks git commit on protected branch", async ( }, }) - await assert.rejects( - plugin["tool.execute.before"]( - { tool: "bash", sessionID: "session-workflow" }, - { args: { command: "git commit -m \"msg\"" } }, - ), - /protected branch/, + const payload = { args: { command: "git commit -m \"msg\"" } } + await plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-workflow" }, + payload, ) + assert.match(payload.args.command, /python3 scripts\/worktree_helper_command\.py maintenance --directory/) + assert.match(payload.args.command, /--command "git commit -m \\\"msg\\\"" --json/) } finally { rmSync(directory, { recursive: true, force: true }) } @@ -177,7 +177,7 @@ test("workflow-conformance-guard allows safe inspection bash commands on protect } }) -test("workflow-conformance-guard still blocks env-prefixed git mutation commands", async () => { +test("workflow-conformance-guard reroutes env-prefixed git mutation commands", async () => { const directory = mkdtempSync(join(tmpdir(), "gateway-workflow-guard-")) try { execSync("git init -b main", { cwd: directory, stdio: ["ignore", "pipe", "pipe"] }) @@ -193,19 +193,18 @@ test("workflow-conformance-guard still blocks env-prefixed git mutation commands }, }) - await assert.rejects( - plugin["tool.execute.before"]( - { tool: "bash", sessionID: "session-workflow-env" }, - { args: { command: "env GIT_TRACE=1 git commit -m \"msg\"" } } - ), - /protected branch/ + const payload = { args: { command: "env GIT_TRACE=1 git commit -m \"msg\"" } } + await plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-workflow-env" }, + payload ) + assert.match(payload.args.command, /python3 scripts\/worktree_helper_command\.py maintenance --directory/) } finally { rmSync(directory, { recursive: true, force: true }) } }) -test("workflow-conformance-guard blocks wrapped rtk git commit on protected branch", async () => { +test("workflow-conformance-guard reroutes wrapped rtk git commit on protected branch", async () => { const directory = mkdtempSync(join(tmpdir(), "gateway-workflow-guard-")) try { execSync("git init -b main", { cwd: directory, stdio: ["ignore", "pipe", "pipe"] }) @@ -221,13 +220,13 @@ test("workflow-conformance-guard blocks wrapped rtk git commit on protected bran }, }) - await assert.rejects( - plugin["tool.execute.before"]( - { tool: "bash", sessionID: "session-workflow-rtk-commit" }, - { args: { command: 'rtk git commit -m "msg"' } }, - ), - /protected branch/, + const payload = { args: { command: 'rtk git commit -m "msg"' } } + await plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-workflow-rtk-commit" }, + payload, ) + assert.match(payload.args.command, /python3 scripts\/worktree_helper_command\.py maintenance --directory/) + assert.match(payload.args.command, /--command "rtk git commit -m \\\"msg\\\"" --json/) } finally { rmSync(directory, { recursive: true, force: true }) } @@ -293,7 +292,7 @@ test("workflow-conformance-guard allows apply_patch targeting a linked worktree } }) -test("workflow-conformance-guard blocks mutating bash commands on protected branches", async () => { +test("workflow-conformance-guard reroutes mutating bash commands on protected branches", async () => { const directory = mkdtempSync(join(tmpdir(), "gateway-workflow-guard-")) try { execSync("git init -b main", { cwd: directory, stdio: ["ignore", "pipe", "pipe"] }) @@ -309,53 +308,48 @@ test("workflow-conformance-guard blocks mutating bash commands on protected bran }, }) - await assert.rejects( - plugin["tool.execute.before"]( - { tool: "bash", sessionID: "session-workflow-bash-mutate" }, - { args: { command: "echo hi > file.txt" } } - ), - /python3 scripts\/worktree_helper_command\.py maintenance --directory .*git worktree add -b chore\/ \.\.\/.*-maint HEAD/ + const mutatePayload = { args: { command: "echo hi > file.txt" } } + await plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-workflow-bash-mutate" }, + mutatePayload ) + assert.match(mutatePayload.args.command, /python3 scripts\/worktree_helper_command\.py maintenance --directory/) + assert.match(mutatePayload.args.command, /--command "echo hi > file\.txt" --json/) - await assert.rejects( - plugin["tool.execute.before"]( - { tool: "bash", sessionID: "session-workflow-gh-api" }, - { args: { command: "gh api -X POST repos/foo/bar/issues" } } - ), - /limited to inspection, validation, and exact sync commands/ + const ghPayload = { args: { command: "gh api -X POST repos/foo/bar/issues" } } + await plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-workflow-gh-api" }, + ghPayload ) + assert.match(ghPayload.args.command, /python3 scripts\/worktree_helper_command\.py maintenance --directory/) - await assert.rejects( - plugin["tool.execute.before"]( - { tool: "bash", sessionID: "session-workflow-chain" }, - { args: { command: "git status --short --branch && echo hi > file.txt" } } - ), - /limited to inspection, validation, and exact sync commands/ + const chainPayload = { args: { command: "git status --short --branch && echo hi > file.txt" } } + await plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-workflow-chain" }, + chainPayload ) + assert.match(chainPayload.args.command, /--command "git status --short --branch && echo hi > file\.txt" --json/) - await assert.rejects( - plugin["tool.execute.before"]( - { tool: "bash", sessionID: "session-workflow-refspec-pull" }, - { args: { command: "git pull --rebase origin feature/x" } } - ), - /limited to inspection, validation, and exact sync commands/ + const pullPayload = { args: { command: "git pull --rebase origin feature/x" } } + await plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-workflow-refspec-pull" }, + pullPayload ) + assert.match(pullPayload.args.command, /python3 scripts\/worktree_helper_command\.py maintenance --directory/) - await assert.rejects( - plugin["tool.execute.before"]( - { tool: "bash", sessionID: "session-workflow-fetch-refspec" }, - { args: { command: "git fetch origin +feature/x:main" } } - ), - /limited to inspection, validation, and exact sync commands/ + const fetchPayload = { args: { command: "git fetch origin +feature/x:main" } } + await plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-workflow-fetch-refspec" }, + fetchPayload ) + assert.match(fetchPayload.args.command, /python3 scripts\/worktree_helper_command\.py maintenance --directory/) - await assert.rejects( - plugin["tool.execute.before"]( - { tool: "bash", sessionID: "session-workflow-redirection" }, - { args: { command: "git status --short --branch > file.txt" } } - ), - /limited to inspection, validation, and exact sync commands/ + const redirectPayload = { args: { command: "git status --short --branch > file.txt" } } + await plugin["tool.execute.before"]( + { tool: "bash", sessionID: "session-workflow-redirection" }, + redirectPayload ) + assert.match(redirectPayload.args.command, /python3 scripts\/worktree_helper_command\.py maintenance --directory/) } finally { rmSync(directory, { recursive: true, force: true }) } diff --git a/scripts/gateway_command.py b/scripts/gateway_command.py index 8192b5b2..fc8a12e1 100644 --- a/scripts/gateway_command.py +++ b/scripts/gateway_command.py @@ -19,9 +19,9 @@ UTC = getattr(datetime, "UTC", timezone.utc) DEFAULT_LONG_TURN_WATCHDOG = { "enabled": True, - "warningThresholdMs": 300000, - "toolCallWarningThreshold": 50, - "reminderCooldownMs": 120000, + "warningThresholdMs": 60000, + "toolCallWarningThreshold": 12, + "reminderCooldownMs": 60000, "maxSessionStateEntries": 1024, "prefix": "[Turn Watchdog]:", }