@mindfoldhq/trellis 0.6.7 → 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 (174) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +38 -0
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/commands/channel/adapters/codex.d.ts +7 -1
  6. package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
  7. package/dist/commands/channel/adapters/codex.js +19 -2
  8. package/dist/commands/channel/adapters/codex.js.map +1 -1
  9. package/dist/commands/channel/adapters/index.d.ts +3 -1
  10. package/dist/commands/channel/adapters/index.d.ts.map +1 -1
  11. package/dist/commands/channel/adapters/index.js +1 -1
  12. package/dist/commands/channel/adapters/index.js.map +1 -1
  13. package/dist/commands/channel/agent-loader.d.ts +2 -2
  14. package/dist/commands/channel/agent-loader.d.ts.map +1 -1
  15. package/dist/commands/channel/agent-loader.js +9 -5
  16. package/dist/commands/channel/agent-loader.js.map +1 -1
  17. package/dist/commands/channel/context-loader.d.ts +1 -1
  18. package/dist/commands/channel/context-loader.d.ts.map +1 -1
  19. package/dist/commands/channel/context-loader.js +36 -15
  20. package/dist/commands/channel/context-loader.js.map +1 -1
  21. package/dist/commands/channel/context-trust.d.ts +31 -0
  22. package/dist/commands/channel/context-trust.d.ts.map +1 -0
  23. package/dist/commands/channel/context-trust.js +139 -0
  24. package/dist/commands/channel/context-trust.js.map +1 -0
  25. package/dist/commands/channel/index.d.ts.map +1 -1
  26. package/dist/commands/channel/index.js +4 -0
  27. package/dist/commands/channel/index.js.map +1 -1
  28. package/dist/commands/channel/spawn.d.ts +3 -0
  29. package/dist/commands/channel/spawn.d.ts.map +1 -1
  30. package/dist/commands/channel/spawn.js +5 -2
  31. package/dist/commands/channel/spawn.js.map +1 -1
  32. package/dist/commands/channel/supervisor.d.ts +3 -0
  33. package/dist/commands/channel/supervisor.d.ts.map +1 -1
  34. package/dist/commands/channel/supervisor.js +1 -0
  35. package/dist/commands/channel/supervisor.js.map +1 -1
  36. package/dist/commands/init.d.ts +3 -0
  37. package/dist/commands/init.d.ts.map +1 -1
  38. package/dist/commands/init.js.map +1 -1
  39. package/dist/commands/mem.d.ts.map +1 -1
  40. package/dist/commands/mem.js +30 -9
  41. package/dist/commands/mem.js.map +1 -1
  42. package/dist/commands/update.d.ts +12 -1
  43. package/dist/commands/update.d.ts.map +1 -1
  44. package/dist/commands/update.js +75 -7
  45. package/dist/commands/update.js.map +1 -1
  46. package/dist/configurators/codex.d.ts +36 -0
  47. package/dist/configurators/codex.d.ts.map +1 -1
  48. package/dist/configurators/codex.js +111 -13
  49. package/dist/configurators/codex.js.map +1 -1
  50. package/dist/configurators/grok.d.ts +22 -0
  51. package/dist/configurators/grok.d.ts.map +1 -0
  52. package/dist/configurators/grok.js +58 -0
  53. package/dist/configurators/grok.js.map +1 -0
  54. package/dist/configurators/index.d.ts.map +1 -1
  55. package/dist/configurators/index.js +17 -2
  56. package/dist/configurators/index.js.map +1 -1
  57. package/dist/configurators/kimi.d.ts +29 -0
  58. package/dist/configurators/kimi.d.ts.map +1 -0
  59. package/dist/configurators/kimi.js +81 -0
  60. package/dist/configurators/kimi.js.map +1 -0
  61. package/dist/configurators/pi.js +9 -9
  62. package/dist/configurators/pi.js.map +1 -1
  63. package/dist/configurators/shared.d.ts.map +1 -1
  64. package/dist/configurators/shared.js +12 -4
  65. package/dist/configurators/shared.js.map +1 -1
  66. package/dist/configurators/snow.d.ts +31 -0
  67. package/dist/configurators/snow.d.ts.map +1 -0
  68. package/dist/configurators/snow.js +95 -0
  69. package/dist/configurators/snow.js.map +1 -0
  70. package/dist/configurators/workflow.d.ts +14 -0
  71. package/dist/configurators/workflow.d.ts.map +1 -1
  72. package/dist/configurators/workflow.js +37 -1
  73. package/dist/configurators/workflow.js.map +1 -1
  74. package/dist/configurators/zcode.d.ts +9 -4
  75. package/dist/configurators/zcode.d.ts.map +1 -1
  76. package/dist/configurators/zcode.js +41 -12
  77. package/dist/configurators/zcode.js.map +1 -1
  78. package/dist/migrations/manifests/0.5.0-beta.0.json +1 -1
  79. package/dist/migrations/manifests/0.6.8.json +17 -0
  80. package/dist/migrations/manifests/0.6.9.json +9 -0
  81. package/dist/templates/codex/agents/trellis-check.toml +7 -12
  82. package/dist/templates/codex/agents/trellis-implement.toml +7 -12
  83. package/dist/templates/codex/agents/trellis-research.toml +15 -15
  84. package/dist/templates/codex/config.toml +11 -0
  85. package/dist/templates/codex/hooks/session-start.py +9 -3
  86. package/dist/templates/codex/hooks.json +12 -0
  87. package/dist/templates/codex/skills/brainstorm/SKILL.md +40 -13
  88. package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +2 -2
  89. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-skills-or-commands.md +1 -1
  90. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +1 -0
  91. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/hooks-and-settings.md +1 -1
  92. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +1 -1
  93. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +10 -4
  94. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md +2 -1
  95. package/dist/templates/common/bundled-skills/trellis-session-insight/SKILL.md +1 -1
  96. package/dist/templates/common/skills/brainstorm.md +40 -13
  97. package/dist/templates/copilot/prompts/brainstorm.prompt.md +40 -13
  98. package/dist/templates/grok/agents/trellis-check.md +105 -0
  99. package/dist/templates/grok/agents/trellis-implement.md +116 -0
  100. package/dist/templates/grok/agents/trellis-research.md +146 -0
  101. package/dist/templates/grok/index.d.ts +16 -0
  102. package/dist/templates/grok/index.d.ts.map +1 -0
  103. package/dist/templates/grok/index.js +19 -0
  104. package/dist/templates/grok/index.js.map +1 -0
  105. package/dist/templates/kimi/agents/trellis-check.md +103 -0
  106. package/dist/templates/kimi/agents/trellis-implement.md +114 -0
  107. package/dist/templates/kimi/agents/trellis-research.md +144 -0
  108. package/dist/templates/kimi/index.d.ts +19 -0
  109. package/dist/templates/kimi/index.d.ts.map +1 -0
  110. package/dist/templates/kimi/index.js +22 -0
  111. package/dist/templates/kimi/index.js.map +1 -0
  112. package/dist/templates/omp/extensions/trellis/index.ts.txt +133 -9
  113. package/dist/templates/opencode/lib/session-utils.js +8 -2
  114. package/dist/templates/opencode/lib/trellis-context.js +263 -43
  115. package/dist/templates/opencode/plugins/inject-subagent-context.js +89 -35
  116. package/dist/templates/opencode/plugins/inject-workflow-state.js +90 -5
  117. package/dist/templates/pi/extensions/trellis/index.ts.txt +278 -24
  118. package/dist/templates/pi/settings.json +0 -3
  119. package/dist/templates/shared-hooks/index.d.ts +11 -5
  120. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  121. package/dist/templates/shared-hooks/index.js +16 -5
  122. package/dist/templates/shared-hooks/index.js.map +1 -1
  123. package/dist/templates/shared-hooks/inject-subagent-context.py +466 -104
  124. package/dist/templates/shared-hooks/inject-workflow-state.py +86 -29
  125. package/dist/templates/shared-hooks/session-start.py +26 -6
  126. package/dist/templates/snow/SNOW.md +86 -0
  127. package/dist/templates/snow/agents/trellis-check.md +135 -0
  128. package/dist/templates/snow/agents/trellis-implement.md +153 -0
  129. package/dist/templates/snow/agents/trellis-research.md +114 -0
  130. package/dist/templates/snow/hooks/beforeSubAgentStart.json +15 -0
  131. package/dist/templates/snow/hooks/onSessionStart.json +15 -0
  132. package/dist/templates/snow/hooks/onUserMessage.json +15 -0
  133. package/dist/templates/snow/hooks/write-trellis-context.py +644 -0
  134. package/dist/templates/snow/index.d.ts +26 -0
  135. package/dist/templates/snow/index.d.ts.map +1 -0
  136. package/dist/templates/snow/index.js +36 -0
  137. package/dist/templates/snow/index.js.map +1 -0
  138. package/dist/templates/template-utils.d.ts.map +1 -1
  139. package/dist/templates/template-utils.js +5 -1
  140. package/dist/templates/template-utils.js.map +1 -1
  141. package/dist/templates/trellis/config.yaml +55 -7
  142. package/dist/templates/trellis/gitattributes.txt +9 -0
  143. package/dist/templates/trellis/index.d.ts +3 -1
  144. package/dist/templates/trellis/index.d.ts.map +1 -1
  145. package/dist/templates/trellis/index.js +3 -1
  146. package/dist/templates/trellis/index.js.map +1 -1
  147. package/dist/templates/trellis/scripts/add_session.py +94 -28
  148. package/dist/templates/trellis/scripts/common/active_task.py +44 -13
  149. package/dist/templates/trellis/scripts/common/cli_adapter.py +69 -5
  150. package/dist/templates/trellis/scripts/common/config.py +105 -10
  151. package/dist/templates/trellis/scripts/common/git.py +36 -0
  152. package/dist/templates/trellis/scripts/common/io.py +16 -9
  153. package/dist/templates/trellis/scripts/common/task_context.py +101 -7
  154. package/dist/templates/trellis/scripts/common/task_store.py +149 -22
  155. package/dist/templates/trellis/scripts/common/task_utils.py +3 -1
  156. package/dist/templates/trellis/scripts/common/trellis_config.py +2 -1
  157. package/dist/templates/trellis/scripts/common/workflow_phase.py +19 -12
  158. package/dist/templates/trellis/scripts/task.py +106 -10
  159. package/dist/templates/trellis/workflow.md +20 -19
  160. package/dist/templates/zcode/agents/trellis-check.md +7 -0
  161. package/dist/templates/zcode/agents/trellis-implement.md +13 -4
  162. package/dist/templates/zcode/config.json +42 -0
  163. package/dist/templates/zcode/index.d.ts +10 -1
  164. package/dist/templates/zcode/index.d.ts.map +1 -1
  165. package/dist/templates/zcode/index.js +12 -2
  166. package/dist/templates/zcode/index.js.map +1 -1
  167. package/dist/types/ai-tools.d.ts +4 -4
  168. package/dist/types/ai-tools.d.ts.map +1 -1
  169. package/dist/types/ai-tools.js +114 -2
  170. package/dist/types/ai-tools.js.map +1 -1
  171. package/dist/utils/template-fetcher.d.ts.map +1 -1
  172. package/dist/utils/template-fetcher.js +0 -3
  173. package/dist/utils/template-fetcher.js.map +1 -1
  174. package/package.json +2 -2
