@mindfoldhq/trellis 0.6.8 → 0.6.9

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 (90) 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.9.json +9 -0
  43. package/dist/templates/codex/agents/trellis-check.toml +2 -0
  44. package/dist/templates/codex/agents/trellis-implement.toml +2 -0
  45. package/dist/templates/codex/agents/trellis-research.toml +2 -0
  46. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +1 -1
  47. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +1 -1
  48. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +4 -2
  49. package/dist/templates/kimi/agents/trellis-research.md +7 -3
  50. package/dist/templates/omp/extensions/trellis/index.ts.txt +114 -4
  51. package/dist/templates/opencode/lib/trellis-context.js +263 -43
  52. package/dist/templates/opencode/plugins/inject-subagent-context.js +89 -35
  53. package/dist/templates/opencode/plugins/inject-workflow-state.js +90 -5
  54. package/dist/templates/pi/extensions/trellis/index.ts.txt +270 -22
  55. package/dist/templates/shared-hooks/inject-subagent-context.py +311 -83
  56. package/dist/templates/shared-hooks/inject-workflow-state.py +36 -1
  57. package/dist/templates/snow/SNOW.md +86 -0
  58. package/dist/templates/snow/agents/trellis-check.md +135 -0
  59. package/dist/templates/snow/agents/trellis-implement.md +153 -0
  60. package/dist/templates/snow/agents/trellis-research.md +114 -0
  61. package/dist/templates/snow/hooks/beforeSubAgentStart.json +15 -0
  62. package/dist/templates/snow/hooks/onSessionStart.json +15 -0
  63. package/dist/templates/snow/hooks/onUserMessage.json +15 -0
  64. package/dist/templates/snow/hooks/write-trellis-context.py +644 -0
  65. package/dist/templates/snow/index.d.ts +26 -0
  66. package/dist/templates/snow/index.d.ts.map +1 -0
  67. package/dist/templates/snow/index.js +36 -0
  68. package/dist/templates/snow/index.js.map +1 -0
  69. package/dist/templates/template-utils.d.ts.map +1 -1
  70. package/dist/templates/template-utils.js +5 -1
  71. package/dist/templates/template-utils.js.map +1 -1
  72. package/dist/templates/trellis/config.yaml +46 -0
  73. package/dist/templates/trellis/gitattributes.txt +9 -0
  74. package/dist/templates/trellis/index.d.ts +3 -1
  75. package/dist/templates/trellis/index.d.ts.map +1 -1
  76. package/dist/templates/trellis/index.js +3 -1
  77. package/dist/templates/trellis/index.js.map +1 -1
  78. package/dist/templates/trellis/scripts/add_session.py +94 -28
  79. package/dist/templates/trellis/scripts/common/active_task.py +4 -0
  80. package/dist/templates/trellis/scripts/common/config.py +89 -2
  81. package/dist/templates/trellis/scripts/common/task_context.py +82 -6
  82. package/dist/templates/trellis/scripts/common/task_store.py +63 -1
  83. package/dist/templates/trellis/scripts/common/trellis_config.py +2 -1
  84. package/dist/templates/trellis/scripts/task.py +21 -2
  85. package/dist/templates/trellis/workflow.md +14 -14
  86. package/dist/types/ai-tools.d.ts +3 -3
  87. package/dist/types/ai-tools.d.ts.map +1 -1
  88. package/dist/types/ai-tools.js +38 -0
  89. package/dist/types/ai-tools.js.map +1 -1
  90. package/package.json +2 -2
