@mindfoldhq/trellis 0.5.0-beta.14 → 0.5.0-beta.16

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 (127) hide show
  1. package/README.md +5 -5
  2. package/dist/cli/index.js +1 -0
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/commands/init.d.ts +1 -0
  5. package/dist/commands/init.d.ts.map +1 -1
  6. package/dist/commands/init.js +24 -20
  7. package/dist/commands/init.js.map +1 -1
  8. package/dist/commands/update.d.ts.map +1 -1
  9. package/dist/commands/update.js +44 -13
  10. package/dist/commands/update.js.map +1 -1
  11. package/dist/configurators/claude.js +1 -1
  12. package/dist/configurators/claude.js.map +1 -1
  13. package/dist/configurators/codebuddy.js +1 -1
  14. package/dist/configurators/codebuddy.js.map +1 -1
  15. package/dist/configurators/codex.d.ts.map +1 -1
  16. package/dist/configurators/codex.js +3 -6
  17. package/dist/configurators/codex.js.map +1 -1
  18. package/dist/configurators/copilot.d.ts.map +1 -1
  19. package/dist/configurators/copilot.js +4 -11
  20. package/dist/configurators/copilot.js.map +1 -1
  21. package/dist/configurators/cursor.js +1 -1
  22. package/dist/configurators/cursor.js.map +1 -1
  23. package/dist/configurators/droid.js +1 -1
  24. package/dist/configurators/droid.js.map +1 -1
  25. package/dist/configurators/gemini.d.ts.map +1 -1
  26. package/dist/configurators/gemini.js +1 -3
  27. package/dist/configurators/gemini.js.map +1 -1
  28. package/dist/configurators/index.d.ts.map +1 -1
  29. package/dist/configurators/index.js +24 -38
  30. package/dist/configurators/index.js.map +1 -1
  31. package/dist/configurators/kiro.js +1 -1
  32. package/dist/configurators/kiro.js.map +1 -1
  33. package/dist/configurators/opencode.d.ts.map +1 -1
  34. package/dist/configurators/opencode.js +4 -1
  35. package/dist/configurators/opencode.js.map +1 -1
  36. package/dist/configurators/pi.d.ts +3 -0
  37. package/dist/configurators/pi.d.ts.map +1 -0
  38. package/dist/configurators/pi.js +39 -0
  39. package/dist/configurators/pi.js.map +1 -0
  40. package/dist/configurators/qoder.d.ts.map +1 -1
  41. package/dist/configurators/qoder.js +1 -3
  42. package/dist/configurators/qoder.js.map +1 -1
  43. package/dist/configurators/shared.d.ts +2 -4
  44. package/dist/configurators/shared.d.ts.map +1 -1
  45. package/dist/configurators/shared.js +6 -9
  46. package/dist/configurators/shared.js.map +1 -1
  47. package/dist/migrations/manifests/0.5.0-beta.15.json +116 -0
  48. package/dist/migrations/manifests/0.5.0-beta.16.json +9 -0
  49. package/dist/templates/claude/agents/trellis-research.md +1 -1
  50. package/dist/templates/claude/settings.json +0 -4
  51. package/dist/templates/codebuddy/agents/trellis-research.md +1 -1
  52. package/dist/templates/codex/agents/trellis-check.toml +0 -16
  53. package/dist/templates/codex/agents/trellis-implement.toml +0 -16
  54. package/dist/templates/codex/agents/trellis-research.toml +3 -2
  55. package/dist/templates/codex/hooks/session-start.py +51 -21
  56. package/dist/templates/codex/skills/start/SKILL.md +1 -1
  57. package/dist/templates/copilot/hooks/session-start.py +51 -21
  58. package/dist/templates/copilot/prompts/start.prompt.md +1 -1
  59. package/dist/templates/cursor/agents/trellis-check.md +1 -1
  60. package/dist/templates/cursor/agents/trellis-implement.md +1 -1
  61. package/dist/templates/cursor/agents/trellis-research.md +2 -2
  62. package/dist/templates/cursor/hooks.json +7 -1
  63. package/dist/templates/droid/droids/trellis-research.md +1 -1
  64. package/dist/templates/extract.d.ts +6 -0
  65. package/dist/templates/extract.d.ts.map +1 -1
  66. package/dist/templates/extract.js +14 -0
  67. package/dist/templates/extract.js.map +1 -1
  68. package/dist/templates/gemini/agents/trellis-research.md +1 -1
  69. package/dist/templates/kiro/agents/trellis-research.json +1 -1
  70. package/dist/templates/markdown/agents.md +11 -12
  71. package/dist/templates/markdown/gitignore.txt +3 -0
  72. package/dist/templates/opencode/agents/trellis-check.md +1 -1
  73. package/dist/templates/opencode/agents/trellis-implement.md +1 -1
  74. package/dist/templates/opencode/agents/trellis-research.md +2 -2
  75. package/dist/templates/opencode/lib/trellis-context.js +100 -13
  76. package/dist/templates/opencode/plugins/inject-subagent-context.js +54 -4
  77. package/dist/templates/opencode/plugins/inject-workflow-state.js +48 -25
  78. package/dist/templates/opencode/plugins/session-start.js +29 -16
  79. package/dist/templates/pi/agents/trellis-check.md +28 -0
  80. package/dist/templates/pi/agents/trellis-implement.md +33 -0
  81. package/dist/templates/pi/agents/trellis-research.md +25 -0
  82. package/dist/templates/pi/extensions/trellis/index.ts.txt +549 -0
  83. package/dist/templates/pi/index.d.ts +5 -0
  84. package/dist/templates/pi/index.d.ts.map +1 -0
  85. package/dist/templates/pi/index.js +12 -0
  86. package/dist/templates/pi/index.js.map +1 -0
  87. package/dist/templates/pi/settings.json +12 -0
  88. package/dist/templates/qoder/agents/trellis-research.md +1 -1
  89. package/dist/templates/shared-hooks/index.d.ts +31 -0
  90. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  91. package/dist/templates/shared-hooks/index.js +59 -0
  92. package/dist/templates/shared-hooks/index.js.map +1 -1
  93. package/dist/templates/shared-hooks/inject-shell-session-context.py +180 -0
  94. package/dist/templates/shared-hooks/inject-subagent-context.py +128 -26
  95. package/dist/templates/shared-hooks/inject-workflow-state.py +99 -62
  96. package/dist/templates/shared-hooks/session-start.py +139 -24
  97. package/dist/templates/trellis/gitignore.txt +3 -0
  98. package/dist/templates/trellis/index.d.ts +1 -0
  99. package/dist/templates/trellis/index.d.ts.map +1 -1
  100. package/dist/templates/trellis/index.js +2 -0
  101. package/dist/templates/trellis/index.js.map +1 -1
  102. package/dist/templates/trellis/scripts/common/__init__.py +8 -0
  103. package/dist/templates/trellis/scripts/common/active_task.py +593 -0
  104. package/dist/templates/trellis/scripts/common/cli_adapter.py +43 -8
  105. package/dist/templates/trellis/scripts/common/paths.py +61 -58
  106. package/dist/templates/trellis/scripts/common/session_context.py +12 -0
  107. package/dist/templates/trellis/scripts/common/task_store.py +6 -8
  108. package/dist/templates/trellis/scripts/task.py +59 -17
  109. package/dist/templates/trellis/workflow.md +30 -26
  110. package/dist/types/ai-tools.d.ts +3 -3
  111. package/dist/types/ai-tools.d.ts.map +1 -1
  112. package/dist/types/ai-tools.js +16 -0
  113. package/dist/types/ai-tools.js.map +1 -1
  114. package/dist/utils/posix.d.ts +13 -0
  115. package/dist/utils/posix.d.ts.map +1 -0
  116. package/dist/utils/posix.js +15 -0
  117. package/dist/utils/posix.js.map +1 -0
  118. package/dist/utils/template-fetcher.d.ts +22 -6
  119. package/dist/utils/template-fetcher.d.ts.map +1 -1
  120. package/dist/utils/template-fetcher.js +405 -27
  121. package/dist/utils/template-fetcher.js.map +1 -1
  122. package/dist/utils/template-hash.d.ts +22 -3
  123. package/dist/utils/template-hash.d.ts.map +1 -1
  124. package/dist/utils/template-hash.js +80 -18
  125. package/dist/utils/template-hash.js.map +1 -1
  126. package/package.json +1 -1
  127. package/dist/templates/shared-hooks/statusline.py +0 -219
