From 17bdef022258c81440d8fd31d83370a315eaff3c Mon Sep 17 00:00:00 2001 From: wangyf <824537320@qq.com> Date: Thu, 27 Aug 2026 11:37:00 +0800 Subject: [PATCH 1/4] feat(omp): implement prompt_injection.skip_keyword escape hatch Add word-boundary, case-insensitive skip keyword detection for per-turn workflow-state breadcrumb injection. When user prompt contains the configured skip keyword (default 'no-trellis'), the workflow-state breadcrumb is skipped for that turn only. - Add readPromptInjectionSkipKeyword() to parse config.yaml - Add shouldSkipWorkflowState() with word-boundary regex match - Update TurnContextCache.get() to accept skipThisTurn parameter - Update input event handler to detect skip keyword via event.text --- .omp/extensions/trellis/index.ts | 78 +++++++++++++++++-- .../omp/extensions/trellis/index.ts.txt | 78 +++++++++++++++++-- 2 files changed, 144 insertions(+), 12 deletions(-) diff --git a/.omp/extensions/trellis/index.ts b/.omp/extensions/trellis/index.ts index d5a59fa4b..0f481450a 100644 --- a/.omp/extensions/trellis/index.ts +++ b/.omp/extensions/trellis/index.ts @@ -714,6 +714,50 @@ function buildTaskContext(projectRoot: string, taskDir: string, agentType?: Agen return `${body}${suffix}`; } +// --------------------------------------------------------------------------- +// Prompt injection config (escape hatch) +// --------------------------------------------------------------------------- + +// Unset config (or missing config.yaml) disables the escape hatch; only an +// explicit prompt_injection.skip_keyword enables per-turn skipping. +function readPromptInjectionSkipKeyword(projectRoot: string): string { + let config = ""; + try { config = readFileSync(join(projectRoot, ".trellis", "config.yaml"), "utf-8"); } catch { return ""; } + + 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; + return unquoteYaml(stripInlineComment(match[1]!).trim()).trim(); + } + return ""; +} + +// Hyphen counts as a word char so "no-trellisx" / "xno-trellis" / +// "foo-no-trellis" don't match, but punctuation/whitespace boundaries do. +// Empty keyword (unset config) 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 = skipThisTurn + ? "" + : `\n${workflowBody}\n\n\n\n${SESSION_OVERVIEW_TEXT}\n`; this.key = cacheKey; this.timestamp = now; @@ -902,6 +951,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", @@ -947,7 +999,16 @@ export default function(pi: ExtensionAPI): void { if (!taskContextChanged && lastInjectionTs > lastCompactionTs) return; const cached = turnCache.get(projectRoot, contextKey); - if (!cached.workflowMsg) return taskContextChanged ? { messages: projectedMessages } : undefined; + if (!cached.workflowMsg) { + // 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 +1047,19 @@ 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); + + // Check if this turn should skip workflow state injection + const skipKeyword = readPromptInjectionSkipKeyword(projectRoot); + const skipThisTurn = shouldSkipWorkflowState(event.text ?? "", skipKeyword); + // Pre-warm the cache so before_agent_start and context can use it - turnCache.get(projectRoot, contextKey); + turnCache.get(projectRoot, contextKey, skipThisTurn); }); } 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..0f481450a 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,50 @@ function buildTaskContext(projectRoot: string, taskDir: string, agentType?: Agen return `${body}${suffix}`; } +// --------------------------------------------------------------------------- +// Prompt injection config (escape hatch) +// --------------------------------------------------------------------------- + +// Unset config (or missing config.yaml) disables the escape hatch; only an +// explicit prompt_injection.skip_keyword enables per-turn skipping. +function readPromptInjectionSkipKeyword(projectRoot: string): string { + let config = ""; + try { config = readFileSync(join(projectRoot, ".trellis", "config.yaml"), "utf-8"); } catch { return ""; } + + 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; + return unquoteYaml(stripInlineComment(match[1]!).trim()).trim(); + } + return ""; +} + +// Hyphen counts as a word char so "no-trellisx" / "xno-trellis" / +// "foo-no-trellis" don't match, but punctuation/whitespace boundaries do. +// Empty keyword (unset config) 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 = skipThisTurn + ? "" + : `\n${workflowBody}\n\n\n\n${SESSION_OVERVIEW_TEXT}\n`; this.key = cacheKey; this.timestamp = now; @@ -902,6 +951,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", @@ -947,7 +999,16 @@ export default function(pi: ExtensionAPI): void { if (!taskContextChanged && lastInjectionTs > lastCompactionTs) return; const cached = turnCache.get(projectRoot, contextKey); - if (!cached.workflowMsg) return taskContextChanged ? { messages: projectedMessages } : undefined; + if (!cached.workflowMsg) { + // 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 +1047,19 @@ 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); + + // Check if this turn should skip workflow state injection + const skipKeyword = readPromptInjectionSkipKeyword(projectRoot); + const skipThisTurn = shouldSkipWorkflowState(event.text ?? "", skipKeyword); + // Pre-warm the cache so before_agent_start and context can use it - turnCache.get(projectRoot, contextKey); + turnCache.get(projectRoot, contextKey, skipThisTurn); }); } From ed8911469648d8caf6913bb8b610623337b743f1 Mon Sep 17 00:00:00 2001 From: wangyf <824537320@qq.com> Date: Thu, 27 Aug 2026 12:14:47 +0800 Subject: [PATCH 2/4] fix(omp): default skip keyword to no-trellis, aligning with Python hook Unset prompt_injection.skip_keyword now falls back to "no-trellis", matching inject-workflow-state.py (_resolve_skip_keyword). An explicit empty string still disables the escape hatch. --- .omp/extensions/trellis/index.ts | 17 ++++++++++------- .../omp/extensions/trellis/index.ts.txt | 17 ++++++++++------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.omp/extensions/trellis/index.ts b/.omp/extensions/trellis/index.ts index 0f481450a..618256395 100644 --- a/.omp/extensions/trellis/index.ts +++ b/.omp/extensions/trellis/index.ts @@ -718,11 +718,14 @@ function buildTaskContext(projectRoot: string, taskDir: string, agentType?: Agen // Prompt injection config (escape hatch) // --------------------------------------------------------------------------- -// Unset config (or missing config.yaml) disables the escape hatch; only an -// explicit prompt_injection.skip_keyword enables per-turn skipping. +// 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"; + function readPromptInjectionSkipKeyword(projectRoot: string): string { let config = ""; - try { config = readFileSync(join(projectRoot, ".trellis", "config.yaml"), "utf-8"); } catch { return ""; } + try { config = readFileSync(join(projectRoot, ".trellis", "config.yaml"), "utf-8"); } catch { return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD; } let inSection = false; let sectionIndent = -1; @@ -742,12 +745,12 @@ function readPromptInjectionSkipKeyword(projectRoot: string): string { if (!match) continue; return unquoteYaml(stripInlineComment(match[1]!).trim()).trim(); } - return ""; + return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD; } -// Hyphen counts as a word char so "no-trellisx" / "xno-trellis" / -// "foo-no-trellis" don't match, but punctuation/whitespace boundaries do. -// Empty keyword (unset config) never matches. +// 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, 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 0f481450a..618256395 100644 --- a/packages/cli/src/templates/omp/extensions/trellis/index.ts.txt +++ b/packages/cli/src/templates/omp/extensions/trellis/index.ts.txt @@ -718,11 +718,14 @@ function buildTaskContext(projectRoot: string, taskDir: string, agentType?: Agen // Prompt injection config (escape hatch) // --------------------------------------------------------------------------- -// Unset config (or missing config.yaml) disables the escape hatch; only an -// explicit prompt_injection.skip_keyword enables per-turn skipping. +// 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"; + function readPromptInjectionSkipKeyword(projectRoot: string): string { let config = ""; - try { config = readFileSync(join(projectRoot, ".trellis", "config.yaml"), "utf-8"); } catch { return ""; } + try { config = readFileSync(join(projectRoot, ".trellis", "config.yaml"), "utf-8"); } catch { return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD; } let inSection = false; let sectionIndent = -1; @@ -742,12 +745,12 @@ function readPromptInjectionSkipKeyword(projectRoot: string): string { if (!match) continue; return unquoteYaml(stripInlineComment(match[1]!).trim()).trim(); } - return ""; + return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD; } -// Hyphen counts as a word char so "no-trellisx" / "xno-trellis" / -// "foo-no-trellis" don't match, but punctuation/whitespace boundaries do. -// Empty keyword (unset config) never matches. +// 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, From 0597065d916ec62dd65ab67822158190cf124d2f Mon Sep 17 00:00:00 2001 From: wangyf <824537320@qq.com> Date: Thu, 27 Aug 2026 12:39:01 +0800 Subject: [PATCH 3/4] fix(omp): propagate turn skip state to all cache readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit findings on the skip-keyword cache design: - TurnContextCache.beginTurn(skip) records the per-turn skip decision and invalidates the TTL cache; the cache key no longer embeds skip state. Previously before_agent_start/context called get() without the skip argument, missed the ':skip' cache entry, recomputed a full message and injected it — the escape hatch never actually suppressed the breadcrumb. - context handler resolves the turn state before its fast path and adds a skipping guard, so a skip turn still strips any persisted breadcrumb from an earlier turn instead of returning early unchanged. --- .omp/extensions/trellis/index.ts | 35 +++++++++++++------ .../omp/extensions/trellis/index.ts.txt | 35 +++++++++++++------ 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/.omp/extensions/trellis/index.ts b/.omp/extensions/trellis/index.ts index 618256395..70c13d637 100644 --- a/.omp/extensions/trellis/index.ts +++ b/.omp/extensions/trellis/index.ts @@ -773,13 +773,20 @@ class TurnContextCache { private key: string | null = null; private timestamp = 0; private workflowMsg = ""; + private skipThisTurn = false; private static readonly TTL_MS = 1500; - get(projectRoot: string, contextKey: string | null, skipThisTurn: boolean = false): { workflowMsg: string } { + // Called once per user turn (input event) with the skip decision for that + // turn; invalidates the cache so every reader in the cascade + // (before_agent_start, context) resolves the same turn state. + beginTurn(skipThisTurn: boolean): void { + this.skipThisTurn = skipThisTurn; + this.key = null; + } + + get(projectRoot: string, contextKey: string | null): { workflowMsg: string } { const now = Date.now(); - // skipThisTurn participates in the cache key: a skip turn cached within - // the TTL must not leak an empty message into the next (non-skip) turn. - const cacheKey = `${projectRoot}:${contextKey ?? ""}:${skipThisTurn ? "skip" : "full"}`; + const cacheKey = `${projectRoot}:${contextKey ?? ""}`; if ( this.key === cacheKey && now - this.timestamp < TurnContextCache.TTL_MS @@ -806,7 +813,7 @@ class TurnContextCache { } // When skip keyword is present, skip workflow state injection this turn - this.workflowMsg = skipThisTurn + this.workflowMsg = this.skipThisTurn ? "" : `\n${workflowBody}\n\n\n\n${SESSION_OVERVIEW_TEXT}\n`; @@ -998,11 +1005,15 @@ 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) { + 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( @@ -1062,7 +1073,9 @@ export default function(pi: ExtensionAPI): void { const skipKeyword = readPromptInjectionSkipKeyword(projectRoot); const skipThisTurn = shouldSkipWorkflowState(event.text ?? "", skipKeyword); - // Pre-warm the cache so before_agent_start and context can use it - turnCache.get(projectRoot, contextKey, skipThisTurn); + // 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 618256395..70c13d637 100644 --- a/packages/cli/src/templates/omp/extensions/trellis/index.ts.txt +++ b/packages/cli/src/templates/omp/extensions/trellis/index.ts.txt @@ -773,13 +773,20 @@ class TurnContextCache { private key: string | null = null; private timestamp = 0; private workflowMsg = ""; + private skipThisTurn = false; private static readonly TTL_MS = 1500; - get(projectRoot: string, contextKey: string | null, skipThisTurn: boolean = false): { workflowMsg: string } { + // Called once per user turn (input event) with the skip decision for that + // turn; invalidates the cache so every reader in the cascade + // (before_agent_start, context) resolves the same turn state. + beginTurn(skipThisTurn: boolean): void { + this.skipThisTurn = skipThisTurn; + this.key = null; + } + + get(projectRoot: string, contextKey: string | null): { workflowMsg: string } { const now = Date.now(); - // skipThisTurn participates in the cache key: a skip turn cached within - // the TTL must not leak an empty message into the next (non-skip) turn. - const cacheKey = `${projectRoot}:${contextKey ?? ""}:${skipThisTurn ? "skip" : "full"}`; + const cacheKey = `${projectRoot}:${contextKey ?? ""}`; if ( this.key === cacheKey && now - this.timestamp < TurnContextCache.TTL_MS @@ -806,7 +813,7 @@ class TurnContextCache { } // When skip keyword is present, skip workflow state injection this turn - this.workflowMsg = skipThisTurn + this.workflowMsg = this.skipThisTurn ? "" : `\n${workflowBody}\n\n\n\n${SESSION_OVERVIEW_TEXT}\n`; @@ -998,11 +1005,15 @@ 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) { + 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( @@ -1062,7 +1073,9 @@ export default function(pi: ExtensionAPI): void { const skipKeyword = readPromptInjectionSkipKeyword(projectRoot); const skipThisTurn = shouldSkipWorkflowState(event.text ?? "", skipKeyword); - // Pre-warm the cache so before_agent_start and context can use it - turnCache.get(projectRoot, contextKey, skipThisTurn); + // 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); }); } From cae00fc5dfe3aa037d251c03c917d7861b725274 Mon Sep 17 00:00:00 2001 From: wangyf <824537320@qq.com> Date: Thu, 27 Aug 2026 13:00:11 +0800 Subject: [PATCH 4/4] fix(omp): fall back to default for non-string skip_keyword scalars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve YAML scalar typing, mirroring _resolve_skip_keyword's isinstance(raw, str) check in inject-workflow-state.py: bare non-string scalars (bool/null/number, including an empty value) resolve to the "no-trellis" default instead of becoming literal keywords, while quoted scalars — including an explicit "" — stay strings. Scalar typing follows the PyYAML resolvers: YAML 1.1 bool set, null variants, int (binary/octal/decimal/hex; leading-zero decimals are strings), float (requires a dot and a signed exponent — "1.5e3" is a string, "1.5e+3" is a float). --- .omp/extensions/trellis/index.ts | 26 ++++++++++++++++++- .../omp/extensions/trellis/index.ts.txt | 26 ++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/.omp/extensions/trellis/index.ts b/.omp/extensions/trellis/index.ts index 70c13d637..7f478a252 100644 --- a/.omp/extensions/trellis/index.ts +++ b/.omp/extensions/trellis/index.ts @@ -723,6 +723,21 @@ function buildTaskContext(projectRoot: string, taskDir: string, agentType?: Agen // 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; } @@ -743,7 +758,16 @@ function readPromptInjectionSkipKeyword(projectRoot: string): string { if (indent <= sectionIndent) break; const match = trimmed.match(/^skip_keyword\s*:\s*(.*)$/); if (!match) continue; - return unquoteYaml(stripInlineComment(match[1]!).trim()).trim(); + 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; } 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 70c13d637..7f478a252 100644 --- a/packages/cli/src/templates/omp/extensions/trellis/index.ts.txt +++ b/packages/cli/src/templates/omp/extensions/trellis/index.ts.txt @@ -723,6 +723,21 @@ function buildTaskContext(projectRoot: string, taskDir: string, agentType?: Agen // 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; } @@ -743,7 +758,16 @@ function readPromptInjectionSkipKeyword(projectRoot: string): string { if (indent <= sectionIndent) break; const match = trimmed.match(/^skip_keyword\s*:\s*(.*)$/); if (!match) continue; - return unquoteYaml(stripInlineComment(match[1]!).trim()).trim(); + 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; }