@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.
@@ -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
- Injects task-specific context when sub-agents (implement, check, research) are spawned.
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 ".gemini" in script_parts:
106
- return "gemini"
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
- stripped = value.strip()
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
- # Find repo root
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
- # Get selected task directory (research doesn't require it)
698
- task_dir = get_selected_task(repo_root, input_data)
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
- # Fallback: extract task path from dispatch prompt (Multitask / clean-context scenarios)
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
- # Check if task directory exists
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
- # Check for [finish] marker in prompt (check agent with finish context)
724
- is_finish_phase = "[finish]" in original_prompt.lower()
725
-
726
- # Get context and build prompt based on subagent type
727
- if subagent_type == AGENT_IMPLEMENT:
728
- assert task_dir is not None # validated above
729
- context = get_implement_context(repo_root, task_dir)
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