diff --git a/.omp/extensions/trellis/index.ts b/.omp/extensions/trellis/index.ts index d5a59fa4b..7f478a252 100644 --- a/.omp/extensions/trellis/index.ts +++ b/.omp/extensions/trellis/index.ts @@ -714,6 +714,77 @@ function buildTaskContext(projectRoot: string, taskDir: string, agentType?: Agen return `${body}${suffix}`; } +// --------------------------------------------------------------------------- +// Prompt injection config (escape hatch) +// --------------------------------------------------------------------------- + +// Mirrors DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD in inject-workflow-state.py: +// the skip keyword defaults to "no-trellis"; an explicit "" disables the +// escape hatch entirely. +const DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis"; + +// PyYAML-compatible resolution of scalars that parse as non-strings: null +// (empty, ~, null variants), bool (YAML 1.1 set), and numbers. Quoted scalars +// never reach this check and stay strings. +function isYamlNonStringScalar(raw: string): boolean { + if (raw === "" || raw === "~" || /^(?:null|Null|NULL)$/.test(raw)) return true; + if (/^(?:true|True|TRUE|false|False|FALSE|yes|Yes|YES|no|No|NO|on|On|ON|off|Off|OFF)$/.test(raw)) return true; + // PyYAML int resolver: binary/octal/decimal/hex; leading-zero decimals are not ints. + if (/^[-+]?(?:0[bB][01_]+|0[0-7_]+|0[xX][0-9a-fA-F_]+|[1-9][\d_]*|0)$/.test(raw)) return true; + // PyYAML float resolver: requires a dot and a signed exponent ("1.5e+3", + // not "1.5e3" — the latter stays a string in PyYAML). + return /^[-+]?(?:\d[\d_]*\.[\d_]*|\.[\d_]+)(?:[eE][-+]\d+)?$/.test(raw) || + /^[-+]?\.(?:inf|Inf|INF)$/.test(raw) || + /^\.(?:nan|NaN|NAN)$/.test(raw); +} + +function readPromptInjectionSkipKeyword(projectRoot: string): string { + let config = ""; + try { config = readFileSync(join(projectRoot, ".trellis", "config.yaml"), "utf-8"); } catch { return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD; } + + let inSection = false; + let sectionIndent = -1; + for (const rawLine of config.split(/\r?\n/)) { + const trimmed = rawLine.trim(); + if (!inSection) { + if (/^prompt_injection\s*:\s*(#.*)?$/.test(trimmed)) { + inSection = true; + sectionIndent = rawLine.length - rawLine.trimStart().length; + } + continue; + } + if (!trimmed || trimmed.startsWith("#")) continue; + const indent = rawLine.length - rawLine.trimStart().length; + if (indent <= sectionIndent) break; + const match = trimmed.match(/^skip_keyword\s*:\s*(.*)$/); + if (!match) continue; + const rawValue = stripInlineComment(match[1]!).trim(); + const unquoted = unquoteYaml(rawValue); + // Preserve YAML scalar typing, mirroring _resolve_skip_keyword's + // isinstance(raw, str) check: a bare non-string scalar (bool/null/ + // number, including an empty value) falls back to the default, while + // quoted scalars — including an explicit "" — stay strings. + if (unquoted === rawValue && isYamlNonStringScalar(rawValue)) { + return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD; + } + return unquoted.trim(); + } + return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD; +} + +// Mirrors prompt_has_skip_keyword() in inject-workflow-state.py: hyphen counts +// as a word char so "no-trellisx" / "xno-trellis" / "foo-no-trellis" don't +// match, but punctuation/whitespace boundaries do. Empty keyword never matches. +function shouldSkipWorkflowState( + userInput: string, + skipKeyword: string, +): boolean { + if (!skipKeyword) return false; + const escapedKeyword = skipKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(`(?\n${workflowBody}\n\n\n\n${SESSION_OVERVIEW_TEXT}\n`; + // When skip keyword is present, skip workflow state injection this turn + this.workflowMsg = this.skipThisTurn + ? "" + : `\n${workflowBody}\n\n\n\n${SESSION_OVERVIEW_TEXT}\n`; this.key = cacheKey; this.timestamp = now; @@ -902,6 +985,9 @@ export default function(pi: ExtensionAPI): void { const cached = turnCache.get(projectRoot, contextKey); lastInjectionTs = Date.now(); + // Skip turn: inject nothing (escape hatch) + if (!cached.workflowMsg) return; + return { message: { customType: "trellis-workflow-state", @@ -943,11 +1029,24 @@ export default function(pi: ExtensionAPI): void { if (replacement && !replaced) projectedMessages.push(replacement); } - // Fast path: no task change and no compaction — all persisted context is current. - if (!taskContextChanged && lastInjectionTs > lastCompactionTs) return; - + // Resolve the turn state before the fast path: a skip turn must still + // run breadcrumb cleanup even when nothing else changed. const cached = turnCache.get(projectRoot, contextKey); - if (!cached.workflowMsg) return taskContextChanged ? { messages: projectedMessages } : undefined; + const skipping = !cached.workflowMsg; + + // Fast path: no task change, no compaction, not skipping — all persisted context is current. + if (!taskContextChanged && !skipping && lastInjectionTs > lastCompactionTs) return; + + if (skipping) { + // Skip turn (escape hatch): drop any persisted breadcrumb from an + // earlier turn so the skip actually takes effect. + const withoutBreadcrumb = projectedMessages.filter( + (message) => !(message.role === "custom" && message.customType === "trellis-workflow-state"), + ); + if (withoutBreadcrumb.length === projectedMessages.length && !taskContextChanged) return; + lastInjectionTs = Date.now(); + return { messages: withoutBreadcrumb }; + } // Post-compaction: reverse-scan to confirm absence before injecting for (let i = projectedMessages.length - 1; i >= 0; i--) { @@ -986,14 +1085,21 @@ export default function(pi: ExtensionAPI): void { }; }); - pi.on("input", async (_event, ctx) => { + pi.on("input", async (event, ctx) => { if (!projectRoot) { projectRoot = findProjectRoot(ctx.cwd); } // Resolve projectRoot on first input if session_start missed it if (!projectRoot) return; const contextKey = rememberContextKey(ctx); - // Pre-warm the cache so before_agent_start and context can use it + + // Check if this turn should skip workflow state injection + const skipKeyword = readPromptInjectionSkipKeyword(projectRoot); + const skipThisTurn = shouldSkipWorkflowState(event.text ?? "", skipKeyword); + + // Record the turn's skip decision and pre-warm the cache so + // before_agent_start and context can use it + turnCache.beginTurn(skipThisTurn); turnCache.get(projectRoot, contextKey); }); } diff --git a/packages/cli/src/templates/omp/extensions/trellis/index.ts.txt b/packages/cli/src/templates/omp/extensions/trellis/index.ts.txt index d5a59fa4b..7f478a252 100644 --- a/packages/cli/src/templates/omp/extensions/trellis/index.ts.txt +++ b/packages/cli/src/templates/omp/extensions/trellis/index.ts.txt @@ -714,6 +714,77 @@ function buildTaskContext(projectRoot: string, taskDir: string, agentType?: Agen return `${body}${suffix}`; } +// --------------------------------------------------------------------------- +// Prompt injection config (escape hatch) +// --------------------------------------------------------------------------- + +// Mirrors DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD in inject-workflow-state.py: +// the skip keyword defaults to "no-trellis"; an explicit "" disables the +// escape hatch entirely. +const DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis"; + +// PyYAML-compatible resolution of scalars that parse as non-strings: null +// (empty, ~, null variants), bool (YAML 1.1 set), and numbers. Quoted scalars +// never reach this check and stay strings. +function isYamlNonStringScalar(raw: string): boolean { + if (raw === "" || raw === "~" || /^(?:null|Null|NULL)$/.test(raw)) return true; + if (/^(?:true|True|TRUE|false|False|FALSE|yes|Yes|YES|no|No|NO|on|On|ON|off|Off|OFF)$/.test(raw)) return true; + // PyYAML int resolver: binary/octal/decimal/hex; leading-zero decimals are not ints. + if (/^[-+]?(?:0[bB][01_]+|0[0-7_]+|0[xX][0-9a-fA-F_]+|[1-9][\d_]*|0)$/.test(raw)) return true; + // PyYAML float resolver: requires a dot and a signed exponent ("1.5e+3", + // not "1.5e3" — the latter stays a string in PyYAML). + return /^[-+]?(?:\d[\d_]*\.[\d_]*|\.[\d_]+)(?:[eE][-+]\d+)?$/.test(raw) || + /^[-+]?\.(?:inf|Inf|INF)$/.test(raw) || + /^\.(?:nan|NaN|NAN)$/.test(raw); +} + +function readPromptInjectionSkipKeyword(projectRoot: string): string { + let config = ""; + try { config = readFileSync(join(projectRoot, ".trellis", "config.yaml"), "utf-8"); } catch { return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD; } + + let inSection = false; + let sectionIndent = -1; + for (const rawLine of config.split(/\r?\n/)) { + const trimmed = rawLine.trim(); + if (!inSection) { + if (/^prompt_injection\s*:\s*(#.*)?$/.test(trimmed)) { + inSection = true; + sectionIndent = rawLine.length - rawLine.trimStart().length; + } + continue; + } + if (!trimmed || trimmed.startsWith("#")) continue; + const indent = rawLine.length - rawLine.trimStart().length; + if (indent <= sectionIndent) break; + const match = trimmed.match(/^skip_keyword\s*:\s*(.*)$/); + if (!match) continue; + const rawValue = stripInlineComment(match[1]!).trim(); + const unquoted = unquoteYaml(rawValue); + // Preserve YAML scalar typing, mirroring _resolve_skip_keyword's + // isinstance(raw, str) check: a bare non-string scalar (bool/null/ + // number, including an empty value) falls back to the default, while + // quoted scalars — including an explicit "" — stay strings. + if (unquoted === rawValue && isYamlNonStringScalar(rawValue)) { + return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD; + } + return unquoted.trim(); + } + return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD; +} + +// Mirrors prompt_has_skip_keyword() in inject-workflow-state.py: hyphen counts +// as a word char so "no-trellisx" / "xno-trellis" / "foo-no-trellis" don't +// match, but punctuation/whitespace boundaries do. Empty keyword never matches. +function shouldSkipWorkflowState( + userInput: string, + skipKeyword: string, +): boolean { + if (!skipKeyword) return false; + const escapedKeyword = skipKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(`(?\n${workflowBody}\n\n\n\n${SESSION_OVERVIEW_TEXT}\n`; + // When skip keyword is present, skip workflow state injection this turn + this.workflowMsg = this.skipThisTurn + ? "" + : `\n${workflowBody}\n\n\n\n${SESSION_OVERVIEW_TEXT}\n`; this.key = cacheKey; this.timestamp = now; @@ -902,6 +985,9 @@ export default function(pi: ExtensionAPI): void { const cached = turnCache.get(projectRoot, contextKey); lastInjectionTs = Date.now(); + // Skip turn: inject nothing (escape hatch) + if (!cached.workflowMsg) return; + return { message: { customType: "trellis-workflow-state", @@ -943,11 +1029,24 @@ export default function(pi: ExtensionAPI): void { if (replacement && !replaced) projectedMessages.push(replacement); } - // Fast path: no task change and no compaction — all persisted context is current. - if (!taskContextChanged && lastInjectionTs > lastCompactionTs) return; - + // Resolve the turn state before the fast path: a skip turn must still + // run breadcrumb cleanup even when nothing else changed. const cached = turnCache.get(projectRoot, contextKey); - if (!cached.workflowMsg) return taskContextChanged ? { messages: projectedMessages } : undefined; + const skipping = !cached.workflowMsg; + + // Fast path: no task change, no compaction, not skipping — all persisted context is current. + if (!taskContextChanged && !skipping && lastInjectionTs > lastCompactionTs) return; + + if (skipping) { + // Skip turn (escape hatch): drop any persisted breadcrumb from an + // earlier turn so the skip actually takes effect. + const withoutBreadcrumb = projectedMessages.filter( + (message) => !(message.role === "custom" && message.customType === "trellis-workflow-state"), + ); + if (withoutBreadcrumb.length === projectedMessages.length && !taskContextChanged) return; + lastInjectionTs = Date.now(); + return { messages: withoutBreadcrumb }; + } // Post-compaction: reverse-scan to confirm absence before injecting for (let i = projectedMessages.length - 1; i >= 0; i--) { @@ -986,14 +1085,21 @@ export default function(pi: ExtensionAPI): void { }; }); - pi.on("input", async (_event, ctx) => { + pi.on("input", async (event, ctx) => { if (!projectRoot) { projectRoot = findProjectRoot(ctx.cwd); } // Resolve projectRoot on first input if session_start missed it if (!projectRoot) return; const contextKey = rememberContextKey(ctx); - // Pre-warm the cache so before_agent_start and context can use it + + // Check if this turn should skip workflow state injection + const skipKeyword = readPromptInjectionSkipKeyword(projectRoot); + const skipThisTurn = shouldSkipWorkflowState(event.text ?? "", skipKeyword); + + // Record the turn's skip decision and pre-warm the cache so + // before_agent_start and context can use it + turnCache.beginTurn(skipThisTurn); turnCache.get(projectRoot, contextKey); }); }