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]:",
}