@@ -148,65 +148,211 @@ def get_current_task(
148
148
  return active.task_path
149
149
 
150
150
 
151
- def read_file_content(base_path: str, file_path: str) -> str | None:
152
- """Read file content, return None if file doesn't exist"""
151
+ # =============================================================================
152
+ # Context Injection Limits (issue #441)
153
+ #
154
+ # Notice text and behavior mirrored byte-for-byte in the Pi TS extension
155
+ # (templates/pi/extensions/trellis/index.ts.txt). Changing wording here
156
+ # requires changing it there too.
157
+ # =============================================================================
158
+
159
+ DEFAULT_MAX_FILE_BYTES = 32768
160
+ DEFAULT_MAX_ARTIFACT_BYTES = 65536
161
+ DEFAULT_MAX_TOTAL_BYTES = 131072
162
+
163
+ DEFAULT_LIMITS: dict[str, int] = {
164
+ "max_file_bytes": DEFAULT_MAX_FILE_BYTES,
165
+ "max_artifact_bytes": DEFAULT_MAX_ARTIFACT_BYTES,
166
+ "max_total_bytes": DEFAULT_MAX_TOTAL_BYTES,
167
+ }
168
+
169
+
170
+ def _get_limits(repo_root: str) -> dict[str, int]:
171
+ """Load context-injection byte limits from config.yaml, with safe fallback."""
172
+ scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts"
173
+ if str(scripts_dir) not in sys.path:
174
+ sys.path.insert(0, str(scripts_dir))
175
+ try:
176
+ from common.config import get_context_injection_limits # type: ignore[import-not-found]
177
+
178
+ return get_context_injection_limits(Path(repo_root))
179
+ except Exception:
180
+ return dict(DEFAULT_LIMITS)
181
+
182
+
183
+ def truncate_utf8(data: bytes, cap: int) -> bytes:
184
+ """Truncate ``data`` to at most ``cap`` bytes without splitting a UTF-8
185
+ multi-byte sequence.
186
+
187
+ ``cap <= 0`` means "no limit" — returns ``data`` unchanged.
188
+ """
189
+ if cap <= 0 or len(data) <= cap:
190
+ return data
191
+
192
+ truncated = data[:cap]
193
+ i = len(truncated)
194
+ # Back off over continuation bytes (10xxxxxx) to find the lead byte.
195
+ while i > 0 and (truncated[i - 1] & 0xC0) == 0x80:
196
+ i -= 1
197
+ if i == 0:
198
+ return b""
199
+
200
+ lead = truncated[i - 1]
201
+ if lead & 0x80:
202
+ if (lead & 0xE0) == 0xC0:
203
+ seq_len = 2
204
+ elif (lead & 0xF0) == 0xE0:
205
+ seq_len = 3
206
+ elif (lead & 0xF8) == 0xF0:
207
+ seq_len = 4
208
+ else:
209
+ seq_len = 1
210
+ # Drop the lead byte too if its full sequence didn't fit.
211
+ if (i - 1) + seq_len > len(truncated):
212
+ i -= 1
213
+
214
+ return truncated[:i]
215
+
216
+
217
+ class _Budget:
218
+ """Tracks the running total of bytes emitted into the sub-agent context."""
219
+
220
+ def __init__(self, max_total_bytes: int) -> None:
221
+ self.max_total_bytes = max_total_bytes
222
+ self.used = 0
223
+
224
+ def has_room(self, size: int) -> bool:
225
+ if self.max_total_bytes <= 0:
226
+ return True
227
+ return self.used + size <= self.max_total_bytes
228
+
229
+ def add(self, size: int) -> None:
230
+ self.used += size
231
+
232
+
233
+ def _read_file_bytes(base_path: str, file_path: str) -> bytes | None:
234
+ """Read raw file bytes, return None if file doesn't exist."""
153
235
  full_path = os.path.join(base_path, file_path)
154
236
  if os.path.exists(full_path) and os.path.isfile(full_path):
155
237
  try:
156
- with open(full_path, "r", encoding="utf-8") as f:
238
+ with open(full_path, "rb") as f:
157
239
  return f.read()
158
240
  except Exception:
159
241
  return None
160
242
  return None
161
243
 
162
244
 
163
- def read_directory_contents(
164
- base_path: str, dir_path: str, max_files: int = 20
165
- ) -> list[tuple[str, str]]:
166
- """
167
- Read all .md files in a directory
245
+ def _truncate_notice(path: str, cap: int) -> str:
246
+ return f"\n[Trellis: truncated at {cap} bytes read {path} for the full content]"
168
247
 
169
- Args:
170
- base_path: Base path (usually repo_root)
171
- dir_path: Directory relative path
172
- max_files: Max files to read (prevent huge directories)
173
248
 
174
- Returns:
175
- [(file_path, content), ...]
176
- """
249
+ def _is_binary_content(data: bytes) -> bool:
250
+ """Return True when raw bytes should not be decoded into model context."""
251
+ if b"\x00" in data:
252
+ return True
253
+ try:
254
+ data.decode("utf-8", errors="strict")
255
+ except UnicodeDecodeError:
256
+ return True
257
+ return False
258
+
259
+
260
+ def _binary_notice(path: str, size: int, reason: str) -> str:
261
+ return (
262
+ f"[Trellis: not inlined (binary file) — "
263
+ f"{path} ({size} bytes): {reason}]"
264
+ )
265
+
266
+
267
+ def _index_notice(path: str, size: int, reason: str) -> str:
268
+ return (
269
+ f"[Trellis: not inlined (total context limit reached) — "
270
+ f"{path} ({size} bytes): {reason}]"
271
+ )
272
+
273
+
274
+ def _budgeted_block(
275
+ budget: _Budget,
276
+ header: str,
277
+ plain_path: str,
278
+ content: str,
279
+ reason: str,
280
+ size_for_index: int,
281
+ ) -> str:
282
+ """Return an inlined ``=== header ===`` block, or degrade to an index
283
+ notice once the total context budget is exhausted."""
284
+ block = f"=== {header} ===\n{content}"
285
+ block_bytes = len(block.encode("utf-8"))
286
+ if not budget.has_room(block_bytes):
287
+ notice = _index_notice(plain_path, size_for_index, reason)
288
+ budget.add(len(notice.encode("utf-8")))
289
+ return notice
290
+ budget.add(block_bytes)
291
+ return block
292
+
293
+
294
+ def _materialize_file(
295
+ base_path: str,
296
+ file_path: str,
297
+ reason: str,
298
+ limits: dict[str, int],
299
+ budget: _Budget,
300
+ ) -> str | None:
301
+ """Read a JSONL-referenced file, apply the per-file cap, then budget it."""
302
+ data = _read_file_bytes(base_path, file_path)
303
+ if data is None:
304
+ return None
305
+
306
+ size = len(data)
307
+ if _is_binary_content(data):
308
+ notice = _binary_notice(file_path, size, reason)
309
+ budget.add(len(notice.encode("utf-8")))
310
+ return notice
311
+
312
+ cap = limits["max_file_bytes"]
313
+ truncated_bytes = truncate_utf8(data, cap)
314
+ content = truncated_bytes.decode("utf-8", errors="replace")
315
+ if len(truncated_bytes) < size:
316
+ content += _truncate_notice(file_path, cap)
317
+
318
+ return _budgeted_block(budget, file_path, file_path, content, reason, size)
319
+
320
+
321
+ def _materialize_directory(
322
+ base_path: str,
323
+ dir_path: str,
324
+ reason: str,
325
+ limits: dict[str, int],
326
+ budget: _Budget,
327
+ max_files: int = 20,
328
+ ) -> list[str]:
329
+ """Read all .md files in a directory, applying the same per-file and
330
+ total caps as a single-file JSONL entry."""
177
331
  full_path = os.path.join(base_path, dir_path)
178
332
  if not os.path.exists(full_path) or not os.path.isdir(full_path):
179
333
  return []
180
334
 
181
- results = []
335
+ blocks: list[str] = []
182
336
  try:
183
- # Only read .md files, sorted by filename
184
337
  md_files = sorted(
185
- [
186
- f
187
- for f in os.listdir(full_path)
188
- if f.endswith(".md") and os.path.isfile(os.path.join(full_path, f))
189
- ]
338
+ f
339
+ for f in os.listdir(full_path)
340
+ if f.endswith(".md") and os.path.isfile(os.path.join(full_path, f))
190
341
  )
191
-
192
342
  for filename in md_files[:max_files]:
193
- file_full_path = os.path.join(full_path, filename)
194
343
  relative_path = os.path.join(dir_path, filename)
195
- try:
196
- with open(file_full_path, "r", encoding="utf-8") as f:
197
- content = f.read()
198
- results.append((relative_path, content))
199
- except Exception:
200
- continue
344
+ block = _materialize_file(base_path, relative_path, reason, limits, budget)
345
+ if block:
346
+ blocks.append(block)
201
347
  except Exception:
202
348
  pass
203
349
 
204
- return results
350
+ return blocks
205
351
 
206
352
 
207
- def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]:
353
+ def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[dict]:
208
354
  """
209
- Read all file/directory contents referenced in jsonl file
355
+ Parse all file/directory entries referenced in a jsonl context file.
210
356
 
211
357
  Schema:
212
358
  {"file": "path/to/file.md", "reason": "..."}
@@ -219,7 +365,7 @@ def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]
219
365
  emitted so the operator can debug missing context.
220
366
 
221
367
  Returns:
222
- [(path, content), ...]
368
+ [{"file": path, "type": "file" | "directory", "reason": reason}, ...]
223
369
  """
