@mindfoldhq/trellis 0.6.8 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/cli/index.js +1 -0
  2. package/dist/cli/index.js.map +1 -1
  3. package/dist/commands/channel/agent-loader.d.ts +2 -2
  4. package/dist/commands/channel/agent-loader.d.ts.map +1 -1
  5. package/dist/commands/channel/agent-loader.js +9 -5
  6. package/dist/commands/channel/agent-loader.js.map +1 -1
  7. package/dist/commands/channel/context-loader.d.ts +1 -1
  8. package/dist/commands/channel/context-loader.d.ts.map +1 -1
  9. package/dist/commands/channel/context-loader.js +36 -15
  10. package/dist/commands/channel/context-loader.js.map +1 -1
  11. package/dist/commands/channel/context-trust.d.ts +31 -0
  12. package/dist/commands/channel/context-trust.d.ts.map +1 -0
  13. package/dist/commands/channel/context-trust.js +139 -0
  14. package/dist/commands/channel/context-trust.js.map +1 -0
  15. package/dist/commands/channel/spawn.d.ts.map +1 -1
  16. package/dist/commands/channel/spawn.js +4 -2
  17. package/dist/commands/channel/spawn.js.map +1 -1
  18. package/dist/commands/init.d.ts +1 -0
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js.map +1 -1
  21. package/dist/commands/update.d.ts.map +1 -1
  22. package/dist/commands/update.js +17 -0
  23. package/dist/commands/update.js.map +1 -1
  24. package/dist/configurators/codex.d.ts +36 -0
  25. package/dist/configurators/codex.d.ts.map +1 -1
  26. package/dist/configurators/codex.js +100 -1
  27. package/dist/configurators/codex.js.map +1 -1
  28. package/dist/configurators/index.d.ts.map +1 -1
  29. package/dist/configurators/index.js +5 -0
  30. package/dist/configurators/index.js.map +1 -1
  31. package/dist/configurators/shared.d.ts.map +1 -1
  32. package/dist/configurators/shared.js +1 -0
  33. package/dist/configurators/shared.js.map +1 -1
  34. package/dist/configurators/snow.d.ts +31 -0
  35. package/dist/configurators/snow.d.ts.map +1 -0
  36. package/dist/configurators/snow.js +95 -0
  37. package/dist/configurators/snow.js.map +1 -0
  38. package/dist/configurators/workflow.d.ts +14 -0
  39. package/dist/configurators/workflow.d.ts.map +1 -1
  40. package/dist/configurators/workflow.js +37 -1
  41. package/dist/configurators/workflow.js.map +1 -1
  42. package/dist/migrations/manifests/0.6.10.json +9 -0
  43. package/dist/migrations/manifests/0.6.9.json +9 -0
  44. package/dist/templates/codex/agents/trellis-check.toml +7 -3
  45. package/dist/templates/codex/agents/trellis-implement.toml +7 -3
  46. package/dist/templates/codex/agents/trellis-research.toml +13 -6
  47. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +1 -1
  48. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +1 -1
  49. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +4 -2
  50. package/dist/templates/kimi/agents/trellis-research.md +7 -3
  51. package/dist/templates/omp/extensions/trellis/index.ts.txt +114 -4
  52. package/dist/templates/opencode/lib/trellis-context.js +263 -43
  53. package/dist/templates/opencode/plugins/inject-subagent-context.js +89 -35
  54. package/dist/templates/opencode/plugins/inject-workflow-state.js +90 -5
  55. package/dist/templates/pi/extensions/trellis/index.ts.txt +270 -22
  56. package/dist/templates/shared-hooks/inject-subagent-context.py +311 -83
  57. package/dist/templates/shared-hooks/inject-workflow-state.py +36 -1
  58. package/dist/templates/snow/SNOW.md +86 -0
  59. package/dist/templates/snow/agents/trellis-check.md +135 -0
  60. package/dist/templates/snow/agents/trellis-implement.md +153 -0
  61. package/dist/templates/snow/agents/trellis-research.md +114 -0
  62. package/dist/templates/snow/hooks/beforeSubAgentStart.json +15 -0
  63. package/dist/templates/snow/hooks/onSessionStart.json +15 -0
  64. package/dist/templates/snow/hooks/onUserMessage.json +15 -0
  65. package/dist/templates/snow/hooks/write-trellis-context.py +644 -0
  66. package/dist/templates/snow/index.d.ts +26 -0
  67. package/dist/templates/snow/index.d.ts.map +1 -0
  68. package/dist/templates/snow/index.js +36 -0
  69. package/dist/templates/snow/index.js.map +1 -0
  70. package/dist/templates/template-utils.d.ts.map +1 -1
  71. package/dist/templates/template-utils.js +5 -1
  72. package/dist/templates/template-utils.js.map +1 -1
  73. package/dist/templates/trellis/config.yaml +46 -0
  74. package/dist/templates/trellis/gitattributes.txt +9 -0
  75. package/dist/templates/trellis/index.d.ts +3 -1
  76. package/dist/templates/trellis/index.d.ts.map +1 -1
  77. package/dist/templates/trellis/index.js +3 -1
  78. package/dist/templates/trellis/index.js.map +1 -1
  79. package/dist/templates/trellis/scripts/add_session.py +94 -28
  80. package/dist/templates/trellis/scripts/common/active_task.py +9 -2
  81. package/dist/templates/trellis/scripts/common/config.py +89 -2
  82. package/dist/templates/trellis/scripts/common/task_context.py +84 -6
  83. package/dist/templates/trellis/scripts/common/task_store.py +63 -1
  84. package/dist/templates/trellis/scripts/common/trellis_config.py +2 -1
  85. package/dist/templates/trellis/scripts/task.py +21 -2
  86. package/dist/templates/trellis/workflow.md +14 -14
  87. package/dist/types/ai-tools.d.ts +3 -3
  88. package/dist/types/ai-tools.d.ts.map +1 -1
  89. package/dist/types/ai-tools.js +38 -0
  90. package/dist/types/ai-tools.js.map +1 -1
  91. package/package.json +2 -2