@@ -13,6 +13,56 @@ const __dirname = dirname(__filename);
13
13
  function readTemplate(relativePath) {
14
14
  return readFileSync(join(__dirname, relativePath), "utf-8");
15
15
  }
16
+ /**
17
+ * Which shared hooks each platform actually invokes. Single source of truth
18
+ * for shared-hook distribution — both `writeSharedHooks` (runtime install)
19
+ * and `collectSharedHooks` (`trellis update` diff) read from this table.
20
+ *
21
+ * Routing rules encoded here:
22
+ * - `session-start.py` — shipped by every platform with a SessionStart
23
+ * hook event *except* codex + copilot, which bundle a platform-specific
24
+ * session-start.py under their own template dirs.
25
+ * - `inject-workflow-state.py` — every platform with a UserPromptSubmit
26
+ * (or equivalent) event. Kiro + codex self-included; platforms without
27
+ * per-turn main-session hooks are excluded.
28
+ * - `inject-subagent-context.py` — class-1 (push-based) platforms only.
29
+ * Class-2 (pull-based) platforms (codex, copilot, gemini, qoder) can't
30
+ * have hooks mutate sub-agent prompts — their sub-agents load context
31
+ * via a prelude instead.
32
+ * - Kiro supports only `agentSpawn` (no SessionStart / UserPromptSubmit
33
+ * event), so it takes just `inject-subagent-context.py`.
34
+ * - Claude Code `statusLine` is intentionally not installed by default.
35
+ * Users can add their own statusLine command in `.claude/settings.json`
36
+ * without Trellis owning a generated hook file.
37
+ */
38
+ export const SHARED_HOOKS_BY_PLATFORM = {
39
+ claude: [
40
+ "session-start.py",
41
+ "inject-workflow-state.py",
42
+ "inject-subagent-context.py",
43
+ ],
44
+ cursor: [
45
+ "session-start.py",
46
+ "inject-shell-session-context.py",
47
+ "inject-workflow-state.py",
48
+ "inject-subagent-context.py",
49
+ ],
50
+ codex: ["inject-workflow-state.py"],
51
+ gemini: ["session-start.py", "inject-workflow-state.py"],
52
+ qoder: ["session-start.py", "inject-workflow-state.py"],
53
+ copilot: ["inject-workflow-state.py"],
54
+ codebuddy: [
55
+ "session-start.py",
56
+ "inject-workflow-state.py",
57
+ "inject-subagent-context.py",
58
+ ],
59
+ droid: [
60
+ "session-start.py",
61
+ "inject-workflow-state.py",
62
+ "inject-subagent-context.py",
63
+ ],
64
+ kiro: ["inject-subagent-context.py"],
65
+ };
16
66
  /**
17
67
  * Get all shared hook scripts. Content is platform-independent and can be
18
68
  * written directly without placeholder resolution.
@@ -27,4 +77,13 @@ export function getSharedHookScripts() {
27
77
  }
28
78
  return scripts;
29
79
  }
80
+ /**
81
+ * Get the shared hook scripts that a given platform actually registers.
82
+ * Drives both `writeSharedHooks` and `collectSharedHooks` so distribution
83
+ * never drifts from the per-platform capability declared above.
84
+ */
85
+ export function getSharedHookScriptsForPlatform(platform) {
86
+ const allowed = new Set(SHARED_HOOKS_BY_PLATFORM[platform]);
87
+ return getSharedHookScripts().filter((h) => allowed.has(h.name));
88
+ }
30
89
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/templates/shared-hooks/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAClD,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;AAEtC,SAAS,YAAY,CAAC,YAAoB;IACxC,OAAO,YAAY,CAAC,IAAI,CAAC,SAAS,EAAE,YAAY,CAAC,EAAE,OAAO,CAAC,CAAC;AAC9D,CAAC;AASD;;;GAGG;AACH,MAAM,UAAU,oBAAoB;IAClC,MAAM,OAAO,GAAiB,EAAE,CAAC;IACjC,MAAM,KAAK,GAAG,WAAW,CAAC,SAAS,CAAC;SACjC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;SAChC,IAAI,EAAE,CAAC;IAEV,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC5D,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/templates/shared-hooks/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAClD,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;AAEtC,SAAS,YAAY,CAAC,YAAoB;IACxC,OAAO,YAAY,CAAC,IAAI,CAAC,SAAS,EAAE,YAAY,CAAC,EAAE,OAAO,CAAC,CAAC;AAC9D,CAAC;AA0BD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAGjC;IACF,MAAM,EAAE;QACN,kBAAkB;QAClB,0BAA0B;QAC1B,4BAA4B;KAC7B;IACD,MAAM,EAAE;QACN,kBAAkB;QAClB,iCAAiC;QACjC,0BAA0B;QAC1B,4BAA4B;KAC7B;IACD,KAAK,EAAE,CAAC,0BAA0B,CAAC;IACnC,MAAM,EAAE,CAAC,kBAAkB,EAAE,0BAA0B,CAAC;IACxD,KAAK,EAAE,CAAC,kBAAkB,EAAE,0BAA0B,CAAC;IACvD,OAAO,EAAE,CAAC,0BAA0B,CAAC;IACrC,SAAS,EAAE;QACT,kBAAkB;QAClB,0BAA0B;QAC1B,4BAA4B;KAC7B;IACD,KAAK,EAAE;QACL,kBAAkB;QAClB,0BAA0B;QAC1B,4BAA4B;KAC7B;IACD,IAAI,EAAE,CAAC,4BAA4B,CAAC;CACrC,CAAC;AAEF;;;GAGG;AACH,MAAM,UAAU,oBAAoB;IAClC,MAAM,OAAO,GAAiB,EAAE,CAAC;IACjC,MAAM,KAAK,GAAG,WAAW,CAAC,SAAS,CAAC;SACjC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;SAChC,IAAI,EAAE,CAAC;IAEV,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC5D,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,+BAA+B,CAC7C,QAA4B;IAE5B,MAAM,OAAO,GAAG,IAAI,GAAG,CAAS,wBAAwB,CAAC,QAAQ,CAAC,CAAC,CAAC;IACpE,OAAO,oBAAoB,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;AACnE,CAAC"}
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ """Cursor beforeShellExecution hook: bridge conversation identity to task.py.
3
+
4
+ Cursor's shell command environment does not inherit SessionStart data. This
5
+ hook writes a short-lived runtime ticket before Cursor runs a shell command
6
+ that calls `task.py start/current/finish`. The task script then consumes the
7
+ ticket only when it has no native session environment.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import shlex
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ DIR_WORKFLOW = ".trellis"
22
+ DIR_RUNTIME = ".runtime"
23
+ DIR_CURSOR_SHELL = "cursor-shell"
24
+ SESSION_SUBCOMMANDS = {"start", "current", "finish"}
25
+ TICKET_TTL_SECONDS = 30
26
+ CONTEXT_IDENTITY_KEYS = (
27
+ "session_id",
28
+ "sessionId",
29
+ "sessionID",
30
+ "conversation_id",
31
+ "conversationId",
32
+ "conversationID",
33
+ "transcript_path",
34
+ "transcriptPath",
35
+ "transcript",
36
+ )
37
+
38
+
39
+ def _string_value(value: Any) -> str | None:
40
+ if isinstance(value, str):
41
+ stripped = value.strip()
42
+ return stripped or None
43
+ return None
44
+
45
+
46
+ def _find_trellis_root(start: Path) -> Path | None:
47
+ current = start.resolve()
48
+ while True:
49
+ if (current / DIR_WORKFLOW).is_dir():
50
+ return current
51
+ if current == current.parent:
52
+ return None
53
+ current = current.parent
54
+
55
+
56
+ def _runtime_ticket_dir(root: Path) -> Path:
57
+ return root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL
58
+
59
+
60
+ def _load_active_task_resolver(root: Path):
61
+ scripts_dir = root / DIR_WORKFLOW / "scripts"
62
+ if str(scripts_dir) not in sys.path:
63
+ sys.path.insert(0, str(scripts_dir))
64
+ from common.active_task import resolve_context_key # type: ignore[import-not-found]
65
+
66
+ return resolve_context_key
67
+
68
+
69
+ def _extract_task_subcommands(command: str) -> list[dict[str, str]]:
70
+ try:
71
+ tokens = shlex.split(command, posix=os.name != "nt")
72
+ except ValueError:
73
+ return []
74
+
75
+ subcommands: list[dict[str, str]] = []
76
+ for index, token in enumerate(tokens[:-1]):
77
+ if Path(token.strip("\"'")).name != "task.py":
78
+ continue
79
+ name = tokens[index + 1]
80
+ if name not in SESSION_SUBCOMMANDS:
81
+ continue
82
+ item = {"name": name}
83
+ if name == "start" and index + 2 < len(tokens):
84
+ item["task_ref"] = tokens[index + 2]
85
+ subcommands.append(item)
86
+ return subcommands
87
+
88
+
89
+ def _cleanup_expired_tickets(ticket_dir: Path, now: float) -> None:
90
+ if not ticket_dir.is_dir():
91
+ return
92
+ for ticket_path in ticket_dir.glob("*.json"):
93
+ try:
94
+ data = json.loads(ticket_path.read_text(encoding="utf-8"))
95
+ except (json.JSONDecodeError, OSError):
96
+ continue
97
+ expires_at = data.get("expires_at_epoch")
98
+ if isinstance(expires_at, (int, float)) and expires_at < now:
99
+ try:
100
+ ticket_path.unlink()
101
+ except OSError:
102
+ pass
103
+
104
+
105
+ def _has_context_identity(hook_input: dict[str, Any]) -> bool:
106
+ return any(_string_value(hook_input.get(key)) for key in CONTEXT_IDENTITY_KEYS)
107
+
108
+
109
+ def _write_ticket(
110
+ root: Path,
111
+ hook_input: dict[str, Any],
112
+ context_key: str,
113
+ subcommands: list[dict[str, str]],
114
+ ) -> None:
115
+ now = time.time()
116
+ ticket_dir = _runtime_ticket_dir(root)
117
+ ticket_dir.mkdir(parents=True, exist_ok=True)
118
+ _cleanup_expired_tickets(ticket_dir, now)
119
+
120
+ command = _string_value(hook_input.get("command")) or ""
121
+ digest = hashlib.sha256(
122
+ f"{context_key}\0{command}\0{now}".encode("utf-8"),
123
+ ).hexdigest()[:16]
124
+ ticket_path = ticket_dir / f"{int(now * 1000)}-{digest}.json"
125
+
126
+ payload = {
127
+ "platform": "cursor",
128
+ "context_key": context_key,
129
+ "conversation_id": _string_value(hook_input.get("conversation_id")),
130
+ "session_id": _string_value(hook_input.get("session_id")),
131
+ "generation_id": _string_value(hook_input.get("generation_id")),
132
+ "cwd": _string_value(hook_input.get("cwd")),
133
+ "command": command,
134
+ "subcommands": subcommands,
135
+ "created_at_epoch": now,
136
+ "expires_at_epoch": now + TICKET_TTL_SECONDS,
137
+ }
138
+ ticket_path.write_text(
139
+ json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
140
+ encoding="utf-8",
141
+ )
142
+
143
+
144
+ def main() -> int:
145
+ try:
146
+ hook_input = json.loads(sys.stdin.read())
147
+ except (json.JSONDecodeError, ValueError):
148
+ hook_input = {}
149
+ if not isinstance(hook_input, dict):
150
+ hook_input = {}
151
+
152
+ command = _string_value(hook_input.get("command")) or ""
153
+ subcommands = _extract_task_subcommands(command)
154
+ if not subcommands:
155
+ return 0
156
+
157
+ cwd = Path(_string_value(hook_input.get("cwd")) or os.getcwd())
158
+ root = _find_trellis_root(cwd)
159
+ if root is None:
160
+ return 0
161
+
162
+ if not _has_context_identity(hook_input):
163
+ return 0
164
+
165
+ resolve_context_key = _load_active_task_resolver(root)
166
+ context_key = resolve_context_key(hook_input, platform="cursor")
167
+ if not context_key:
168
+ return 0
169
+
170
+ try:
171
+ _write_ticket(root, hook_input, context_key, subcommands)
172
+ except OSError:
173
+ return 0
174
+
175
+ print(json.dumps({"permission": "allow"}, ensure_ascii=False))
176
+ return 0
177
+
178
+
179
+ if __name__ == "__main__":
180
+ sys.exit(main())
@@ -12,7 +12,7 @@ Core Design Philosophy:
12
12
 
13
13
  Trigger: PreToolUse (before Task tool call)
14
14
 
15
- Context Source: .trellis/.current-task points to task directory
15
+ Context Source: Trellis active task resolver points to task directory
16
16
  - implement.jsonl - Implement agent dedicated context
17
17
  - check.jsonl - Check agent dedicated context
18
18
  - prd.md - Requirements document
@@ -29,6 +29,7 @@ import json
29
29
  import os
30
30
  import sys
31
31
  from pathlib import Path
32
+ from typing import Any
32
33
 
33
34
  # IMPORTANT: Force stdout to use UTF-8 on Windows
34
35
  # This fixes UnicodeEncodeError when outputting non-ASCII characters
@@ -46,7 +47,6 @@ if sys.platform.startswith("win"):
46
47
 
47
48
  DIR_WORKFLOW = ".trellis"
48
49
  DIR_SPEC = "spec"
49
- FILE_CURRENT_TASK = ".current-task"
50
50
  FILE_TASK_JSON = "task.json"
51
51
 
52
52
  # =============================================================================
@@ -78,32 +78,57 @@ def find_repo_root(start_path: str) -> str | None:
78
78
  return None
79
79
 
80
80
 
81
- def get_current_task(repo_root: str) -> str | None:
82
- """
83
- Read current task directory path from .trellis/.current-task
81
+ def _detect_platform(input_data: dict) -> str | None:
82
+ if isinstance(input_data.get("cursor_version"), str):
83
+ return "cursor"
84
+ env_map = {
85
+ "CLAUDE_PROJECT_DIR": "claude",
86
+ "CURSOR_PROJECT_DIR": "cursor",
87
+ "CODEBUDDY_PROJECT_DIR": "codebuddy",
88
+ "FACTORY_PROJECT_DIR": "droid",
89
+ "GEMINI_PROJECT_DIR": "gemini",
90
+ "QODER_PROJECT_DIR": "qoder",
91
+ "KIRO_PROJECT_DIR": "kiro",
92
+ "COPILOT_PROJECT_DIR": "copilot",
93
+ }
94
+ for env_name, platform in env_map.items():
95
+ if os.environ.get(env_name):
96
+ return platform
97
+ script_parts = set(Path(sys.argv[0]).parts)
98
+ if ".claude" in script_parts:
99
+ return "claude"
100
+ if ".cursor" in script_parts:
101
+ return "cursor"
102
+ if ".gemini" in script_parts:
103
+ return "gemini"
104
+ if ".qoder" in script_parts:
105
+ return "qoder"
106
+ if ".codebuddy" in script_parts:
107
+ return "codebuddy"
108
+ if ".factory" in script_parts:
109
+ return "droid"
110
+ if ".kiro" in script_parts:
111
+ return "kiro"
112
+ return None
84
113
 