224
370
  full_path = os.path.join(base_path, jsonl_path)
225
371
  if not os.path.exists(full_path):
@@ -230,7 +376,7 @@ def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]
230
376
  )
231
377
  return []
232
378
 
233
- results = []
379
+ entries: list[dict] = []
234
380
  saw_real_entry = False
235
381
  try:
236
382
  with open(full_path, "r", encoding="utf-8") as f:
@@ -241,22 +387,19 @@ def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]
241
387
  try:
242
388
  item = json.loads(line)
243
389
  file_path = item.get("file") or item.get("path")
244
- entry_type = item.get("type", "file")
245
390
 
246
391
  if not file_path:
247
392
  # Seed / comment row — skip silently
248
393
  continue
249
394
 
250
395
  saw_real_entry = True
251
- if entry_type == "directory":
252
- # Read all .md files in directory
253
- dir_contents = read_directory_contents(base_path, file_path)
254
- results.extend(dir_contents)
255
- else:
256
- # Read single file
257
- content = read_file_content(base_path, file_path)
258
- if content:
259
- results.append((file_path, content))
396
+ entries.append(
397
+ {
398
+ "file": file_path,
399
+ "type": item.get("type", "file"),
400
+ "reason": item.get("reason") or "-",
401
+ }
402
+ )
260
403
  except json.JSONDecodeError:
261
404
  continue
262
405
  except Exception:
@@ -270,23 +413,69 @@ def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]
270
413
  file=sys.stderr,
271
414
  )
272
415
 
273
- return results
274
-
275
-
416
+ return entries
417
+
418
+
419
+ def _materialize_jsonl_entries(
420
+ base_path: str, jsonl_path: str, limits: dict[str, int], budget: _Budget
421
+ ) -> list[str]:
422
+ """Materialize every entry in a jsonl context file into context blocks,
423
+ applying per-file and total budget caps."""
424
+ blocks: list[str] = []
425
+ for entry in read_jsonl_entries(base_path, jsonl_path):
426
+ if entry["type"] == "directory":
427
+ blocks.extend(
428
+ _materialize_directory(
429
+ base_path, entry["file"], entry["reason"], limits, budget
430
+ )
431
+ )
432
+ else:
433
+ block = _materialize_file(
434
+ base_path, entry["file"], entry["reason"], limits, budget
435
+ )
436
+ if block:
437
+ blocks.append(block)
438
+ return blocks
276
439
 
277
440
 
278
- def get_agent_context(repo_root: str, task_dir: str, agent_type: str) -> str:
441
+ def get_agent_context(
442
+ repo_root: str,
443
+ task_dir: str,
444
+ agent_type: str,
445
+ limits: dict[str, int],
446
+ budget: _Budget,
447
+ ) -> str:
279
448
  """
280
449
  Get context from {agent_type}.jsonl for the specified agent.
281
450
  Only reads implement.jsonl or check.jsonl (the two JSONL files the task system creates).
282
451
  """
283
- context_parts = []
284
-
285
452
  agent_jsonl = f"{task_dir}/{agent_type}.jsonl"
286
- for file_path, content in read_jsonl_entries(repo_root, agent_jsonl):
287
- context_parts.append(f"=== {file_path} ===\n{content}")
453
+ blocks = _materialize_jsonl_entries(repo_root, agent_jsonl, limits, budget)
454
+ return "\n\n".join(blocks)
288
455
 
289
- return "\n\n".join(context_parts)
456
+
457
+ def _materialize_artifact(
458
+ base_path: str,
459
+ file_path: str,
460
+ header_label: str,
461
+ reason: str,
462
+ limits: dict[str, int],
463
+ budget: _Budget,
464
+ ) -> str | None:
465
+ """Read a task artifact (prd/design/implement.md), apply the per-artifact
466
+ cap, then budget it."""
467
+ data = _read_file_bytes(base_path, file_path)
468
+ if data is None:
469
+ return None
470
+
471
+ size = len(data)
472
+ cap = limits["max_artifact_bytes"]
473
+ truncated_bytes = truncate_utf8(data, cap)
474
+ content = truncated_bytes.decode("utf-8", errors="replace")
475
+ if len(truncated_bytes) < size:
476
+ content += _truncate_notice(file_path, cap)
477
+
478
+ return _budgeted_block(budget, header_label, file_path, content, reason, size)
290
479
 