@@ -5,11 +5,12 @@
5
5
  * JSONL parsing, and context building capabilities.
6
6
  */
7
7
 
8
- import { existsSync, readFileSync, appendFileSync, readdirSync } from "fs"
8
+ import { existsSync, readFileSync, appendFileSync, readdirSync, statSync } from "fs"
9
9
  import { isAbsolute, join } from "path"
10
10
  import { platform } from "os"
11
11
  import { execSync } from "child_process"
12
12
  import { createHash } from "crypto"
13
+ import { Buffer, isUtf8 } from "buffer"
13
14
  import process from "process"
14
15
 
15
16
  const PYTHON_CMD = platform() === "win32" ? "python" : "python3"
@@ -78,6 +79,240 @@ export function isTrellisSubagent(input) {
78
79
  return TRELLIS_SUBAGENT_RE.test(agent)
79
80
  }
80
81
 
82
+ // ============================================================
83
+ // Context Injection Limits (issue #441)
84
+ //
85
+ // Notice text and behavior mirrored byte-for-byte from the shared-hooks
86
+ // Python sub-agent context injection hook and the Pi extension. Changing
87
+ // wording here requires changing it there too.
88
+ // ============================================================
89
+
90
+ const DEFAULT_CONTEXT_INJECTION_LIMITS = {
91
+ max_file_bytes: 32768,
92
+ max_artifact_bytes: 65536,
93
+ max_total_bytes: 131072,
94
+ }
95
+
96
+ /**
97
+ * Truncate `buf` to at most `cap` bytes without splitting a UTF-8
98
+ * multi-byte sequence. `cap <= 0` means "no limit".
99
+ */
100
+ function truncateUtf8(buf, cap) {
101
+ if (cap <= 0 || buf.length <= cap) return buf
102
+ let i = cap
103
+ // Back off over continuation bytes (10xxxxxx) to find the lead byte.
104
+ while (i > 0 && (buf[i - 1] & 0xc0) === 0x80) i--
105
+ if (i === 0) return Buffer.alloc(0)
106
+ const lead = buf[i - 1]
107
+ if (lead & 0x80) {
108
+ let seqLen = 1
109
+ if ((lead & 0xe0) === 0xc0) seqLen = 2
110
+ else if ((lead & 0xf0) === 0xe0) seqLen = 3
111
+ else if ((lead & 0xf8) === 0xf0) seqLen = 4
112
+ // Drop the lead byte too if its full sequence didn't fit.
113
+ if (i - 1 + seqLen > cap) i--
114
+ }
115
+ return buf.subarray(0, i)
116
+ }
117
+
118
+ function stripInlineComment(value) {
119
+ let inQuote = null
120
+ for (let idx = 0; idx < value.length; idx++) {
121
+ const ch = value[idx]
122
+ if (inQuote) {
123
+ if (ch === inQuote) inQuote = null
124
+ continue
125
+ }
126
+ if (ch === '"' || ch === "'") {
127
+ inQuote = ch
128
+ continue
129
+ }
130
+ if (ch === "#" && (idx === 0 || /\s/.test(value[idx - 1]))) {
131
+ return value.slice(0, idx)
132
+ }
133
+ }
134
+ return value
135
+ }
136
+
137
+ function unquoteYaml(s) {
138
+ if (s.length >= 2 && s[0] === s[s.length - 1] && (s[0] === '"' || s[0] === "'")) {
139
+ return s.slice(1, -1)
140
+ }
141
+ return s
142
+ }
143
+
144
+ /**
145
+ * Line-based parser for ONLY the `context_injection:` block of
146
+ * `.trellis/config.yaml`. Not a general YAML parser — mirrors
147
+ * `common.config.get_context_injection_limits()` semantics for this
148
+ * section only (missing keys keep the default; invalid/negative values
149
+ * fall back to the default for that key with a debugLog warning).
150
+ */
151
+ function readContextInjectionLimits(repoRoot) {
152
+ const limits = { ...DEFAULT_CONTEXT_INJECTION_LIMITS }
153
+ let text = null
154
+ try {
155
+ text = readFileSync(join(repoRoot, ".trellis", "config.yaml"), "utf-8")
156
+ } catch {
157
+ return limits
158
+ }
159
+ if (!text) return limits
160
+
161
+ let inSection = false
162
+ let sectionIndent = -1
163
+ for (const rawLine of text.split(/\r?\n/)) {
164
+ const trimmed = rawLine.trim()
165
+ if (!inSection) {
166
+ if (/^context_injection\s*:\s*(#.*)?$/.test(trimmed)) {
167
+ inSection = true
168
+ sectionIndent = rawLine.length - rawLine.trimStart().length
169
+ }
170
+ continue
171
+ }
172
+ if (!trimmed || trimmed.startsWith("#")) continue
173
+ const indent = rawLine.length - rawLine.trimStart().length
174
+ if (indent <= sectionIndent) break
175
+ const m = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/)
176
+ if (!m) continue
177
+ const key = m[1]
178
+ if (!(key in limits)) continue
179
+ const raw = unquoteYaml(stripInlineComment(m[2]).trim()).trim()
180
+ if (!/^-?\d+$/.test(raw) || parseInt(raw, 10) < 0) {
181
+ // invalid/negative -> keep default (Python warns on stderr)
182
+ debugLog("context", `invalid context_injection.${key} value: ${raw}; using default ${limits[key]}`)
183
+ continue
184
+ }
185
+ limits[key] = parseInt(raw, 10)
186
+ }
187
+ return limits
188
+ }
189
+
190
+ /** Tracks the running total of bytes emitted into the sub-agent context. */
191
+ class ContextBudget {
192
+ constructor(maxTotalBytes) {
193
+ this.maxTotalBytes = maxTotalBytes
194
+ this.used = 0
195
+ }
196
+
197
+ hasRoom(size) {
198
+ if (this.maxTotalBytes <= 0) return true
199
+ return this.used + size <= this.maxTotalBytes
200
+ }
201
+
202
+ add(size) {
203
+ this.used += size
204
+ }
205
+ }
206
+
207
+ function truncateNotice(path, cap) {
208
+ return `\n[Trellis: truncated at ${cap} bytes — read ${path} for the full content]`
209
+ }
210
+
211
+ function isBinaryContent(data) {
212
+ return data.includes(0) || !isUtf8(data)
213
+ }
214
+
215
+ function binaryNotice(path, size, reason) {
216
+ return `[Trellis: not inlined (binary file) — ${path} (${size} bytes): ${reason}]`
217
+ }
218
+
219
+ function indexNotice(path, size, reason) {
220
+ return `[Trellis: not inlined (total context limit reached) — ${path} (${size} bytes): ${reason}]`
221
+ }
222
+
223
+ /**
224
+ * Return an inlined `=== header ===` block, or degrade to an index
225
+ * notice once the total context budget is exhausted.
226
+ */
227
+ function budgetedBlock(budget, header, plainPath, content, reason, sizeForIndex) {
228
+ const block = `=== ${header} ===\n${content}`
229
+ const blockBytes = Buffer.byteLength(block, "utf-8")
230
+ if (!budget.hasRoom(blockBytes)) {
231
+ const notice = indexNotice(plainPath, sizeForIndex, reason)
232
+ budget.add(Buffer.byteLength(notice, "utf-8"))
233
+ return notice
234
+ }
235
+ budget.add(blockBytes)
236
+ return block
237
+ }
238
+
239
+ /** Read raw file bytes, return null if file doesn't exist. */
240
+ function readFileBytes(basePath, filePath) {
241
+ const fullPath = isAbsolute(filePath) ? filePath : join(basePath, filePath)
242
+ try {
243
+ if (!statSync(fullPath).isFile()) return null
244
+ } catch {
245
+ return null
246
+ }
247
+ try {
248
+ return readFileSync(fullPath)
249
+ } catch {
250
+ return null
251
+ }
252
+ }
253
+
254
+ /** Read a JSONL-referenced file, apply the per-file cap, then budget it. */
255
+ function materializeFile(basePath, filePath, reason, limits, budget) {
256
+ const data = readFileBytes(basePath, filePath)
257
+ if (data === null) return null
258
+
259
+ const size = data.length
260
+ if (isBinaryContent(data)) {
261
+ const notice = binaryNotice(filePath, size, reason)
262
+ budget.add(Buffer.byteLength(notice, "utf-8"))
263
+ return notice
264
+ }
265
+ const cap = limits.max_file_bytes
266
+ const truncated = truncateUtf8(data, cap)
267
+ let content = truncated.toString("utf-8")
268
+ if (truncated.length < size) content += truncateNotice(filePath, cap)
269
+
270
+ return budgetedBlock(budget, filePath, filePath, content, reason, size)
271
+ }
272
+
273
+ /**
274
+ * Read all .md files in a directory, applying the same per-file and
275
+ * total caps as a single-file JSONL entry.
276
+ */
277
+ function materializeDirectory(basePath, dirPath, reason, limits, budget, maxFiles = 20) {
278
+ const blocks = []
279
+ const fullPath = isAbsolute(dirPath) ? dirPath : join(basePath, dirPath)
280
+
281
+ let files
282
+ try {
283
+ if (!statSync(fullPath).isDirectory()) return blocks
284
+ files = readdirSync(fullPath)
285
+ .filter(f => f.endsWith(".md") && statSync(join(fullPath, f)).isFile())
286
+ .sort()
287
+ } catch {
288
+ return blocks
289
+ }
290
+
291
+ for (const filename of files.slice(0, maxFiles)) {
292
+ const relativePath = join(dirPath, filename)
293
+ const block = materializeFile(basePath, relativePath, reason, limits, budget)
294
+ if (block) blocks.push(block)
295
+ }
296
+ return blocks
297
+ }
298
+
299
+ /**
300
+ * Read a task artifact (prd/design/implement.md), apply the per-artifact
301
+ * cap, then budget it.
302
+ */
303
+ function materializeArtifact(basePath, filePath, headerLabel, reason, limits, budget) {
304
+ const data = readFileBytes(basePath, filePath)
305
+ if (data === null) return null
306
+
307
+ const size = data.length
308
+ const cap = limits.max_artifact_bytes
309
+ const truncated = truncateUtf8(data, cap)
310
+ let content = truncated.toString("utf-8")
311
+ if (truncated.length < size) content += truncateNotice(filePath, cap)
312
+
313
+ return budgetedBlock(budget, headerLabel, filePath, content, reason, size)
314
+ }
315
+
81
316
  /**
82
317
  * Trellis Context Manager
83
318
  */