85
- Returns:
86
- Task directory relative path (relative to repo_root)
87
- None if not set
88
- """
89
- current_task_file = os.path.join(repo_root, DIR_WORKFLOW, FILE_CURRENT_TASK)
90
- if not os.path.exists(current_task_file):
91
- return None
92
114
 
115
+ def get_current_task(repo_root: str, input_data: dict) -> str | None:
116
+ """Resolve current task directory through the unified active task resolver."""
117
+ scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts"
118
+ if str(scripts_dir) not in sys.path:
119
+ sys.path.insert(0, str(scripts_dir))
93
120
  try:
94
- with open(current_task_file, "r", encoding="utf-8") as f:
95
- content = f.read().strip()
96
- if not content:
97
- return None
98
- normalized = content.replace("\\", "/")
99
- while normalized.startswith("./"):
100
- normalized = normalized[2:]
101
- if normalized.startswith("tasks/"):
102
- normalized = f".trellis/{normalized}"
103
- return normalized
121
+ from common.active_task import resolve_active_task # type: ignore[import-not-found]
104
122
  except Exception:
105
123
  return None
106
124
 
125
+ active = resolve_active_task(
126
+ Path(repo_root),
127
+ input_data,
128
+ platform=_detect_platform(input_data),
129
+ )
130
+ return active.task_path
131
+
107
132
 
108
133
  def read_file_content(base_path: str, file_path: str) -> str | None:
109
134
  """Read file content, return None if file doesn't exist"""
