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;
}