291
480
 
292
481
  def get_implement_context(repo_root: str, task_dir: str) -> str:
@@ -299,31 +488,50 @@ def get_implement_context(repo_root: str, task_dir: str) -> str:
299
488
  3. design.md if present (technical design)
300
489
  4. implement.md if present (execution plan)
301
490
  """
491
+ limits = _get_limits(repo_root)
492
+ budget = _Budget(limits["max_total_bytes"])
302
493
  context_parts = []
303
494
 
304
495
  # 1. Read implement.jsonl
305
- base_context = get_agent_context(repo_root, task_dir, "implement")
496
+ base_context = get_agent_context(repo_root, task_dir, "implement", limits, budget)
306
497
  if base_context:
307
498
  context_parts.append(base_context)
308
499
 
309
500
  # 2. Requirements document
310
- prd_content = read_file_content(repo_root, f"{task_dir}/prd.md")
311
- if prd_content:
312
- context_parts.append(f"=== {task_dir}/prd.md (Requirements) ===\n{prd_content}")
501
+ prd_block = _materialize_artifact(
502
+ repo_root,
503
+ f"{task_dir}/prd.md",
504
+ f"{task_dir}/prd.md (Requirements)",
505
+ "Requirements document",
506
+ limits,
507
+ budget,
508
+ )
509
+ if prd_block:
510
+ context_parts.append(prd_block)
313
511
 
314
512
  # 3. Technical design for complex tasks
315
- design_content = read_file_content(repo_root, f"{task_dir}/design.md")
316
- if design_content:
317
- context_parts.append(
318
- f"=== {task_dir}/design.md (Technical Design) ===\n{design_content}"
319
- )
513
+ design_block = _materialize_artifact(
514
+ repo_root,
515
+ f"{task_dir}/design.md",
516
+ f"{task_dir}/design.md (Technical Design)",
517
+ "Technical design document",
518
+ limits,
519
+ budget,
520
+ )
521
+ if design_block:
522
+ context_parts.append(design_block)
320
523
 
321
524
  # 4. Execution plan for complex tasks
322
- implement_plan_content = read_file_content(repo_root, f"{task_dir}/implement.md")
323
- if implement_plan_content:
324
- context_parts.append(
325
- f"=== {task_dir}/implement.md (Execution Plan) ===\n{implement_plan_content}"
326
- )
525
+ implement_plan_block = _materialize_artifact(
526
+ repo_root,
527
+ f"{task_dir}/implement.md",
528
+ f"{task_dir}/implement.md (Execution Plan)",
529
+ "Execution plan document",
530
+ limits,
531
+ budget,
532
+ )
533
+ if implement_plan_block:
534
+ context_parts.append(implement_plan_block)
327
535
 
328
536
  return "\n\n".join(context_parts)
329
537
 
@@ -332,26 +540,46 @@ def get_check_context(repo_root: str, task_dir: str) -> str:
332
540
  """
333
541
  Context for Check Agent: check.jsonl + task artifacts.
