@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.
- package/dist/cli/index.js +1 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/channel/agent-loader.d.ts +2 -2
- package/dist/commands/channel/agent-loader.d.ts.map +1 -1
- package/dist/commands/channel/agent-loader.js +9 -5
- package/dist/commands/channel/agent-loader.js.map +1 -1
- package/dist/commands/channel/context-loader.d.ts +1 -1
- package/dist/commands/channel/context-loader.d.ts.map +1 -1
- package/dist/commands/channel/context-loader.js +36 -15
- package/dist/commands/channel/context-loader.js.map +1 -1
- package/dist/commands/channel/context-trust.d.ts +31 -0
- package/dist/commands/channel/context-trust.d.ts.map +1 -0
- package/dist/commands/channel/context-trust.js +139 -0
- package/dist/commands/channel/context-trust.js.map +1 -0
- package/dist/commands/channel/spawn.d.ts.map +1 -1
- package/dist/commands/channel/spawn.js +4 -2
- package/dist/commands/channel/spawn.js.map +1 -1
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +17 -0
- package/dist/commands/update.js.map +1 -1
- package/dist/configurators/codex.d.ts +36 -0
- package/dist/configurators/codex.d.ts.map +1 -1
- package/dist/configurators/codex.js +100 -1
- package/dist/configurators/codex.js.map +1 -1
- package/dist/configurators/index.d.ts.map +1 -1
- package/dist/configurators/index.js +5 -0
- package/dist/configurators/index.js.map +1 -1
- package/dist/configurators/shared.d.ts.map +1 -1
- package/dist/configurators/shared.js +1 -0
- package/dist/configurators/shared.js.map +1 -1
- package/dist/configurators/snow.d.ts +31 -0
- package/dist/configurators/snow.d.ts.map +1 -0
- package/dist/configurators/snow.js +95 -0
- package/dist/configurators/snow.js.map +1 -0
- package/dist/configurators/workflow.d.ts +14 -0
- package/dist/configurators/workflow.d.ts.map +1 -1
- package/dist/configurators/workflow.js +37 -1
- package/dist/configurators/workflow.js.map +1 -1
- package/dist/migrations/manifests/0.6.10.json +9 -0
- package/dist/migrations/manifests/0.6.9.json +9 -0
- package/dist/templates/codex/agents/trellis-check.toml +7 -3
- package/dist/templates/codex/agents/trellis-implement.toml +7 -3
- package/dist/templates/codex/agents/trellis-research.toml +13 -6
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +4 -2
- package/dist/templates/kimi/agents/trellis-research.md +7 -3
- package/dist/templates/omp/extensions/trellis/index.ts.txt +114 -4
- package/dist/templates/opencode/lib/trellis-context.js +263 -43
- package/dist/templates/opencode/plugins/inject-subagent-context.js +89 -35
- package/dist/templates/opencode/plugins/inject-workflow-state.js +90 -5
- package/dist/templates/pi/extensions/trellis/index.ts.txt +270 -22
- package/dist/templates/shared-hooks/inject-subagent-context.py +311 -83
- package/dist/templates/shared-hooks/inject-workflow-state.py +36 -1
- package/dist/templates/snow/SNOW.md +86 -0
- package/dist/templates/snow/agents/trellis-check.md +135 -0
- package/dist/templates/snow/agents/trellis-implement.md +153 -0
- package/dist/templates/snow/agents/trellis-research.md +114 -0
- package/dist/templates/snow/hooks/beforeSubAgentStart.json +15 -0
- package/dist/templates/snow/hooks/onSessionStart.json +15 -0
- package/dist/templates/snow/hooks/onUserMessage.json +15 -0
- package/dist/templates/snow/hooks/write-trellis-context.py +644 -0
- package/dist/templates/snow/index.d.ts +26 -0
- package/dist/templates/snow/index.d.ts.map +1 -0
- package/dist/templates/snow/index.js +36 -0
- package/dist/templates/snow/index.js.map +1 -0
- package/dist/templates/template-utils.d.ts.map +1 -1
- package/dist/templates/template-utils.js +5 -1
- package/dist/templates/template-utils.js.map +1 -1
- package/dist/templates/trellis/config.yaml +46 -0
- package/dist/templates/trellis/gitattributes.txt +9 -0
- package/dist/templates/trellis/index.d.ts +3 -1
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +3 -1
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/add_session.py +94 -28
- package/dist/templates/trellis/scripts/common/active_task.py +9 -2
- package/dist/templates/trellis/scripts/common/config.py +89 -2
- package/dist/templates/trellis/scripts/common/task_context.py +84 -6
- package/dist/templates/trellis/scripts/common/task_store.py +63 -1
- package/dist/templates/trellis/scripts/common/trellis_config.py +2 -1
- package/dist/templates/trellis/scripts/task.py +21 -2
- package/dist/templates/trellis/workflow.md +14 -14
- package/dist/types/ai-tools.d.ts +3 -3
- package/dist/types/ai-tools.d.ts.map +1 -1
- package/dist/types/ai-tools.js +38 -0
- package/dist/types/ai-tools.js.map +1 -1
- package/package.json +2 -2
|
@@ -148,65 +148,211 @@ def get_current_task(
|
|
|
148
148
|
return active.task_path
|
|
149
149
|
|
|
150
150
|
|
|
151
|
-
|
|
152
|
-
|
|
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, "
|
|
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
|
|
164
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
335
|
+
blocks: list[str] = []
|
|
182
336
|
try:
|
|
183
|
-
# Only read .md files, sorted by filename
|
|
184
337
|
md_files = sorted(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
350
|
+
return blocks
|
|
205
351
|
|
|
206
352
|
|
|
207
|
-
def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[
|
|
353
|
+
def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[dict]:
|
|
208
354
|
"""
|
|
209
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
287
|
-
|
|
453
|
+
blocks = _materialize_jsonl_entries(repo_root, agent_jsonl, limits, budget)
|
|
454
|
+
return "\n\n".join(blocks)
|
|
288
455
|
|
|
289
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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`.
|