@@ -517,13 +542,90 @@ Provide structured search results including:
517
542
  - External references (if any)"""
518
543
 
519
544
 
545
+ def _string_value(value: Any) -> str:
546
+ if isinstance(value, str):
547
+ stripped = value.strip()
548
+ return stripped
549
+ return ""
550
+
551
+
552
+ def _extract_subagent_name(value: Any) -> str:
553
+ """Extract a sub-agent name from common platform encodings.
554
+
555
+ Cursor's native Task args encode custom sub-agents as a protobuf oneof,
556
+ which can appear in hook JSON as either ``{"custom": {"name": "..."}}``
557
+ or ``{"type": {"case": "custom", "value": {"name": "..."}}}``.
558
+ """
559
+ direct = _string_value(value)
560
+ if direct:
561
+ return direct
562
+
563
+ if not isinstance(value, dict):
564
+ return ""
565
+
566
+ for key in ("name", "subagent_type_name", "subagentTypeName"):
567
+ direct = _string_value(value.get(key))
568
+ if direct:
569
+ return direct
570
+
571
+ custom = value.get("custom")
572
+ if isinstance(custom, dict):
573
+ custom_name = _string_value(custom.get("name"))
574
+ if custom_name:
575
+ return custom_name
576
+
577
+ oneof = value.get("type")
578
+ if isinstance(oneof, dict):
579
+ case_name = _string_value(oneof.get("case"))
580
+ if case_name == "custom":
581
+ nested_value = oneof.get("value")
582
+ if isinstance(nested_value, dict):
583
+ custom_name = _string_value(nested_value.get("name"))
584
+ if custom_name:
585
+ return custom_name
586
+ if case_name:
587
+ return case_name
588
+
589
+ case_name = _string_value(value.get("case"))
590
+ if case_name == "custom":
591
+ nested_value = value.get("value")
592
+ if isinstance(nested_value, dict):
593
+ custom_name = _string_value(nested_value.get("name"))
594
+ if custom_name:
595
+ return custom_name
596
+ if case_name:
597
+ return case_name
598
+
599
+ for agent_name in AGENTS_ALL:
600
+ if agent_name in value:
601
+ return agent_name
602
+
603
+ return ""
604
+
605
+
606
+ def _extract_subagent_type(tool_input: dict) -> str:
607
+ for key in (
608
+ "subagent_type",
609
+ "subagentType",
610
+ "subagent_type_name",
611
+ "subagentTypeName",
612
+ "agent_type",
613
+ "agentType",
614
+ "name",
615
+ ):
616
+ agent_name = _extract_subagent_name(tool_input.get(key))
617
+ if agent_name:
618
+ return agent_name
619
+ return ""
620
+
621
+
520
622
  def _parse_hook_input(input_data: dict) -> tuple[str, str, dict]:
521
623
  """Parse hook input across different platform formats.