@@ -80,9 +80,12 @@ def find_repo_root(start_path: str) -> str | None:
80
80
 
81
81
 
82
82
  def _detect_platform(input_data: dict) -> str | None:
83
+ if _hook_event_name(input_data) == "SubagentStart":
84
+ return "codex"
83
85
  if isinstance(input_data.get("cursor_version"), str):
84
86
  return "cursor"
85
87
  env_map = {
88
+ "ZCODE_PROJECT_DIR": "zcode",
86
89
  "CLAUDE_PROJECT_DIR": "claude",
87
90
  "CURSOR_PROJECT_DIR": "cursor",
88
91
  "CODEBUDDY_PROJECT_DIR": "codebuddy",
@@ -110,10 +113,20 @@ def _detect_platform(input_data: dict) -> str | None:
110
113
  return "droid"
111
114
  if ".kiro" in script_parts:
112
115
  return "kiro"
116
+ if ".zcode" in script_parts:
117
+ return "zcode"
113
118
  return None
114
119
 
115
120
 
116
- def get_current_task(repo_root: str, input_data: dict) -> str | None:
121
+ def get_current_task(
122
+ repo_root: str,
123
+ input_data: dict,
124
+ *,
125
+ platform: str | None = None,
126
+ allow_single_session_fallback: bool = True,
127
+ allow_environment_context: bool = True,
128
+ require_existing: bool = False,
129
+ ) -> str | None:
117
130
  """Resolve current task directory through the unified active task resolver."""
118
131
  scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts"
119
132
  if str(scripts_dir) not in sys.path:
@@ -126,70 +139,220 @@ def get_current_task(repo_root: str, input_data: dict) -> str | None:
126
139
  active = resolve_active_task(
127
140
  Path(repo_root),
128
141
  input_data,
129
- platform=_detect_platform(input_data),
142
+ platform=platform or _detect_platform(input_data),
143
+ allow_single_session_fallback=allow_single_session_fallback,
144
+ allow_environment_context=allow_environment_context,
130
145
  )
146
+ if require_existing and active.stale:
147
+ return None
131
148
  return active.task_path
132
149
 
133
150
 
134
- def read_file_content(base_path: str, file_path: str) -> str | None:
135
- """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."""
136
235
  full_path = os.path.join(base_path, file_path)
137
236
  if os.path.exists(full_path) and os.path.isfile(full_path):
138
237
  try:
139
- with open(full_path, "r", encoding="utf-8") as f:
238
+ with open(full_path, "rb") as f:
140
239
  return f.read()
141
240
  except Exception:
142
241
  return None
143
242
  return None
144
243
 
145
244
 
146
- def read_directory_contents(
147
- base_path: str, dir_path: str, max_files: int = 20
148
- ) -> list[tuple[str, str]]:
149
- """
150
- 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]"
151
247
 
152
- Args:
153
- base_path: Base path (usually repo_root)
154
- dir_path: Directory relative path
155
- max_files: Max files to read (prevent huge directories)
156
248
 
157
- Returns:
158
- [(file_path, content), ...]
159
- """
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."""
160
331
  full_path = os.path.join(base_path, dir_path)
161
332
  if not os.path.exists(full_path) or not os.path.isdir(full_path):
162
333
  return []
163
334
 
164
- results = []
335
+ blocks: list[str] = []
165
336
  try:
166
- # Only read .md files, sorted by filename
167
337
  md_files = sorted(
168
- [
169
- f
170
- for f in os.listdir(full_path)
171
- if f.endswith(".md") and os.path.isfile(os.path.join(full_path, f))
172
- ]
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))
173
341
  )
174
-
175
342
  for filename in md_files[:max_files]:
176
- file_full_path = os.path.join(full_path, filename)
177
343
  relative_path = os.path.join(dir_path, filename)
178
- try:
179
- with open(file_full_path, "r", encoding="utf-8") as f:
180
- content = f.read()
181
- results.append((relative_path, content))
182
- except Exception:
183
- continue
344
+ block = _materialize_file(base_path, relative_path, reason, limits, budget)
345
+ if block:
346
+ blocks.append(block)
184
347
  except Exception:
185
348
  pass
186
349
 
187
- return results
350
+ return blocks
188
351
 
189
352
 
190
- 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]:
191
354
  """
192
- Read all file/directory contents referenced in jsonl file
355
+ Parse all file/directory entries referenced in a jsonl context file.
193
356
 
194
357
  Schema:
195
358
  {"file": "path/to/file.md", "reason": "..."}
@@ -202,7 +365,7 @@ def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]
202
365
  emitted so the operator can debug missing context.
203
366
 
204
367
  Returns:
205
- [(path, content), ...]
368
+ [{"file": path, "type": "file" | "directory", "reason": reason}, ...]
206
369
  """
207
370
  full_path = os.path.join(base_path, jsonl_path)
208
371
  if not os.path.exists(full_path):
@@ -213,7 +376,7 @@ def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]
213
376
  )
214
377
  return []
215
378
 
216
- results = []
379
+ entries: list[dict] = []
217
380
  saw_real_entry = False
218
381
  try:
219
382
  with open(full_path, "r", encoding="utf-8") as f:
@@ -224,22 +387,19 @@ def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]
224
387
  try:
225
388
  item = json.loads(line)
226
389
  file_path = item.get("file") or item.get("path")
227
- entry_type = item.get("type", "file")
228
390
 
229
391
  if not file_path:
230
392
  # Seed / comment row — skip silently
231
393
  continue
232
394
 
233
395
  saw_real_entry = True
234
- if entry_type == "directory":
235
- # Read all .md files in directory
236
- dir_contents = read_directory_contents(base_path, file_path)
237
- results.extend(dir_contents)
238
- else:
239
- # Read single file
240
- content = read_file_content(base_path, file_path)
241
- if content:
242
- 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
+ )
243
403
  except json.JSONDecodeError:
244
404
  continue
245
405
  except Exception:
@@ -253,23 +413,69 @@ def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]
253
413
  file=sys.stderr,
254
414
  )
255
415
 
256
- return results
257
-
258
-
259
-
260
-
261
- def get_agent_context(repo_root: str, task_dir: str, agent_type: str) -> str:
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
439
+
440
+
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:
262
448
  """
263
449
  Get context from {agent_type}.jsonl for the specified agent.
264
450
  Only reads implement.jsonl or check.jsonl (the two JSONL files the task system creates).
265
451
  """
266
- context_parts = []
267
-
268
452
  agent_jsonl = f"{task_dir}/{agent_type}.jsonl"
269
- for file_path, content in read_jsonl_entries(repo_root, agent_jsonl):
270
- 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)
455
+
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
271
470
 
272
- return "\n\n".join(context_parts)
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)
273
479
 
274
480
 
275
481
  def get_implement_context(repo_root: str, task_dir: str) -> str:
@@ -282,31 +488,50 @@ def get_implement_context(repo_root: str, task_dir: str) -> str:
282
488
  3. design.md if present (technical design)
283
489
  4. implement.md if present (execution plan)
284
490
  """
491
+ limits = _get_limits(repo_root)
492
+ budget = _Budget(limits["max_total_bytes"])
285
493
  context_parts = []
286
494
 
287
495
  # 1. Read implement.jsonl
288
- base_context = get_agent_context(repo_root, task_dir, "implement")
496
+ base_context = get_agent_context(repo_root, task_dir, "implement", limits, budget)
289
497
  if base_context:
290
498
  context_parts.append(base_context)
291
499
 
292
500
  # 2. Requirements document
293
- prd_content = read_file_content(repo_root, f"{task_dir}/prd.md")
294
- if prd_content:
295
- 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)
296
511
 
297
512
  # 3. Technical design for complex tasks
298
- design_content = read_file_content(repo_root, f"{task_dir}/design.md")
299
- if design_content:
300
- context_parts.append(
301
- f"=== {task_dir}/design.md (Technical Design) ===\n{design_content}"
302
- )
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)
303
523
 
304
524
  # 4. Execution plan for complex tasks
305
- implement_plan_content = read_file_content(repo_root, f"{task_dir}/implement.md")
306
- if implement_plan_content:
307
- context_parts.append(
308
- f"=== {task_dir}/implement.md (Execution Plan) ===\n{implement_plan_content}"
309
- )
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)
310
535
 
311
536
  return "\n\n".join(context_parts)
312
537
 
@@ -315,26 +540,46 @@ def get_check_context(repo_root: str, task_dir: str) -> str:
315
540
  """
316
541
  Context for Check Agent: check.jsonl + task artifacts.
317
542
  """
543
+ limits = _get_limits(repo_root)
544
+ budget = _Budget(limits["max_total_bytes"])
318
545
  context_parts = []
319
546
 
320
- for file_path, content in read_jsonl_entries(repo_root, f"{task_dir}/check.jsonl"):
321
- context_parts.append(f"=== {file_path} ===\n{content}")
322
-
323
- prd_content = read_file_content(repo_root, f"{task_dir}/prd.md")
324
- if prd_content:
325
- context_parts.append(f"=== {task_dir}/prd.md (Requirements) ===\n{prd_content}")
326
-
327
- design_content = read_file_content(repo_root, f"{task_dir}/design.md")
328
- if design_content:
329
- context_parts.append(
330
- f"=== {task_dir}/design.md (Technical Design) ===\n{design_content}"
331
- )
547
+ base_context = get_agent_context(repo_root, task_dir, "check", limits, budget)
548
+ if base_context:
549
+ context_parts.append(base_context)
332
550
 
333
- implement_plan_content = read_file_content(repo_root, f"{task_dir}/implement.md")
334
- if implement_plan_content:
335
- context_parts.append(
336
- f"=== {task_dir}/implement.md (Execution Plan) ===\n{implement_plan_content}"
337
- )
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)
561
+
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)
572
+
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)
338
583
 
339
584
  return "\n\n".join(context_parts)
340
585
 
@@ -574,6 +819,98 @@ def _string_value(value: Any) -> str:
574
819
  return ""
575
820
 
576
821
 
822
+ def _hook_event_name(input_data: dict) -> str:
823
+ """Return a hook event name from the documented snake/camel-case fields."""
824
+ return _string_value(
825
+ input_data.get("hook_event_name") or input_data.get("hookEventName")
826
+ )
827
+
828
+
829
+ def _codex_subagent_type(input_data: dict) -> str:
830
+ """Return a Trellis Codex agent type only for a native start event."""
831
+ if _hook_event_name(input_data) != "SubagentStart":
832
+ return ""
833
+ agent_type = _string_value(
834
+ input_data.get("agent_type") or input_data.get("agentType")
835
+ )
836
+ return agent_type if agent_type in AGENTS_ALL else ""
837
+
838
+
839
+ def build_codex_subagent_context(
840
+ subagent_type: str,
841
+ task_dir: str,
842
+ context: str,
843
+ ) -> str:
844
+ """Build developer context for a native, already-dispatched Codex role."""
845
+ role = subagent_type.removeprefix("trellis-")
846
+ return f"""<!-- trellis-hook-injected -->
847
+ # Trellis Native {role.title()} Subagent
848
+
849
+ You are the dispatched `{subagent_type}` role for this task. Perform that role
850
+ directly; do not follow main-session dispatch or wait instructions, and do not
851
+ spawn another Trellis subagent.
852
+
853
+ Active task: {task_dir}
854
+
855
+ ## Curated Context
856
+
857
+ {context}"""
858
+
859
+
860
+ def _handle_codex_subagent_start(input_data: dict) -> None:
861
+ """Emit Codex developer context for a recognised native Trellis subagent.
862
+
863
+ The event supplies the parent session id. Disabling the generic
864
+ single-session fallback is essential here: native starts must never borrow
865
+ a task from another Codex window when that parent id is absent or stale.
866
+ """
867
+ subagent_type = _codex_subagent_type(input_data)
868
+ parent_session_id = _string_value(input_data.get("session_id"))
869
+ if not subagent_type or not parent_session_id:
870
+ return
871
+
872
+ cwd = _string_value(input_data.get("cwd")) or os.getcwd()
873
+ repo_root = find_repo_root(cwd)
874
+ if not repo_root:
875
+ return
876
+
877
+ task_dir = get_current_task(
878
+ repo_root,
879
+ {"session_id": parent_session_id},
880
+ platform="codex",
881
+ allow_single_session_fallback=False,
882
+ allow_environment_context=False,
883
+ require_existing=True,
884
+ )
885
+ if not task_dir:
886
+ return
887
+
888
+ if subagent_type in AGENTS_REQUIRE_TASK:
889
+ task_dir_full = Path(repo_root) / task_dir
890
+ if not task_dir_full.is_dir():
891
+ return
892
+
893
+ if subagent_type == AGENT_IMPLEMENT:
894
+ context = get_implement_context(repo_root, task_dir)
895
+ elif subagent_type == AGENT_CHECK:
896
+ context = get_check_context(repo_root, task_dir)
897
+ else:
898
+ context = get_research_context(repo_root, task_dir)
899
+
900
+ if not context:
901
+ return
902
+
903
+ output = {
904
+ "hookSpecificOutput": {
905
+ "hookEventName": "SubagentStart",
906
+ "additionalContext": build_codex_subagent_context(
907
+ subagent_type, task_dir, context
908
+ ),
909
+ }
910
+ }
911
+ print(json.dumps(output, ensure_ascii=False))
912
+
913
+
577
914
  def _extract_subagent_name(value: Any) -> str:
578
915
  """Extract a sub-agent name from common platform encodings.
579
916
 
@@ -652,10 +989,15 @@ def _parse_hook_input(input_data: dict) -> tuple[str, str, dict]:
652
989
  - Claude Code / Qoder / CodeBuddy / Droid: tool_name=Task|Agent, tool_input.subagent_type
653
990
  - Cursor: tool_name=Task|Subagent, tool_input.subagent_type
654
991
  - Copilot CLI: toolName=task (camelCase key, lowercase value)
992
+ - ZCode: toolName=Agent, toolInput/tool_input.subagent_type
655
993
  - Gemini CLI: tool_name IS the agent name (BeforeTool matcher already filtered)
656
994
  - Kiro: agentSpawn hook, agent_name field at top level
657
995
  """
658
996
  tool_input = input_data.get("tool_input", {})
997
+ if not isinstance(tool_input, dict):
998
+ tool_input = input_data.get("toolInput", {})
999
+ if not isinstance(tool_input, dict):
1000
+ tool_input = {}
659
1001
 
660
1002
  # Standard format: Task/Agent tool with subagent_type
661
1003
  tool_name = input_data.get("tool_name", "") or input_data.get("toolName", "")
@@ -692,6 +1034,17 @@ def main():
692
1034
  input_data = json.load(sys.stdin)
693
1035
  except json.JSONDecodeError:
694
1036
  sys.exit(0)
1037
+ if not isinstance(input_data, dict):
1038
+ sys.exit(0)
1039
+
1040
+ if _hook_event_name(input_data) == "SubagentStart":
1041
+ try:
1042
+ _handle_codex_subagent_start(input_data)
1043
+ except Exception:
1044
+ # A native context hook must never prevent Codex from spawning the
1045
+ # requested child when its runtime state is unavailable or stale.
1046
+ pass
1047
+ sys.exit(0)
695
1048
 
696
1049
  subagent_type, original_prompt, tool_input = _parse_hook_input(input_data)
697
1050
  cwd = input_data.get("cwd", os.getcwd())
@@ -745,23 +1098,32 @@ def main():
745
1098
  if not context:
746
1099
  sys.exit(0)
747
1100
 
748
- # Return updated input use a multi-format output that covers all platforms.
749
- # Most platforms ignore unrecognized fields, so we include multiple formats.
750
- # The platform picks whichever fields it understands.
1101
+ # Return updated input. Most platforms ignore unrecognized fields, so we
1102
+ # include multiple formats. ZCode is stricter; live probing confirmed the
1103
+ # nested Claude-compatible shape below reaches the sub-agent prompt.
751
1104
  updated = {**tool_input, "prompt": new_prompt}
752
- output = {
753
- # Claude Code / Qoder / CodeBuddy / Droid format
754
- "hookSpecificOutput": {
755
- "hookEventName": "PreToolUse",
756
- "permissionDecision": "allow",
1105
+ if _detect_platform(input_data) == "zcode":
1106
+ output = {
1107
+ "hookSpecificOutput": {
1108
+ "hookEventName": "PreToolUse",
1109
+ "permissionDecision": "allow",
1110
+ "updatedInput": updated,
1111
+ }
1112
+ }
1113
+ else:
1114
+ output = {
1115
+ # Claude Code / Qoder / CodeBuddy / Droid format
1116
+ "hookSpecificOutput": {
1117
+ "hookEventName": "PreToolUse",
1118
+ "permissionDecision": "allow",
1119
+ "updatedInput": updated,
1120
+ },
1121
+ # Cursor format
1122
+ "permission": "allow",
1123
+ "updated_input": updated,
1124
+ # Gemini format
757
1125
  "updatedInput": updated,
758
- },
759
- # Cursor format
760
- "permission": "allow",
761
- "updated_input": updated,
762
- # Gemini format
763
- "updatedInput": updated,
764
- }
1126
+ }
765
1127
 
766
1128
  print(json.dumps(output, ensure_ascii=False))
767
1129
  sys.exit(0)