334
542
  """
543
+ limits = _get_limits(repo_root)
544
+ budget = _Budget(limits["max_total_bytes"])
335
545
  context_parts = []
336
546
 
337
- for file_path, content in read_jsonl_entries(repo_root, f"{task_dir}/check.jsonl"):
338
- context_parts.append(f"=== {file_path} ===\n{content}")
547
+ base_context = get_agent_context(repo_root, task_dir, "check", limits, budget)
548
+ if base_context:
549
+ context_parts.append(base_context)
339
550
 
340
- prd_content = read_file_content(repo_root, f"{task_dir}/prd.md")
341
- if prd_content:
342
- context_parts.append(f"=== {task_dir}/prd.md (Requirements) ===\n{prd_content}")
551
+ prd_block = _materialize_artifact(
552
+ repo_root,
553
+ f"{task_dir}/prd.md",
554
+ f"{task_dir}/prd.md (Requirements)",
555
+ "Requirements document",
556
+ limits,
557
+ budget,
558
+ )
559
+ if prd_block:
560
+ context_parts.append(prd_block)
343
561
 
344
- design_content = read_file_content(repo_root, f"{task_dir}/design.md")
345
- if design_content:
346
- context_parts.append(
347
- f"=== {task_dir}/design.md (Technical Design) ===\n{design_content}"
348
- )
562
+ design_block = _materialize_artifact(
563
+ repo_root,
564
+ f"{task_dir}/design.md",
565
+ f"{task_dir}/design.md (Technical Design)",
566
+ "Technical design document",
567
+ limits,
568
+ budget,
569
+ )
570
+ if design_block:
571
+ context_parts.append(design_block)
349
572
 
350
- implement_plan_content = read_file_content(repo_root, f"{task_dir}/implement.md")
351
- if implement_plan_content:
352
- context_parts.append(
353
- f"=== {task_dir}/implement.md (Execution Plan) ===\n{implement_plan_content}"
354
- )
573
+ implement_plan_block = _materialize_artifact(
574
+ repo_root,
575
+ f"{task_dir}/implement.md",
576
+ f"{task_dir}/implement.md (Execution Plan)",
577
+ "Execution plan document",
578
+ limits,
579
+ budget,
580
+ )
581
+ if implement_plan_block:
582
+ context_parts.append(implement_plan_block)
355
583
 
356
584
  return "\n\n".join(context_parts)
357
585
 
@@ -228,6 +228,38 @@ def _read_trellis_config(root: Path) -> dict:
228
228
  return {}
229
229
 
230
230
 
231
+ DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis"
232
+
233
+
234
+ def _resolve_skip_keyword(config: dict) -> str:
235
+ """Read `prompt_injection.skip_keyword` from parsed .trellis/config.yaml.
236
+
237
+ Mirrors `common.config.get_prompt_injection_config()`. Defaults to
238
+ "no-trellis"; "" disables the escape hatch entirely. A non-string value
239
+ falls back to the default.
240
+ """
241
+ if isinstance(config, dict):
242
+ section = config.get("prompt_injection")
243
+ if isinstance(section, dict):
244
+ raw = section.get("skip_keyword", DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD)
245
+ if isinstance(raw, str):
246
+ return raw
247
+ return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD
248
+
249
+
250
+ def prompt_has_skip_keyword(prompt: str, keyword: str) -> bool:
251
+ """Case-insensitive, word-boundary match of `keyword` in `prompt`.
252
+
253
+ Hyphen counts as a word char so "no-trellisx" / "xno-trellis" /
254
+ "foo-no-trellis" don't match, but punctuation/whitespace boundaries do.
255
+ Empty keyword never matches (disables the escape hatch).
256
+ """
257
+ if not keyword or not isinstance(prompt, str):
258
+ return False
259
+ pattern = r"(?<![\w-])" + re.escape(keyword) + r"(?![\w-])"
260
+ return re.search(pattern, prompt, re.IGNORECASE) is not None
261
+
262
+
231
263
  def _resolve_codex_dispatch_mode(config: dict) -> str:
232
264
  """Normalize `codex.dispatch_mode` from .trellis/config.yaml to "auto" or "inline".
233
265
 
@@ -375,9 +407,12 @@ def main() -> int:
375
407
  if root is None:
376
408
  return 0 # not a Trellis project
377
409
 
410
+ config = _read_trellis_config(root)
411
+ if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)):
412
+ return 0 # user opted out of the per-turn breadcrumb for this turn
413
+
378
414
  templates = load_breadcrumbs(root)
379
415
  platform = _detect_platform(data)
380
- config = _read_trellis_config(root)
381
416
  task = get_active_task(root, data)
382
417
  if task is None:
383
418
  # No active task — still emit a breadcrumb nudging AI toward
@@ -0,0 +1,86 @@
1
+ # Trellis on Snow CLI
2
+
3
+ Snow is a **class-1** Trellis host: auto context inject + project agent discovery +
4
+ `beforeSubAgentStart` prompt enrichment.
5
+
6
+ | Capability | Status |
7
+ | ------------------------------------------------- | ---------------------------------------------------------------------------- |
8
+ | Skills (`.snow/skills/trellis-*/SKILL.md`) | Works |
9
+ | Prompt commands (`.snow/commands/trellis-*.json`) | Works (`/trellis-continue`, `/trellis-finish-work`, …) |
10
+ | Context hooks (`.snow/hooks/`) | Inject model context via stdout JSON + write `.snow/log/trellis-context.txt` |
11
+ | Project agents (`.snow/agents/*.md`) | Auto-discovered by Snow (`#trellis-implement`, …) |
12
+ | `beforeSubAgentStart` | Injects active-task breadcrumb into sub-agent prompts |
13
+ | `trellis-start` | Optional — session hooks replace the old manual ritual |
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ trellis init --snow -u your-name
19
+ snow
20
+ ```
21
+
22
+ In Snow:
23
+
24
+ 1. Open a session in the project root — `onSessionStart` injects Trellis context automatically.
25
+ 2. Dispatch implement/check/research (project agents under `.snow/agents/`). Prefer prompt first line:
26
+
27
+ ```text
28
+ Active task: .trellis/tasks/<id>
29
+ ```
30
+
31
+ 3. Optional: `/trellis-continue` / `/trellis-finish-work`, or `skill-execute` on `trellis-*` skills.
32
+ 4. Debug injects: set `SNOW_DEBUG_HOOKS=1` and inspect `.snow/log/hooks-inject.txt`.
33
+
34
+ ## Agents
35
+
36
+ Snow loads project agents from `.snow/agents/**/*.md` (priority over `~/.snow/sub-agents.json`).
37
+ Primary path: project agent discovery — no manual merge required. Do not use legacy sub-agent JSON merge files.
38
+
39
+ Context loading is class-1 hook inject (`beforeSubAgentStart` / session / user). Agents are **not** shipped with class-2 pull-based prelude text. Hooks already inject context; agents still re-read task artifacts for correctness.
40
+
41
+ ## Tool names (Snow-native)
42
+
43
+ - `filesystem-read` / `filesystem-create` / `filesystem-replaceedit` / `filesystem-edit`
44
+ - `terminal-execute`
45
+ - `ace-search` / `codebase-search`
46
+ - `todo-manage` / `notebook-manage`
47
+ - `skill-execute`
48
+ - `websearch-search` / `websearch-fetch` (research)
49
+ - `ide-get_diagnostics`
50
+
51
+ ## Hook protocol
52
+
53
+ Session / user / sub-agent hooks emit:
54
+
55
+ ```json
56
+ { "additionalContext": "...", "display": "..." }
57
+ ```
58
+
59
+ - exit 0 + JSON → inject (prepend); UI bubble keeps user original text
60
+ - exit 1 on `onUserMessage` → replace (not used by Trellis)
61
+ - non-JSON stdout → ignored
62
+
63
+ Hook modes (same script, different depth):
64
+
65
+ | Hook | argv mode | Payload |
66
+ | --------------------- | ---------- | ---------------------------------------------------------------- |
67
+ | `onSessionStart` | `session` | full (~7.5KB): task.py, artifacts, prd summary, workflow/session |
68
+ | `onUserMessage` | `user` | compact (~2.8KB): task.py + artifact presence only |
69
+ | `beforeSubAgentStart` | `subagent` | full + agent-kind tailoring (implement/check/research) |
70
+
71
+ ## Session identity (multi-session)
72
+
73
+ Snow injects these env vars into hook commands, `terminal-execute`, bash mode, and sub-agent children:
74
+
75
+ | Variable | Example | Purpose |
76
+ | -------------------- | ------------------- | ----------------------------------------- |
77
+ | `SNOW_SESSION_ID` | `c2343752-...` | Native Snow session uuid |
78
+ | `TRELLIS_CONTEXT_ID` | `snow-c2343752-...` | Preferred Trellis active-task context key |
79
+ | `SNOW_CWD` | project root | Working directory for hooks/tools |
80
+ | `SNOW_PLATFORM` | `snow` | Platform tag |
81
+
82
+ Notes:
83
+
84
+ - `TRELLIS_CONTEXT_ID` wins when already set (explicit override).
85
+ - Otherwise Trellis resolves `SNOW_SESSION_ID` via `active_task.py` as platform `snow`.
86
+ - Hook stdin may also include dual keys: `sessionId` / `session_id`.