@@ -282,44 +517,21 @@ export class TrellisContext {
282
517
  // JSONL Reading
283
518
  // ============================================================
284
519
 
285
- readDirectoryMdFiles(dirPath, maxFiles = 20) {
286
- const results = []
287
- const fullPath = join(this.directory, dirPath)
288
-
289
- if (!existsSync(fullPath)) {
290
- return results
291
- }
292
-
293
- try {
294
- const files = readdirSync(fullPath)
295
- .filter(f => f.endsWith(".md"))
296
- .sort()
297
- .slice(0, maxFiles)
298
-
299
- for (const filename of files) {
300
- const filePath = join(dirPath, filename)
301
- const content = this.readProjectFile(filePath)
302
- if (content) {
303
- results.push({ path: filePath, content })
304
- }
305
- }
306
- } catch {
307
- // Ignore directory read errors
308
- }
309
-
310
- return results
311
- }
312
-
313
520
  /**
314
- * Read a JSONL file and load referenced files/directories
521
+ * Read a JSONL file and materialize referenced files/directories into
522
+ * context blocks, applying per-file caps and the shared total budget
523
+ * (issue #441). Mirrors Python `_materialize_jsonl_entries`.
315
524
  * Supports:
316
525
  * {"file": "path/to/file.md", "reason": "..."}
317
526
  * {"file": "path/to/dir/", "type": "directory", "reason": "..."}
527
+ *
528
+ * Missing referenced files are skipped silently (Python `_materialize_file`
529
+ * returns None for them).
318
530
  */
319
- readJsonlWithFiles(jsonlPath) {
320
- const results = []
531
+ readJsonlWithFiles(jsonlPath, limits, budget) {
532
+ const blocks = []
321
533
  const content = this.readFile(jsonlPath)
322
- if (!content) return results
534
+ if (!content) return blocks
323
535
 
324
536
  for (const line of content.split("\n")) {
325
537
  if (!line.trim()) continue
@@ -327,28 +539,25 @@ export class TrellisContext {
327
539
  const item = JSON.parse(line)
328
540
  const file = item.file || item.path
329
541
  const entryType = item.type || "file"
542
+ const reason = item.reason || "-"
330
543
 
331
544
  if (!file) continue
332
545
 
333
546
  if (entryType === "directory") {
334
- const dirEntries = this.readDirectoryMdFiles(file)
335
- results.push(...dirEntries)
547
+ blocks.push(...materializeDirectory(this.directory, file, reason, limits, budget))
336
548
  } else {
337
- const fullPath = join(this.directory, file)
338
- const fileContent = this.readFile(fullPath)
339
- if (fileContent) {
340
- results.push({ path: file, content: fileContent })
341
- }
549
+ const block = materializeFile(this.directory, file, reason, limits, budget)
550
+ if (block) blocks.push(block)
342
551
  }
343
552
  } catch {
344
553
  // Ignore parse errors for individual lines
345
554
  }
346
555
  }
347
- return results
556
+ return blocks
348
557
  }
349
558
 
350
- buildContextFromEntries(entries) {
351
- return entries.map(e => `=== ${e.path} ===\n${e.content}`).join("\n\n")
559
+ buildContextFromEntries(blocks) {
560
+ return blocks.join("\n\n")
352
561
  }
353
562
  }
354
563
 
@@ -379,3 +588,14 @@ export const contextCollector = new ContextCollector()
379
588
 
380
589
  // Export debug log for plugins
381
590
  export { debugLog }
591
+
592
+ // Context injection limits (issue #441) — exported for plugins and tests
593
+ export {
594
+ DEFAULT_CONTEXT_INJECTION_LIMITS,
595
+ truncateUtf8,
596
+ readContextInjectionLimits,
597
+ ContextBudget,
598
+ materializeFile,
599
+ materializeDirectory,
600
+ materializeArtifact,
601
+ }
@@ -8,7 +8,13 @@
8
8
 
9
9
  import { existsSync, readdirSync } from "fs"
10
10
  import { join } from "path"
11
- import { TrellisContext, debugLog } from "../lib/trellis-context.js"
11
+ import {
12
+ TrellisContext,
13
+ debugLog,
14
+ readContextInjectionLimits,
15
+ ContextBudget,
16
+ materializeArtifact,
17
+ } from "../lib/trellis-context.js"
12
18
 
13
19
  // Supported subagent types
14
20
  const AGENTS_ALL = ["implement", "check", "research"]
@@ -29,64 +35,112 @@ function extractActiveTaskHint(prompt) {
29
35
  * Get context for implement agent. `taskDir` may be relative
30
36
  * (`.trellis/tasks/foo`) or absolute; both are resolved via
31
37
  * `ctx.resolveTaskDir`.
38
+ *
39
+ * Read order (mirrors Python `get_implement_context`):
40
+ * 1. All files in implement.jsonl (spec/research manifests)
41
+ * 2. prd.md (requirements)
42
+ * 3. design.md if present (technical design)
43
+ * 4. implement.md if present (execution plan)
44
+ * All blocks share one total budget (issue #441).
32
45
  */
33
46
  function getImplementContext(ctx, taskDir) {
34
47
  const parts = []
35
48
  const taskDirFull = ctx.resolveTaskDir(taskDir)
36
49
  if (!taskDirFull) return ""
37
50
 
38
- const jsonlPath = join(taskDirFull, "implement.jsonl")
39
- const entries = ctx.readJsonlWithFiles(jsonlPath)
40
- if (entries.length > 0) {
41
- parts.push(ctx.buildContextFromEntries(entries))
42
- }
43
-
44
- const prd = ctx.readFile(join(taskDirFull, "prd.md"))
45
- if (prd) {
46
- parts.push(`=== ${taskDir}/prd.md (Requirements) ===\n${prd}`)
47
- }
51
+ const limits = readContextInjectionLimits(ctx.directory)
52
+ const budget = new ContextBudget(limits.max_total_bytes)
48
53
 
49
- const design = ctx.readFile(join(taskDirFull, "design.md"))
50
- if (design) {
51
- parts.push(`=== ${taskDir}/design.md (Technical Design) ===\n${design}`)
54
+ // 1. Read implement.jsonl
55
+ const jsonlPath = join(taskDirFull, "implement.jsonl")
56
+ const blocks = ctx.readJsonlWithFiles(jsonlPath, limits, budget)
57
+ if (blocks.length > 0) {
58
+ parts.push(ctx.buildContextFromEntries(blocks))
52
59
  }
53
60
 
54
- const implementPlan = ctx.readFile(join(taskDirFull, "implement.md"))
55
- if (implementPlan) {
56
- parts.push(`=== ${taskDir}/implement.md (Execution Plan) ===\n${implementPlan}`)
57
- }
61
+ // 2. Requirements document
62
+ const prdBlock = materializeArtifact(
63
+ ctx.directory,
64
+ `${taskDir}/prd.md`,
65
+ `${taskDir}/prd.md (Requirements)`,
66
+ "Requirements document",
67
+ limits,
68
+ budget,
69
+ )
70
+ if (prdBlock) parts.push(prdBlock)
71
+
72
+ // 3. Technical design for complex tasks
73
+ const designBlock = materializeArtifact(
74
+ ctx.directory,
75
+ `${taskDir}/design.md`,
76
+ `${taskDir}/design.md (Technical Design)`,
77
+ "Technical design document",
78
+ limits,
79
+ budget,
80
+ )
81
+ if (designBlock) parts.push(designBlock)
82
+
83
+ // 4. Execution plan for complex tasks
84
+ const implementPlanBlock = materializeArtifact(
85
+ ctx.directory,
86
+ `${taskDir}/implement.md`,
87
+ `${taskDir}/implement.md (Execution Plan)`,
88
+ "Execution plan document",
89
+ limits,
90
+ budget,
91
+ )
92
+ if (implementPlanBlock) parts.push(implementPlanBlock)
58
93
 
59
94
  return parts.join("\n\n")
60
95
  }
61
96
 
62
97
  /**
63
98
  * Get context for check agent. `taskDir` may be relative or absolute.
99
+ * Same read order and shared budget as the implement context.
64
100
  */
65
101
  function getCheckContext(ctx, taskDir) {
66
102
  const parts = []
67
103
  const taskDirFull = ctx.resolveTaskDir(taskDir)
68
104
  if (!taskDirFull) return ""
69
105
 
70
- const jsonlPath = join(taskDirFull, "check.jsonl")
71
- const entries = ctx.readJsonlWithFiles(jsonlPath)
72
- if (entries.length > 0) {
73
- parts.push(ctx.buildContextFromEntries(entries))
74
- }
106
+ const limits = readContextInjectionLimits(ctx.directory)
107
+ const budget = new ContextBudget(limits.max_total_bytes)
75
108
 
76
- const prd = ctx.readFile(join(taskDirFull, "prd.md"))
77
- if (prd) {
78
- parts.push(`=== ${taskDir}/prd.md (Requirements) ===\n${prd}`)
79
- }
80
-
81
- const design = ctx.readFile(join(taskDirFull, "design.md"))
82
- if (design) {
83
- parts.push(`=== ${taskDir}/design.md (Technical Design) ===\n${design}`)
109
+ const jsonlPath = join(taskDirFull, "check.jsonl")
110
+ const blocks = ctx.readJsonlWithFiles(jsonlPath, limits, budget)
111
+ if (blocks.length > 0) {
112
+ parts.push(ctx.buildContextFromEntries(blocks))
84
113
  }
85
114
 
86
- const implementPlan = ctx.readFile(join(taskDirFull, "implement.md"))
87
- if (implementPlan) {
88
- parts.push(`=== ${taskDir}/implement.md (Execution Plan) ===\n${implementPlan}`)
89
- }
115
+ const prdBlock = materializeArtifact(
116
+ ctx.directory,
117
+ `${taskDir}/prd.md`,
118
+ `${taskDir}/prd.md (Requirements)`,
119
+ "Requirements document",
120
+ limits,
121
+ budget,
122
+ )
123
+ if (prdBlock) parts.push(prdBlock)
124
+
125
+ const designBlock = materializeArtifact(
126
+ ctx.directory,
127
+ `${taskDir}/design.md`,
128
+ `${taskDir}/design.md (Technical Design)`,
129
+ "Technical design document",
130
+ limits,
131
+ budget,
132
+ )
133
+ if (designBlock) parts.push(designBlock)
134
+
135
+ const implementPlanBlock = materializeArtifact(
136
+ ctx.directory,
137
+ `${taskDir}/implement.md`,
138
+ `${taskDir}/implement.md (Execution Plan)`,
139
+ "Execution plan document",
140
+ limits,
141
+ budget,
142
+ )
143
+ if (implementPlanBlock) parts.push(implementPlanBlock)
90
144
 
91
145
  return parts.join("\n\n")
92
146
  }
@@ -31,6 +31,82 @@ import { TrellisContext, debugLog, isTrellisSubagent } from "../lib/trellis-cont
31
31
  // (so "in-review" / "blocked-by-team" work alongside "in_progress").
32
32
  const TAG_RE = /\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g
33
33
 
34
+ // Escape hatch for the per-turn breadcrumb (issue #427). Mirrors
35
+ // `common.config.get_prompt_injection_config()` / the shared Python hook's
36
+ // `_resolve_skip_keyword()` + `prompt_has_skip_keyword()`.
37
+ const DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis"
38
+
39
+ function stripInlineComment(value) {
40
+ let inQuote = null
41
+ for (let idx = 0; idx < value.length; idx++) {
42
+ const ch = value[idx]
43
+ if (inQuote) {
44
+ if (ch === inQuote) inQuote = null
45
+ continue
46
+ }
47
+ if (ch === '"' || ch === "'") {
48
+ inQuote = ch
49
+ continue
50
+ }
51
+ if (ch === "#" && (idx === 0 || /\s/.test(value[idx - 1]))) return value.slice(0, idx)
52
+ }
53
+ return value
54
+ }
55
+
56
+ function unquoteYaml(s) {
57
+ if (s.length >= 2 && s[0] === s[s.length - 1] && (s[0] === '"' || s[0] === "'")) return s.slice(1, -1)
58
+ return s
59
+ }
60
+
61
+ /**
62
+ * Line-based parser for ONLY the `prompt_injection:` block of
63
+ * `.trellis/config.yaml`. Not a general YAML parser — mirrors
64
+ * `common.config.get_prompt_injection_config()` semantics for this section
65
+ * only (missing key keeps the default; non-string value keeps the default).
66
+ */
67
+ function readSkipKeyword(directory) {
68
+ const path = join(directory, ".trellis", "config.yaml")
69
+ if (!existsSync(path)) return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD
70
+ let text
71
+ try {
72
+ text = readFileSync(path, "utf-8")
73
+ } catch {
74
+ return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD
75
+ }
76
+
77
+ let inSection = false
78
+ let sectionIndent = -1
79
+ for (const rawLine of text.split(/\r?\n/)) {
80
+ const trimmed = rawLine.trim()
81
+ if (!inSection) {
82
+ if (/^prompt_injection\s*:\s*(#.*)?$/.test(trimmed)) {
83
+ inSection = true
84
+ sectionIndent = rawLine.length - rawLine.trimStart().length
85
+ }
86
+ continue
87
+ }
88
+ if (!trimmed || trimmed.startsWith("#")) continue
89
+ const indent = rawLine.length - rawLine.trimStart().length
90
+ if (indent <= sectionIndent) break
91
+ const m = trimmed.match(/^skip_keyword\s*:\s*(.*)$/)
92
+ if (!m) continue
93
+ return unquoteYaml(stripInlineComment(m[1]).trim())
94
+ }
95
+ return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD
96
+ }
97
+
98
+ /**
99
+ * Case-insensitive, word-boundary match of `keyword` in `text`. Hyphen
100
+ * counts as a word char so "no-trellisx" / "xno-trellis" don't match, but
101
+ * punctuation/whitespace boundaries do. Empty keyword never matches.
102
+ */
103
+ function promptHasSkipKeyword(text, keyword) {
104
+ if (!keyword || typeof text !== "string") return false
105
+ const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
106
+ const pattern = new RegExp(`(?<![\\w-])${escaped}(?![\\w-])`, "i")
107
+ return pattern.test(text)
108
+ }
109
+
34
110
  /**
35
111
  * Parse workflow.md for [workflow-state:STATUS] blocks.
36
112
  *
@@ -124,18 +200,27 @@ export default async ({ directory }) => {
124
200
  if (!ctx.isTrellisProject()) {
125
201
  return
126
202
  }
203
+
204
+ const parts = output?.parts || []
205
+ const textPartIndex = parts.findIndex(
206
+ p => p.type === "text" && p.text !== undefined,
207
+ )
208
+ const originalText = textPartIndex !== -1 ? (parts[textPartIndex].text || "") : ""
209
+
210
+ // Escape hatch (issue #427): user prompt contains the skip keyword
211
+ // as a standalone word — emit nothing for this turn only.
212
+ if (promptHasSkipKeyword(originalText, readSkipKeyword(directory))) {
213
+ debugLog("workflow-state", "Skipping turn: skip keyword present in prompt")
214
+ return
215
+ }
216
+
127
217
  const templates = loadBreadcrumbs(directory)
128
218
  const task = getActiveTask(ctx, input)
129
219
  const breadcrumb = task
130
220
  ? buildBreadcrumb(task.id, task.status, templates, task.source)
131
221
  : buildBreadcrumb(null, "no_task", templates)
132
222
 
133
- const parts = output?.parts || []
134
- const textPartIndex = parts.findIndex(
135
- p => p.type === "text" && p.text !== undefined,
136
- )
137
223
  if (textPartIndex !== -1) {
138
- const originalText = parts[textPartIndex].text || ""
139
224
  parts[textPartIndex].text = `${breadcrumb}\n\n${originalText}`
140
225
  } else {
141
226
  parts.unshift({ type: "text", text: breadcrumb })