522
624
 
523
625
  Returns (subagent_type, original_prompt, tool_input).
524
626
  Handles:
525
627
  - Claude Code / Qoder / CodeBuddy / Droid: tool_name=Task|Agent, tool_input.subagent_type
526
- - Cursor: tool_name=Task, tool_input.subagent_type
628
+ - Cursor: tool_name=Task|Subagent, tool_input.subagent_type
527
629
  - Copilot CLI: toolName=task (camelCase key, lowercase value)
528
630
  - Gemini CLI: tool_name IS the agent name (BeforeTool matcher already filtered)
529
631
  - Kiro: agentSpawn hook, agent_name field at top level
@@ -532,9 +634,9 @@ def _parse_hook_input(input_data: dict) -> tuple[str, str, dict]:
532
634
 
533
635
  # Standard format: Task/Agent tool with subagent_type
534
636
  tool_name = input_data.get("tool_name", "") or input_data.get("toolName", "")
535
- if tool_name.lower() in ("task", "agent"):
637
+ if tool_name.lower() in ("task", "agent", "subagent"):
536
638
  return (
537
- tool_input.get("subagent_type", ""),
639
+ _extract_subagent_type(tool_input),
538
640
  tool_input.get("prompt", ""),
539
641
  tool_input,
540
642
  )
@@ -576,7 +678,7 @@ def main():
576
678
  sys.exit(0)
577
679
 
578
680
  # Get current task directory (research doesn't require it)
579
- task_dir = get_current_task(repo_root)
681
+ task_dir = get_current_task(repo_root, input_data)
580
682
 
581
683
  # implement/check need task directory
582
684
  if subagent_type in AGENTS_REQUIRE_TASK: