@blxzer/cursor-trellis 0.1.1 → 0.1.2
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/migrations/manifests/0.1.1.json +9 -0
- package/dist/migrations/manifests/0.1.2.json +9 -0
- package/dist/templates/markdown/spec/guides/cursor-context-injection-guide.md.txt +2 -1
- package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +16 -3
- package/dist/templates/shared-hooks/inject-subagent-context.py +31 -567
- package/dist/templates/trellis/index.d.ts +1 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +2 -0
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/common/subagent_dispatch.py +527 -0
- package/dist/templates/trellis/scripts/common/task_store.py +32 -0
- package/dist/templates/trellis/scripts/task.py +26 -0
- package/dist/templates/trellis/workflow.md +5 -22
- package/package.json +2 -2
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
"""
|
|
4
|
-
Multi-Platform Sub-Agent Context Injection Hook
|
|
4
|
+
Multi-Platform Sub-Agent Context Injection Hook (thin wrapper).
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
Core Design Philosophy:
|
|
9
|
-
- Hook is responsible for injecting all context, subagent works autonomously with complete info
|
|
10
|
-
- Each agent has a dedicated jsonl file defining its context
|
|
11
|
-
- No resume needed, no segmentation, behavior controlled by code not prompt
|
|
12
|
-
|
|
13
|
-
Trigger: PreToolUse (before Task tool call)
|
|
14
|
-
|
|
15
|
-
Context Source: Trellis selected task resolver points to task directory
|
|
16
|
-
- implement.jsonl - Implement agent dedicated context
|
|
17
|
-
- check.jsonl - Check agent dedicated context
|
|
18
|
-
- prd.md - Requirements document
|
|
19
|
-
- design.md - Technical design for complex tasks
|
|
20
|
-
- implement.md - Execution plan for complex tasks
|
|
21
|
-
- codex-review-output.txt - Code Review results
|
|
6
|
+
CLI Layer 2 (`generate-dispatch-prompt`) is the primary Cursor path; this hook is
|
|
7
|
+
best-effort and skips when the prompt already contains the injection marker.
|
|
22
8
|
"""
|
|
23
9
|
from __future__ import annotations
|
|
24
10
|
|
|
25
|
-
# IMPORTANT: Suppress all warnings FIRST
|
|
26
11
|
import warnings
|
|
12
|
+
|
|
27
13
|
warnings.filterwarnings("ignore")
|
|
28
14
|
|
|
29
15
|
import json
|
|
@@ -32,45 +18,24 @@ import sys
|
|
|
32
18
|
from pathlib import Path
|
|
33
19
|
from typing import Any
|
|
34
20
|
|
|
35
|
-
# IMPORTANT: Force stdout to use UTF-8 on Windows
|
|
36
|
-
# This fixes UnicodeEncodeError when outputting non-ASCII characters
|
|
37
21
|
if sys.platform.startswith("win"):
|
|
38
22
|
import io as _io
|
|
23
|
+
|
|
39
24
|
if hasattr(sys.stdout, "reconfigure"):
|
|
40
25
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
41
26
|
elif hasattr(sys.stdout, "detach"):
|
|
42
27
|
sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
43
28
|
|
|
44
|
-
|
|
45
|
-
# =============================================================================
|
|
46
|
-
# Path Constants (change here to rename directories)
|
|
47
|
-
# =============================================================================
|
|
48
|
-
|
|
49
29
|
DIR_WORKFLOW = ".trellis"
|
|
50
|
-
DIR_SPEC = "spec"
|
|
51
|
-
FILE_TASK_JSON = "task.json"
|
|
52
|
-
|
|
53
|
-
# =============================================================================
|
|
54
|
-
# Subagent Constants (change here to rename subagent types)
|
|
55
|
-
# =============================================================================
|
|
56
30
|
|
|
57
31
|
AGENT_IMPLEMENT = "trellis-implement"
|
|
58
32
|
AGENT_CHECK = "trellis-check"
|
|
59
33
|
AGENT_RESEARCH = "trellis-research"
|
|
60
|
-
|
|
61
|
-
# Agents that require a task directory
|
|
62
|
-
AGENTS_REQUIRE_TASK = (AGENT_IMPLEMENT, AGENT_CHECK)
|
|
63
|
-
# All supported agents
|
|
64
34
|
AGENTS_ALL = (AGENT_IMPLEMENT, AGENT_CHECK, AGENT_RESEARCH)
|
|
35
|
+
AGENTS_REQUIRE_TASK = (AGENT_IMPLEMENT, AGENT_CHECK)
|
|
65
36
|
|
|
66
37
|
|
|
67
38
|
def find_repo_root(start_path: str) -> str | None:
|
|
68
|
-
"""
|
|
69
|
-
Find Trellis harness root (.trellis/) or git repo root from start_path upwards.
|
|
70
|
-
|
|
71
|
-
Returns:
|
|
72
|
-
Workspace root path, or None if not found
|
|
73
|
-
"""
|
|
74
39
|
current = Path(start_path).resolve()
|
|
75
40
|
while current != current.parent:
|
|
76
41
|
if (current / DIR_WORKFLOW).is_dir():
|
|
@@ -98,25 +63,14 @@ def _detect_platform(input_data: dict) -> str | None:
|
|
|
98
63
|
if os.environ.get(env_name):
|
|
99
64
|
return platform
|
|
100
65
|
script_parts = set(Path(sys.argv[0]).parts)
|
|
101
|
-
if ".claude" in script_parts:
|
|
102
|
-
return "claude"
|
|
103
66
|
if ".cursor" in script_parts:
|
|
104
67
|
return "cursor"
|
|
105
|
-
if ".
|
|
106
|
-
return "
|
|
107
|
-
if ".qoder" in script_parts:
|
|
108
|
-
return "qoder"
|
|
109
|
-
if ".codebuddy" in script_parts:
|
|
110
|
-
return "codebuddy"
|
|
111
|
-
if ".factory" in script_parts:
|
|
112
|
-
return "droid"
|
|
113
|
-
if ".kiro" in script_parts:
|
|
114
|
-
return "kiro"
|
|
68
|
+
if ".claude" in script_parts:
|
|
69
|
+
return "claude"
|
|
115
70
|
return None
|
|
116
71
|
|
|
117
72
|
|
|
118
73
|
def get_selected_task(repo_root: str, input_data: dict) -> str | None:
|
|
119
|
-
"""Resolve selected task directory through the unified selected task resolver."""
|
|
120
74
|
scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts"
|
|
121
75
|
if str(scripts_dir) not in sys.path:
|
|
122
76
|
sys.path.insert(0, str(scripts_dir))
|
|
@@ -133,461 +87,27 @@ def get_selected_task(repo_root: str, input_data: dict) -> str | None:
|
|
|
133
87
|
return selected.task_path
|
|
134
88
|
|
|
135
89
|
|
|
136
|
-
def read_file_content(base_path: str, file_path: str) -> str | None:
|
|
137
|
-
"""Read file content, return None if file doesn't exist"""
|
|
138
|
-
full_path = os.path.join(base_path, file_path)
|
|
139
|
-
if os.path.exists(full_path) and os.path.isfile(full_path):
|
|
140
|
-
try:
|
|
141
|
-
with open(full_path, "r", encoding="utf-8") as f:
|
|
142
|
-
return f.read()
|
|
143
|
-
except Exception:
|
|
144
|
-
return None
|
|
145
|
-
return None
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def read_directory_contents(
|
|
149
|
-
base_path: str, dir_path: str, max_files: int = 20
|
|
150
|
-
) -> list[tuple[str, str]]:
|
|
151
|
-
"""
|
|
152
|
-
Read all .md files in a directory
|
|
153
|
-
|
|
154
|
-
Args:
|
|
155
|
-
base_path: Base path (usually repo_root)
|
|
156
|
-
dir_path: Directory relative path
|
|
157
|
-
max_files: Max files to read (prevent huge directories)
|
|
158
|
-
|
|
159
|
-
Returns:
|
|
160
|
-
[(file_path, content), ...]
|
|
161
|
-
"""
|
|
162
|
-
full_path = os.path.join(base_path, dir_path)
|
|
163
|
-
if not os.path.exists(full_path) or not os.path.isdir(full_path):
|
|
164
|
-
return []
|
|
165
|
-
|
|
166
|
-
results = []
|
|
167
|
-
try:
|
|
168
|
-
# Only read .md files, sorted by filename
|
|
169
|
-
md_files = sorted(
|
|
170
|
-
[
|
|
171
|
-
f
|
|
172
|
-
for f in os.listdir(full_path)
|
|
173
|
-
if f.endswith(".md") and os.path.isfile(os.path.join(full_path, f))
|
|
174
|
-
]
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
for filename in md_files[:max_files]:
|
|
178
|
-
file_full_path = os.path.join(full_path, filename)
|
|
179
|
-
relative_path = os.path.join(dir_path, filename)
|
|
180
|
-
try:
|
|
181
|
-
with open(file_full_path, "r", encoding="utf-8") as f:
|
|
182
|
-
content = f.read()
|
|
183
|
-
results.append((relative_path, content))
|
|
184
|
-
except Exception:
|
|
185
|
-
continue
|
|
186
|
-
except Exception:
|
|
187
|
-
pass
|
|
188
|
-
|
|
189
|
-
return results
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]:
|
|
193
|
-
"""
|
|
194
|
-
Read all file/directory contents referenced in jsonl file
|
|
195
|
-
|
|
196
|
-
Schema:
|
|
197
|
-
{"file": "path/to/file.md", "reason": "..."}
|
|
198
|
-
{"file": "path/to/dir/", "type": "directory", "reason": "..."}
|
|
199
|
-
{"_example": "..."} # seed row — skipped (no `file` field)
|
|
200
|
-
|
|
201
|
-
Rows without a ``file`` field (e.g. the self-describing seed line written
|
|
202
|
-
by ``task.py create`` before the agent has curated entries) are skipped
|
|
203
|
-
silently. If the resulting entry list is empty, a stderr warning is
|
|
204
|
-
emitted so the operator can debug missing context.
|
|
205
|
-
|
|
206
|
-
Returns:
|
|
207
|
-
[(path, content), ...]
|
|
208
|
-
"""
|
|
209
|
-
full_path = os.path.join(base_path, jsonl_path)
|
|
210
|
-
if not os.path.exists(full_path):
|
|
211
|
-
print(
|
|
212
|
-
f"[inject-subagent-context] WARN: {jsonl_path} not found — "
|
|
213
|
-
f"sub-agent will receive only task artifacts",
|
|
214
|
-
file=sys.stderr,
|
|
215
|
-
)
|
|
216
|
-
return []
|
|
217
|
-
|
|
218
|
-
results = []
|
|
219
|
-
saw_real_entry = False
|
|
220
|
-
try:
|
|
221
|
-
with open(full_path, "r", encoding="utf-8") as f:
|
|
222
|
-
for line in f:
|
|
223
|
-
line = line.strip()
|
|
224
|
-
if not line:
|
|
225
|
-
continue
|
|
226
|
-
try:
|
|
227
|
-
item = json.loads(line)
|
|
228
|
-
file_path = item.get("file") or item.get("path")
|
|
229
|
-
entry_type = item.get("type", "file")
|
|
230
|
-
|
|
231
|
-
if not file_path:
|
|
232
|
-
# Seed / comment row — skip silently
|
|
233
|
-
continue
|
|
234
|
-
|
|
235
|
-
saw_real_entry = True
|
|
236
|
-
if entry_type == "directory":
|
|
237
|
-
# Read all .md files in directory
|
|
238
|
-
dir_contents = read_directory_contents(base_path, file_path)
|
|
239
|
-
results.extend(dir_contents)
|
|
240
|
-
else:
|
|
241
|
-
# Read single file
|
|
242
|
-
content = read_file_content(base_path, file_path)
|
|
243
|
-
if content:
|
|
244
|
-
results.append((file_path, content))
|
|
245
|
-
except json.JSONDecodeError:
|
|
246
|
-
continue
|
|
247
|
-
except Exception:
|
|
248
|
-
pass
|
|
249
|
-
|
|
250
|
-
if not saw_real_entry:
|
|
251
|
-
print(
|
|
252
|
-
f"[inject-subagent-context] WARN: {jsonl_path} has no curated "
|
|
253
|
-
f"entries (only seed / empty) — sub-agent will receive only "
|
|
254
|
-
f"task artifacts. See workflow.md planning artifact guidance.",
|
|
255
|
-
file=sys.stderr,
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
return results
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
def get_agent_context(repo_root: str, task_dir: str, agent_type: str) -> str:
|
|
264
|
-
"""
|
|
265
|
-
Get context from {agent_type}.jsonl for the specified agent.
|
|
266
|
-
Only reads implement.jsonl or check.jsonl (the two JSONL files the task system creates).
|
|
267
|
-
"""
|
|
268
|
-
context_parts = []
|
|
269
|
-
|
|
270
|
-
agent_jsonl = f"{task_dir}/{agent_type}.jsonl"
|
|
271
|
-
for file_path, content in read_jsonl_entries(repo_root, agent_jsonl):
|
|
272
|
-
context_parts.append(f"=== {file_path} ===\n{content}")
|
|
273
|
-
|
|
274
|
-
return "\n\n".join(context_parts)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def get_implement_context(repo_root: str, task_dir: str) -> str:
|
|
278
|
-
"""
|
|
279
|
-
Complete context for Implement Agent
|
|
280
|
-
|
|
281
|
-
Read order:
|
|
282
|
-
1. All files in implement.jsonl (spec/research manifests)
|
|
283
|
-
2. prd.md (requirements)
|
|
284
|
-
3. design.md if present (technical design)
|
|
285
|
-
4. implement.md if present (execution plan)
|
|
286
|
-
"""
|
|
287
|
-
context_parts = []
|
|
288
|
-
|
|
289
|
-
# 1. Read implement.jsonl
|
|
290
|
-
base_context = get_agent_context(repo_root, task_dir, "implement")
|
|
291
|
-
if base_context:
|
|
292
|
-
context_parts.append(base_context)
|
|
293
|
-
|
|
294
|
-
# 2. Requirements document
|
|
295
|
-
prd_content = read_file_content(repo_root, f"{task_dir}/prd.md")
|
|
296
|
-
if prd_content:
|
|
297
|
-
context_parts.append(f"=== {task_dir}/prd.md (Requirements) ===\n{prd_content}")
|
|
298
|
-
|
|
299
|
-
# 3. Technical design for complex tasks
|
|
300
|
-
design_content = read_file_content(repo_root, f"{task_dir}/design.md")
|
|
301
|
-
if design_content:
|
|
302
|
-
context_parts.append(
|
|
303
|
-
f"=== {task_dir}/design.md (Technical Design) ===\n{design_content}"
|
|
304
|
-
)
|
|
305
|
-
|
|
306
|
-
# 4. Execution plan for complex tasks
|
|
307
|
-
implement_plan_content = read_file_content(repo_root, f"{task_dir}/implement.md")
|
|
308
|
-
if implement_plan_content:
|
|
309
|
-
context_parts.append(
|
|
310
|
-
f"=== {task_dir}/implement.md (Execution Plan) ===\n{implement_plan_content}"
|
|
311
|
-
)
|
|
312
|
-
|
|
313
|
-
return "\n\n".join(context_parts)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def get_check_context(repo_root: str, task_dir: str) -> str:
|
|
317
|
-
"""
|
|
318
|
-
Context for Check Agent: check.jsonl + task artifacts.
|
|
319
|
-
"""
|
|
320
|
-
context_parts = []
|
|
321
|
-
|
|
322
|
-
for file_path, content in read_jsonl_entries(repo_root, f"{task_dir}/check.jsonl"):
|
|
323
|
-
context_parts.append(f"=== {file_path} ===\n{content}")
|
|
324
|
-
|
|
325
|
-
prd_content = read_file_content(repo_root, f"{task_dir}/prd.md")
|
|
326
|
-
if prd_content:
|
|
327
|
-
context_parts.append(f"=== {task_dir}/prd.md (Requirements) ===\n{prd_content}")
|
|
328
|
-
|
|
329
|
-
design_content = read_file_content(repo_root, f"{task_dir}/design.md")
|
|
330
|
-
if design_content:
|
|
331
|
-
context_parts.append(
|
|
332
|
-
f"=== {task_dir}/design.md (Technical Design) ===\n{design_content}"
|
|
333
|
-
)
|
|
334
|
-
|
|
335
|
-
implement_plan_content = read_file_content(repo_root, f"{task_dir}/implement.md")
|
|
336
|
-
if implement_plan_content:
|
|
337
|
-
context_parts.append(
|
|
338
|
-
f"=== {task_dir}/implement.md (Execution Plan) ===\n{implement_plan_content}"
|
|
339
|
-
)
|
|
340
|
-
|
|
341
|
-
return "\n\n".join(context_parts)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
def get_finish_context(repo_root: str, task_dir: str) -> str:
|
|
345
|
-
"""
|
|
346
|
-
Context for Finish phase: reuses check.jsonl + prd.md
|
|
347
|
-
(Finish is a final check, same context source.)
|
|
348
|
-
"""
|
|
349
|
-
return get_check_context(repo_root, task_dir)
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
def build_implement_prompt(original_prompt: str, context: str) -> str:
|
|
354
|
-
"""Build complete prompt for Implement"""
|
|
355
|
-
return f"""<!-- trellis-hook-injected -->
|
|
356
|
-
# Implement Agent Task
|
|
357
|
-
|
|
358
|
-
You are the Implement Agent in the Multi-Agent Pipeline.
|
|
359
|
-
|
|
360
|
-
## Your Context
|
|
361
|
-
|
|
362
|
-
All the information you need has been prepared for you:
|
|
363
|
-
|
|
364
|
-
{context}
|
|
365
|
-
|
|
366
|
-
---
|
|
367
|
-
|
|
368
|
-
## Your Task
|
|
369
|
-
|
|
370
|
-
{original_prompt}
|
|
371
|
-
|
|
372
|
-
---
|
|
373
|
-
|
|
374
|
-
## Workflow
|
|
375
|
-
|
|
376
|
-
1. **Understand specs** - All dev specs are injected above, understand them
|
|
377
|
-
2. **Understand task artifacts** - Read requirements, technical design if present, and execution plan if present
|
|
378
|
-
3. **Implement feature** - Implement following specs and task artifacts
|
|
379
|
-
4. **Self-check** - Ensure code quality against check specs
|
|
380
|
-
|
|
381
|
-
## Important Constraints
|
|
382
|
-
|
|
383
|
-
- Do NOT execute git commit, only code modifications
|
|
384
|
-
- Follow all dev specs injected above
|
|
385
|
-
- Report list of modified/created files when done"""
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
def build_check_prompt(original_prompt: str, context: str) -> str:
|
|
389
|
-
"""Build complete prompt for Check"""
|
|
390
|
-
return f"""<!-- trellis-hook-injected -->
|
|
391
|
-
# Check Agent Task
|
|
392
|
-
|
|
393
|
-
You are the Check Agent in the Multi-Agent Pipeline (code and cross-layer checker).
|
|
394
|
-
|
|
395
|
-
## Your Context
|
|
396
|
-
|
|
397
|
-
All check specs and dev specs you need:
|
|
398
|
-
|
|
399
|
-
{context}
|
|
400
|
-
|
|
401
|
-
---
|
|
402
|
-
|
|
403
|
-
## Your Task
|
|
404
|
-
|
|
405
|
-
{original_prompt}
|
|
406
|
-
|
|
407
|
-
---
|
|
408
|
-
|
|
409
|
-
## Workflow
|
|
410
|
-
|
|
411
|
-
1. **Get changes** - Run `git diff --name-only` and `git diff` to get code changes
|
|
412
|
-
2. **Check against specs** - Check item by item against specs above
|
|
413
|
-
3. **Self-fix** - Fix issues directly, don't just report
|
|
414
|
-
4. **Run verification** - Run project's lint and typecheck commands
|
|
415
|
-
|
|
416
|
-
## Important Constraints
|
|
417
|
-
|
|
418
|
-
- Fix issues yourself, don't just report
|
|
419
|
-
- Must execute complete checklist in check specs
|
|
420
|
-
- Pay special attention to impact radius analysis (L1-L5)"""
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
def build_finish_prompt(original_prompt: str, context: str) -> str:
|
|
424
|
-
"""Build complete prompt for Finish (final check before PR)"""
|
|
425
|
-
return f"""<!-- trellis-hook-injected -->
|
|
426
|
-
# Finish Agent Task
|
|
427
|
-
|
|
428
|
-
You are performing the final check before creating a PR.
|
|
429
|
-
|
|
430
|
-
## Your Context
|
|
431
|
-
|
|
432
|
-
Finish checklist and requirements:
|
|
433
|
-
|
|
434
|
-
{context}
|
|
435
|
-
|
|
436
|
-
---
|
|
437
|
-
|
|
438
|
-
## Your Task
|
|
439
|
-
|
|
440
|
-
{original_prompt}
|
|
441
|
-
|
|
442
|
-
---
|
|
443
|
-
|
|
444
|
-
## Workflow
|
|
445
|
-
|
|
446
|
-
1. **Review changes** - Run `git diff --name-only` to see all changed files
|
|
447
|
-
2. **Verify task artifacts** - Check requirements in prd.md and, when present, design.md / implement.md
|
|
448
|
-
3. **Spec sync** - Analyze whether changes introduce new patterns, contracts, or conventions
|
|
449
|
-
- If new pattern/convention found: read target spec file → update it → update index.md if needed
|
|
450
|
-
- If infra/cross-layer change: follow the 7-section mandatory template from update-spec.md
|
|
451
|
-
- If pure code fix with no new patterns: skip this step
|
|
452
|
-
4. **Run final checks** - Execute lint and typecheck
|
|
453
|
-
5. **Confirm ready** - Ensure code is ready for PR
|
|
454
|
-
|
|
455
|
-
## Important Constraints
|
|
456
|
-
|
|
457
|
-
- You MAY update spec files when gaps are detected (use update-spec.md as guide)
|
|
458
|
-
- MUST read the target spec file BEFORE editing (avoid duplicating existing content)
|
|
459
|
-
- Do NOT update specs for trivial changes (typos, formatting, obvious fixes)
|
|
460
|
-
- If critical CODE issues found, report them clearly (fix specs, not code)
|
|
461
|
-
- Verify all acceptance criteria in prd.md are met
|
|
462
|
-
- Verify design.md and implement.md constraints when those files are present"""
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
def get_research_context(repo_root: str, task_dir: str | None) -> str:
|
|
467
|
-
"""
|
|
468
|
-
Context for Research Agent — project structure overview for spec directories.
|
|
469
|
-
|
|
470
|
-
`task_dir` kept for signature parity with get_implement_context / get_check_context
|
|
471
|
-
so the dispatcher can call them uniformly.
|
|
472
|
-
"""
|
|
473
|
-
_ = task_dir
|
|
474
|
-
context_parts = []
|
|
475
|
-
|
|
476
|
-
# 1. Project structure overview (dynamically discover spec directories)
|
|
477
|
-
spec_path = f"{DIR_WORKFLOW}/{DIR_SPEC}"
|
|
478
|
-
spec_root = Path(repo_root) / DIR_WORKFLOW / DIR_SPEC
|
|
479
|
-
|
|
480
|
-
# Build spec tree dynamically
|
|
481
|
-
tree_lines = [f"{spec_path}/"]
|
|
482
|
-
if spec_root.is_dir():
|
|
483
|
-
pkg_dirs = sorted(d for d in spec_root.iterdir() if d.is_dir())
|
|
484
|
-
for i, pkg_dir in enumerate(pkg_dirs):
|
|
485
|
-
is_last = i == len(pkg_dirs) - 1
|
|
486
|
-
prefix = "└── " if is_last else "├── "
|
|
487
|
-
layers = sorted(d.name for d in pkg_dir.iterdir() if d.is_dir())
|
|
488
|
-
layer_info = f" ({', '.join(layers)})" if layers else ""
|
|
489
|
-
tree_lines.append(f"{prefix}{pkg_dir.name}/{layer_info}")
|
|
490
|
-
|
|
491
|
-
spec_tree = "\n".join(tree_lines)
|
|
492
|
-
|
|
493
|
-
project_structure = f"""## Project Spec Directory Structure
|
|
494
|
-
|
|
495
|
-
```
|
|
496
|
-
{spec_tree}
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
To get structured package info, run: `python ./{DIR_WORKFLOW}/scripts/get_context.py --mode packages`
|
|
500
|
-
|
|
501
|
-
## Search Tips
|
|
502
|
-
|
|
503
|
-
- Spec files: `{spec_path}/**/*.md`
|
|
504
|
-
- Code search: Use Glob and Grep tools
|
|
505
|
-
- External facts / docs: load `smart-search-cli` skill and use Bash (`smart-search` CLI), not Cursor WebSearch/WebFetch by default"""
|
|
506
|
-
|
|
507
|
-
context_parts.append(project_structure)
|
|
508
|
-
|
|
509
|
-
return "\n\n".join(context_parts)
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
def build_research_prompt(original_prompt: str, context: str) -> str:
|
|
513
|
-
"""Build complete prompt for Research (aligned with trellis-research.md)."""
|
|
514
|
-
return f"""<!-- trellis-hook-injected -->
|
|
515
|
-
# Research Agent Task
|
|
516
|
-
|
|
517
|
-
You are the Trellis Research Agent.
|
|
518
|
-
|
|
519
|
-
## Core Principle
|
|
520
|
-
|
|
521
|
-
**You do one thing: find, explain, and PERSIST information.**
|
|
522
|
-
|
|
523
|
-
Conversations get compacted; files do not. Every research topic MUST be written under `{{TASK_DIR}}/research/`. Chat-only findings are a failure.
|
|
524
|
-
|
|
525
|
-
## Dispatch contract
|
|
526
|
-
|
|
527
|
-
- External facts: load `smart-search-cli` skill + Bash — default **not** Cursor `WebSearch`/`WebFetch`.
|
|
528
|
-
- Do NOT spawn nested `trellis-implement` / `trellis-check` / `trellis-research` sub-agents.
|
|
529
|
-
|
|
530
|
-
## Project Info
|
|
531
|
-
|
|
532
|
-
{context}
|
|
533
|
-
|
|
534
|
-
---
|
|
535
|
-
|
|
536
|
-
## Your Task
|
|
537
|
-
|
|
538
|
-
{original_prompt}
|
|
539
|
-
|
|
540
|
-
---
|
|
541
|
-
|
|
542
|
-
## Workflow
|
|
543
|
-
|
|
544
|
-
1. **Resolve task** — `python ./.trellis/scripts/task.py selected --source`; ensure `{{TASK_DIR}}/research/` exists (`mkdir -p`).
|
|
545
|
-
2. **Classify** — internal / external / mixed.
|
|
546
|
-
3. **Search** — Glob/Grep/Read for repo; `smart-search-cli` + CLI for external.
|
|
547
|
-
4. **Persist** — Write each topic to `{{TASK_DIR}}/research/<topic-slug>.md`.
|
|
548
|
-
5. **Report** — Reply with file paths + one-line summaries only (not full content).
|
|
549
|
-
|
|
550
|
-
## Write ALLOWED
|
|
551
|
-
|
|
552
|
-
- `{{TASK_DIR}}/research/*.md` only
|
|
553
|
-
|
|
554
|
-
## Write FORBIDDEN
|
|
555
|
-
|
|
556
|
-
- Code, `.trellis/spec/`, platform config, git operations"""
|
|
557
|
-
|
|
558
|
-
|
|
559
90
|
def _string_value(value: Any) -> str:
|
|
560
91
|
if isinstance(value, str):
|
|
561
|
-
|
|
562
|
-
return stripped
|
|
92
|
+
return value.strip()
|
|
563
93
|
return ""
|
|
564
94
|
|
|
565
95
|
|
|
566
96
|
def _extract_subagent_name(value: Any) -> str:
|
|
567
|
-
"""Extract a sub-agent name from common platform encodings.
|
|
568
|
-
|
|
569
|
-
Cursor's native Task args encode custom sub-agents as a protobuf oneof,
|
|
570
|
-
which can appear in hook JSON as either ``{"custom": {"name": "..."}}``
|
|
571
|
-
or ``{"type": {"case": "custom", "value": {"name": "..."}}}``.
|
|
572
|
-
"""
|
|
573
97
|
direct = _string_value(value)
|
|
574
98
|
if direct:
|
|
575
99
|
return direct
|
|
576
|
-
|
|
577
100
|
if not isinstance(value, dict):
|
|
578
101
|
return ""
|
|
579
|
-
|
|
580
102
|
for key in ("name", "subagent_type_name", "subagentTypeName"):
|
|
581
103
|
direct = _string_value(value.get(key))
|
|
582
104
|
if direct:
|
|
583
105
|
return direct
|
|
584
|
-
|
|
585
106
|
custom = value.get("custom")
|
|
586
107
|
if isinstance(custom, dict):
|
|
587
108
|
custom_name = _string_value(custom.get("name"))
|
|
588
109
|
if custom_name:
|
|
589
110
|
return custom_name
|
|
590
|
-
|
|
591
111
|
oneof = value.get("type")
|
|
592
112
|
if isinstance(oneof, dict):
|
|
593
113
|
case_name = _string_value(oneof.get("case"))
|
|
@@ -599,21 +119,9 @@ def _extract_subagent_name(value: Any) -> str:
|
|
|
599
119
|
return custom_name
|
|
600
120
|
if case_name:
|
|
601
121
|
return case_name
|
|
602
|
-
|
|
603
|
-
case_name = _string_value(value.get("case"))
|
|
604
|
-
if case_name == "custom":
|
|
605
|
-
nested_value = value.get("value")
|
|
606
|
-
if isinstance(nested_value, dict):
|
|
607
|
-
custom_name = _string_value(nested_value.get("name"))
|
|
608
|
-
if custom_name:
|
|
609
|
-
return custom_name
|
|
610
|
-
if case_name:
|
|
611
|
-
return case_name
|
|
612
|
-
|
|
613
122
|
for agent_name in AGENTS_ALL:
|
|
614
123
|
if agent_name in value:
|
|
615
124
|
return agent_name
|
|
616
|
-
|
|
617
125
|
return ""
|
|
618
126
|
|
|
619
127
|
|
|
@@ -634,19 +142,7 @@ def _extract_subagent_type(tool_input: dict) -> str:
|
|
|
634
142
|
|
|
635
143
|
|
|
636
144
|
def _parse_hook_input(input_data: dict) -> tuple[str, str, dict]:
|
|
637
|
-
"""Parse hook input across different platform formats.
|
|
638
|
-
|
|
639
|
-
Returns (subagent_type, original_prompt, tool_input).
|
|
640
|
-
Handles:
|
|
641
|
-
- Claude Code / Qoder / CodeBuddy / Droid: tool_name=Task|Agent, tool_input.subagent_type
|
|
642
|
-
- Cursor: tool_name=Task|Subagent, tool_input.subagent_type
|
|
643
|
-
- Copilot CLI: toolName=task (camelCase key, lowercase value)
|
|
644
|
-
- Gemini CLI: tool_name IS the agent name (BeforeTool matcher already filtered)
|
|
645
|
-
- Kiro: agentSpawn hook, agent_name field at top level
|
|
646
|
-
"""
|
|
647
145
|
tool_input = input_data.get("tool_input", {})
|
|
648
|
-
|
|
649
|
-
# Standard format: Task/Agent tool with subagent_type
|
|
650
146
|
tool_name = input_data.get("tool_name", "") or input_data.get("toolName", "")
|
|
651
147
|
if tool_name.lower() in ("task", "agent", "subagent"):
|
|
652
148
|
return (
|
|
@@ -654,26 +150,18 @@ def _parse_hook_input(input_data: dict) -> tuple[str, str, dict]:
|
|
|
654
150
|
tool_input.get("prompt", ""),
|
|
655
151
|
tool_input,
|
|
656
152
|
)
|
|
657
|
-
|
|
658
|
-
# Kiro: agentSpawn hook passes agent_name at top level
|
|
659
153
|
agent_name = input_data.get("agent_name", "")
|
|
660
154
|
if agent_name:
|
|
661
155
|
return agent_name, tool_input.get("prompt", input_data.get("prompt", "")), tool_input
|
|
662
|
-
|
|
663
|
-
# Gemini CLI: BeforeTool where tool_name IS the agent name
|
|
664
|
-
# (matcher already ensured it's one of our agents)
|
|
665
156
|
if tool_name in AGENTS_ALL:
|
|
666
157
|
return tool_name, tool_input.get("prompt", ""), tool_input
|
|
667
|
-
|
|
668
|
-
# Copilot CLI: toolName field (camelCase), value might be the agent name
|
|
669
158
|
tool_name_camel = input_data.get("toolName", "")
|
|
670
159
|
if tool_name_camel in AGENTS_ALL:
|
|
671
160
|
return tool_name_camel, input_data.get("toolArgs", ""), tool_input
|
|
672
|
-
|
|
673
161
|
return "", "", tool_input
|
|
674
162
|
|
|
675
163
|
|
|
676
|
-
def main():
|
|
164
|
+
def main() -> None:
|
|
677
165
|
if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1":
|
|
678
166
|
sys.exit(0)
|
|
679
167
|
|
|
@@ -683,21 +171,26 @@ def main():
|
|
|
683
171
|
sys.exit(0)
|
|
684
172
|
|
|
685
173
|
subagent_type, original_prompt, tool_input = _parse_hook_input(input_data)
|
|
686
|
-
cwd = input_data.get("cwd", os.getcwd())
|
|
687
|
-
|
|
688
|
-
# Only handle subagent types we care about
|
|
689
174
|
if subagent_type not in AGENTS_ALL:
|
|
690
175
|
sys.exit(0)
|
|
691
176
|
|
|
692
|
-
|
|
693
|
-
repo_root = find_repo_root(cwd)
|
|
177
|
+
repo_root = find_repo_root(input_data.get("cwd", os.getcwd()))
|
|
694
178
|
if not repo_root:
|
|
695
179
|
sys.exit(0)
|
|
696
180
|
|
|
697
|
-
|
|
698
|
-
|
|
181
|
+
scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts"
|
|
182
|
+
if str(scripts_dir) not in sys.path:
|
|
183
|
+
sys.path.insert(0, str(scripts_dir))
|
|
184
|
+
|
|
185
|
+
from common.subagent_dispatch import ( # type: ignore[import-not-found]
|
|
186
|
+
build_dispatch_prompt_for_agent,
|
|
187
|
+
prompt_has_injection_marker,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if prompt_has_injection_marker(original_prompt):
|
|
191
|
+
sys.exit(0)
|
|
699
192
|
|
|
700
|
-
|
|
193
|
+
task_dir = get_selected_task(repo_root, input_data)
|
|
701
194
|
if not task_dir and original_prompt:
|
|
702
195
|
for line in original_prompt.split("\n"):
|
|
703
196
|
line = line.strip()
|
|
@@ -711,61 +204,32 @@ def main():
|
|
|
711
204
|
)
|
|
712
205
|
break
|
|
713
206
|
|
|
714
|
-
# implement/check need task directory
|
|
715
207
|
if subagent_type in AGENTS_REQUIRE_TASK:
|
|
716
208
|
if not task_dir:
|
|
717
209
|
sys.exit(0)
|
|
718
|
-
|
|
719
|
-
task_dir_full = os.path.join(repo_root, task_dir)
|
|
720
|
-
if not os.path.exists(task_dir_full):
|
|
210
|
+
if not os.path.exists(os.path.join(repo_root, task_dir)):
|
|
721
211
|
sys.exit(0)
|
|
722
212
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
new_prompt = build_implement_prompt(original_prompt, context)
|
|
731
|
-
elif subagent_type == AGENT_CHECK:
|
|
732
|
-
assert task_dir is not None # validated above
|
|
733
|
-
if is_finish_phase:
|
|
734
|
-
# Finish phase: use finish context (lighter, focused on final verification)
|
|
735
|
-
context = get_finish_context(repo_root, task_dir)
|
|
736
|
-
new_prompt = build_finish_prompt(original_prompt, context)
|
|
737
|
-
else:
|
|
738
|
-
# Regular check phase: use check context (full specs for self-fix loop)
|
|
739
|
-
context = get_check_context(repo_root, task_dir)
|
|
740
|
-
new_prompt = build_check_prompt(original_prompt, context)
|
|
741
|
-
elif subagent_type == AGENT_RESEARCH:
|
|
742
|
-
# Research can work without task directory
|
|
743
|
-
context = get_research_context(repo_root, task_dir)
|
|
744
|
-
new_prompt = build_research_prompt(original_prompt, context)
|
|
745
|
-
else:
|
|
746
|
-
sys.exit(0)
|
|
747
|
-
|
|
748
|
-
if not context:
|
|
213
|
+
new_prompt = build_dispatch_prompt_for_agent(
|
|
214
|
+
repo_root,
|
|
215
|
+
task_dir or "",
|
|
216
|
+
subagent_type,
|
|
217
|
+
original_prompt,
|
|
218
|
+
)
|
|
219
|
+
if not new_prompt:
|
|
749
220
|
sys.exit(0)
|
|
750
221
|
|
|
751
|
-
# Return updated input — use a multi-format output that covers all platforms.
|
|
752
|
-
# Most platforms ignore unrecognized fields, so we include multiple formats.
|
|
753
|
-
# The platform picks whichever fields it understands.
|
|
754
222
|
updated = {**tool_input, "prompt": new_prompt}
|
|
755
223
|
output = {
|
|
756
|
-
# Claude Code / Qoder / CodeBuddy / Droid format
|
|
757
224
|
"hookSpecificOutput": {
|
|
758
225
|
"hookEventName": "PreToolUse",
|
|
759
226
|
"permissionDecision": "allow",
|
|
760
227
|
"updatedInput": updated,
|
|
761
228
|
},
|
|
762
|
-
# Cursor format
|
|
763
229
|
"permission": "allow",
|
|
764
230
|
"updated_input": updated,
|
|
765
|
-
# Gemini format
|
|
766
231
|
"updatedInput": updated,
|
|
767
232
|
}
|
|
768
|
-
|
|
769
233
|
print(json.dumps(output, ensure_ascii=False))
|
|
770
234
|
sys.exit(0)
|
|
771
235
|
|