@mindfoldhq/trellis 0.5.0-beta.13 → 0.5.0-beta.15
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/README.md +5 -5
- package/dist/cli/index.js +1 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +24 -20
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +15 -12
- package/dist/commands/update.js.map +1 -1
- package/dist/configurators/claude.js +1 -1
- package/dist/configurators/claude.js.map +1 -1
- package/dist/configurators/codebuddy.js +1 -1
- package/dist/configurators/codebuddy.js.map +1 -1
- package/dist/configurators/codex.d.ts.map +1 -1
- package/dist/configurators/codex.js +3 -6
- package/dist/configurators/codex.js.map +1 -1
- package/dist/configurators/copilot.d.ts.map +1 -1
- package/dist/configurators/copilot.js +4 -11
- package/dist/configurators/copilot.js.map +1 -1
- package/dist/configurators/cursor.js +1 -1
- package/dist/configurators/cursor.js.map +1 -1
- package/dist/configurators/droid.js +1 -1
- package/dist/configurators/droid.js.map +1 -1
- package/dist/configurators/gemini.d.ts.map +1 -1
- package/dist/configurators/gemini.js +1 -3
- package/dist/configurators/gemini.js.map +1 -1
- package/dist/configurators/index.d.ts.map +1 -1
- package/dist/configurators/index.js +24 -38
- package/dist/configurators/index.js.map +1 -1
- package/dist/configurators/kiro.js +1 -1
- package/dist/configurators/kiro.js.map +1 -1
- package/dist/configurators/pi.d.ts +3 -0
- package/dist/configurators/pi.d.ts.map +1 -0
- package/dist/configurators/pi.js +39 -0
- package/dist/configurators/pi.js.map +1 -0
- package/dist/configurators/qoder.d.ts.map +1 -1
- package/dist/configurators/qoder.js +1 -3
- package/dist/configurators/qoder.js.map +1 -1
- package/dist/configurators/shared.d.ts +2 -4
- package/dist/configurators/shared.d.ts.map +1 -1
- package/dist/configurators/shared.js +6 -9
- package/dist/configurators/shared.js.map +1 -1
- package/dist/migrations/manifests/0.5.0-beta.14.json +9 -0
- package/dist/migrations/manifests/0.5.0-beta.15.json +126 -0
- package/dist/templates/claude/agents/trellis-research.md +1 -1
- package/dist/templates/claude/settings.json +0 -4
- package/dist/templates/codebuddy/agents/trellis-research.md +1 -1
- package/dist/templates/codex/agents/trellis-check.toml +0 -16
- package/dist/templates/codex/agents/trellis-implement.toml +0 -16
- package/dist/templates/codex/agents/trellis-research.toml +3 -2
- package/dist/templates/codex/hooks/session-start.py +82 -22
- package/dist/templates/codex/skills/start/SKILL.md +1 -1
- package/dist/templates/copilot/hooks/session-start.py +84 -26
- package/dist/templates/copilot/prompts/start.prompt.md +1 -1
- package/dist/templates/cursor/agents/trellis-check.md +1 -1
- package/dist/templates/cursor/agents/trellis-implement.md +1 -1
- package/dist/templates/cursor/agents/trellis-research.md +2 -2
- package/dist/templates/cursor/hooks.json +7 -1
- package/dist/templates/droid/droids/trellis-research.md +1 -1
- package/dist/templates/extract.d.ts +6 -0
- package/dist/templates/extract.d.ts.map +1 -1
- package/dist/templates/extract.js +14 -0
- package/dist/templates/extract.js.map +1 -1
- package/dist/templates/gemini/agents/trellis-research.md +1 -1
- package/dist/templates/kiro/agents/trellis-research.json +1 -1
- package/dist/templates/markdown/agents.md +11 -12
- package/dist/templates/markdown/gitignore.txt +3 -0
- package/dist/templates/opencode/agents/trellis-check.md +1 -1
- package/dist/templates/opencode/agents/trellis-implement.md +1 -1
- package/dist/templates/opencode/agents/trellis-research.md +2 -2
- package/dist/templates/opencode/lib/trellis-context.js +100 -13
- package/dist/templates/opencode/plugins/inject-subagent-context.js +54 -4
- package/dist/templates/opencode/plugins/inject-workflow-state.js +50 -23
- package/dist/templates/opencode/plugins/session-start.js +46 -21
- package/dist/templates/pi/agents/trellis-check.md +28 -0
- package/dist/templates/pi/agents/trellis-implement.md +33 -0
- package/dist/templates/pi/agents/trellis-research.md +25 -0
- package/dist/templates/pi/extensions/trellis/index.ts.txt +549 -0
- package/dist/templates/pi/index.d.ts +5 -0
- package/dist/templates/pi/index.d.ts.map +1 -0
- package/dist/templates/pi/index.js +12 -0
- package/dist/templates/pi/index.js.map +1 -0
- package/dist/templates/pi/settings.json +12 -0
- package/dist/templates/qoder/agents/trellis-research.md +1 -1
- package/dist/templates/shared-hooks/index.d.ts +31 -0
- package/dist/templates/shared-hooks/index.d.ts.map +1 -1
- package/dist/templates/shared-hooks/index.js +59 -0
- package/dist/templates/shared-hooks/index.js.map +1 -1
- package/dist/templates/shared-hooks/inject-shell-session-context.py +180 -0
- package/dist/templates/shared-hooks/inject-subagent-context.py +128 -26
- package/dist/templates/shared-hooks/inject-workflow-state.py +101 -61
- package/dist/templates/shared-hooks/session-start.py +151 -28
- package/dist/templates/trellis/gitignore.txt +3 -0
- 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/__init__.py +8 -0
- package/dist/templates/trellis/scripts/common/active_task.py +593 -0
- package/dist/templates/trellis/scripts/common/cli_adapter.py +43 -8
- package/dist/templates/trellis/scripts/common/paths.py +61 -58
- package/dist/templates/trellis/scripts/common/session_context.py +12 -0
- package/dist/templates/trellis/scripts/common/task_store.py +4 -6
- package/dist/templates/trellis/scripts/task.py +56 -14
- package/dist/templates/trellis/workflow.md +31 -26
- package/dist/types/ai-tools.d.ts +3 -3
- package/dist/types/ai-tools.d.ts.map +1 -1
- package/dist/types/ai-tools.js +16 -0
- package/dist/types/ai-tools.js.map +1 -1
- package/dist/utils/template-fetcher.d.ts +22 -6
- package/dist/utils/template-fetcher.d.ts.map +1 -1
- package/dist/utils/template-fetcher.js +405 -27
- package/dist/utils/template-fetcher.js.map +1 -1
- package/dist/utils/template-hash.d.ts.map +1 -1
- package/dist/utils/template-hash.js +3 -2
- package/dist/utils/template-hash.js.map +1 -1
- package/package.json +1 -1
- package/dist/templates/shared-hooks/statusline.py +0 -218
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Session-scoped active task resolution.
|
|
3
|
+
|
|
4
|
+
The user-facing concept is a single "active task". Trellis stores that pointer
|
|
5
|
+
per AI session/window under `.trellis/.runtime/sessions/`; without a stable
|
|
6
|
+
session key there is no active task.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
DIR_WORKFLOW = ".trellis"
|
|
23
|
+
DIR_TASKS = "tasks"
|
|
24
|
+
DIR_RUNTIME = ".runtime"
|
|
25
|
+
DIR_SESSIONS = "sessions"
|
|
26
|
+
DIR_CURSOR_SHELL = "cursor-shell"
|
|
27
|
+
CURSOR_SHELL_TICKET_TTL_SECONDS = 30
|
|
28
|
+
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
|
|
29
|
+
|
|
30
|
+
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
|
|
31
|
+
_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID")
|
|
32
|
+
_TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript")
|
|
33
|
+
_NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput")
|
|
34
|
+
_KNOWN_PLATFORMS = {
|
|
35
|
+
"claude",
|
|
36
|
+
"codex",
|
|
37
|
+
"cursor",
|
|
38
|
+
"opencode",
|
|
39
|
+
"gemini",
|
|
40
|
+
"droid",
|
|
41
|
+
"qoder",
|
|
42
|
+
"codebuddy",
|
|
43
|
+
"kiro",
|
|
44
|
+
"copilot",
|
|
45
|
+
"pi",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
49
|
+
("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")),
|
|
50
|
+
("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")),
|
|
51
|
+
("cursor", ("CURSOR_SESSION_ID",)),
|
|
52
|
+
("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")),
|
|
53
|
+
("gemini", ("GEMINI_SESSION_ID",)),
|
|
54
|
+
("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")),
|
|
55
|
+
("qoder", ("QODER_SESSION_ID",)),
|
|
56
|
+
("codebuddy", ("CODEBUDDY_SESSION_ID",)),
|
|
57
|
+
("kiro", ("KIRO_SESSION_ID",)),
|
|
58
|
+
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
|
|
59
|
+
("pi", ("PI_SESSION_ID", "PI_SESSIONID")),
|
|
60
|
+
)
|
|
61
|
+
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
62
|
+
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
|
|
63
|
+
)
|
|
64
|
+
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
65
|
+
("claude", ("CLAUDE_TRANSCRIPT_PATH",)),
|
|
66
|
+
("codex", ("CODEX_TRANSCRIPT_PATH",)),
|
|
67
|
+
("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
|
|
68
|
+
("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
|
|
69
|
+
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
|
|
70
|
+
("qoder", ("QODER_TRANSCRIPT_PATH",)),
|
|
71
|
+
("codebuddy", ("CODEBUDDY_TRANSCRIPT_PATH",)),
|
|
72
|
+
)
|
|
73
|
+
_ENV_PLATFORM_ALIASES = {
|
|
74
|
+
"claude-code": "claude",
|
|
75
|
+
"factory": "droid",
|
|
76
|
+
"factory-ai": "droid",
|
|
77
|
+
"github-copilot": "copilot",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class ActiveTask:
|
|
83
|
+
"""Resolved active task state."""
|
|
84
|
+
|
|
85
|
+
task_path: str | None
|
|
86
|
+
source_type: str
|
|
87
|
+
context_key: str | None = None
|
|
88
|
+
stale: bool = False
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def source(self) -> str:
|
|
92
|
+
"""Human-readable source label."""
|
|
93
|
+
if self.source_type == "session" and self.context_key:
|
|
94
|
+
return f"session:{self.context_key}"
|
|
95
|
+
return self.source_type
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def normalize_task_ref(task_ref: str) -> str:
|
|
99
|
+
"""Normalize a task ref for stable storage and comparison."""
|
|
100
|
+
normalized = task_ref.strip()
|
|
101
|
+
if not normalized:
|
|
102
|
+
return ""
|
|
103
|
+
|
|
104
|
+
path_obj = Path(normalized)
|
|
105
|
+
if path_obj.is_absolute():
|
|
106
|
+
return str(path_obj)
|
|
107
|
+
|
|
108
|
+
normalized = normalized.replace("\\", "/")
|
|
109
|
+
while normalized.startswith("./"):
|
|
110
|
+
normalized = normalized[2:]
|
|
111
|
+
|
|
112
|
+
if normalized.startswith(f"{DIR_TASKS}/"):
|
|
113
|
+
return f"{DIR_WORKFLOW}/{normalized}"
|
|
114
|
+
|
|
115
|
+
return normalized
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None:
|
|
119
|
+
"""Resolve a task ref to an absolute task directory."""
|
|
120
|
+
normalized = normalize_task_ref(task_ref)
|
|
121
|
+
if not normalized:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
path_obj = Path(normalized)
|
|
125
|
+
if path_obj.is_absolute():
|
|
126
|
+
return path_obj
|
|
127
|
+
|
|
128
|
+
if normalized.startswith(f"{DIR_WORKFLOW}/"):
|
|
129
|
+
return repo_root / path_obj
|
|
130
|
+
|
|
131
|
+
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _runtime_sessions_dir(repo_root: Path) -> Path:
|
|
135
|
+
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _sanitize_key(raw: str) -> str:
|
|
139
|
+
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw.strip())
|
|
140
|
+
safe = safe.strip("._-")
|
|
141
|
+
return safe[:160] if safe else ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _hash_value(raw: str) -> str:
|
|
145
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _as_dict(value: Any) -> dict[str, Any] | None:
|
|
149
|
+
return value if isinstance(value, dict) else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _string_value(value: Any) -> str | None:
|
|
153
|
+
if isinstance(value, str):
|
|
154
|
+
stripped = value.strip()
|
|
155
|
+
return stripped or None
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _lookup_string(data: dict[str, Any], keys: tuple[str, ...]) -> str | None:
|
|
160
|
+
for key in keys:
|
|
161
|
+
value = _string_value(data.get(key))
|
|
162
|
+
if value:
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
for nested_key in _NESTED_KEYS:
|
|
166
|
+
nested = _as_dict(data.get(nested_key))
|
|
167
|
+
if not nested:
|
|
168
|
+
continue
|
|
169
|
+
value = _lookup_string(nested, keys)
|
|
170
|
+
if value:
|
|
171
|
+
return value
|
|
172
|
+
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None) -> str:
|
|
177
|
+
if platform:
|
|
178
|
+
return _sanitize_key(platform) or "session"
|
|
179
|
+
if platform_input:
|
|
180
|
+
for key in ("_trellis_platform", "trellis_platform", "platform", "source"):
|
|
181
|
+
value = _string_value(platform_input.get(key))
|
|
182
|
+
if value:
|
|
183
|
+
return _sanitize_key(value) or "session"
|
|
184
|
+
if _string_value(platform_input.get("cursor_version")):
|
|
185
|
+
return "cursor"
|
|
186
|
+
return "session"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _context_key(platform_name: str, kind: str, value: str) -> str:
|
|
190
|
+
if kind == "transcript":
|
|
191
|
+
return f"{platform_name}_transcript_{_hash_value(value)}"
|
|
192
|
+
safe_value = _sanitize_key(value)
|
|
193
|
+
if safe_value:
|
|
194
|
+
return f"{platform_name}_{safe_value}"
|
|
195
|
+
return f"{platform_name}_{_hash_value(value)}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _iter_env_keys(
|
|
199
|
+
env_keys: tuple[tuple[str, tuple[str, ...]], ...],
|
|
200
|
+
platform_name: str | None,
|
|
201
|
+
) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
|
202
|
+
if not platform_name:
|
|
203
|
+
return env_keys
|
|
204
|
+
matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
|
|
205
|
+
return matched
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _env_platform_name(platform_name: str | None) -> str | None:
|
|
209
|
+
if not platform_name or platform_name == "session":
|
|
210
|
+
return None
|
|
211
|
+
return _ENV_PLATFORM_ALIASES.get(platform_name, platform_name)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _lookup_env_context_key(platform_name: str | None) -> str | None:
|
|
215
|
+
"""Resolve a context key from platform-provided environment variables.
|
|
216
|
+
|
|
217
|
+
Hooks pass `TRELLIS_CONTEXT_ID` to subprocesses they launch, but an AI-run
|
|
218
|
+
shell command can only see session identity if the host platform exports it
|
|
219
|
+
in the command environment. These names are best-effort adapters; if none
|
|
220
|
+
are present, there is no session-scoped active task.
|
|
221
|
+
"""
|
|
222
|
+
env_platform_name = _env_platform_name(platform_name)
|
|
223
|
+
|
|
224
|
+
for name, keys in _iter_env_keys(_ENV_SESSION_KEYS, env_platform_name):
|
|
225
|
+
for key in keys:
|
|
226
|
+
value = _string_value(os.environ.get(key))
|
|
227
|
+
if value:
|
|
228
|
+
return _context_key(name, "session", value)
|
|
229
|
+
|
|
230
|
+
for name, keys in _iter_env_keys(_ENV_CONVERSATION_KEYS, env_platform_name):
|
|
231
|
+
for key in keys:
|
|
232
|
+
value = _string_value(os.environ.get(key))
|
|
233
|
+
if value:
|
|
234
|
+
return _context_key(name, "conversation", value)
|
|
235
|
+
|
|
236
|
+
for name, keys in _iter_env_keys(_ENV_TRANSCRIPT_KEYS, env_platform_name):
|
|
237
|
+
for key in keys:
|
|
238
|
+
value = _string_value(os.environ.get(key))
|
|
239
|
+
if value:
|
|
240
|
+
return _context_key(name, "transcript", value)
|
|
241
|
+
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _find_repo_root_from_cwd() -> Path | None:
|
|
246
|
+
current = Path.cwd().resolve()
|
|
247
|
+
while True:
|
|
248
|
+
if (current / DIR_WORKFLOW).is_dir():
|
|
249
|
+
return current
|
|
250
|
+
if current == current.parent:
|
|
251
|
+
return None
|
|
252
|
+
current = current.parent
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _cursor_shell_ticket_dir(repo_root: Path) -> Path:
|
|
256
|
+
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _remove_file(path: Path) -> bool:
|
|
260
|
+
try:
|
|
261
|
+
path.unlink()
|
|
262
|
+
return True
|
|
263
|
+
except OSError:
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _task_refs_match(left: str | None, right: str | None, repo_root: Path) -> bool:
|
|
268
|
+
if not left or not right:
|
|
269
|
+
return False
|
|
270
|
+
left_path = resolve_task_ref(left, repo_root)
|
|
271
|
+
right_path = resolve_task_ref(right, repo_root)
|
|
272
|
+
if left_path is not None and right_path is not None:
|
|
273
|
+
return left_path == right_path
|
|
274
|
+
return normalize_task_ref(left) == normalize_task_ref(right)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> bool:
|
|
278
|
+
if Path(sys.argv[0]).name != "task.py":
|
|
279
|
+
return False
|
|
280
|
+
args = tuple(sys.argv[1:])
|
|
281
|
+
if not args:
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
command_name = args[0]
|
|
285
|
+
if command_name not in TASK_SESSION_COMMANDS:
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
subcommands = ticket.get("subcommands")
|
|
289
|
+
if not isinstance(subcommands, list):
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
for subcommand in subcommands:
|
|
293
|
+
if not isinstance(subcommand, dict):
|
|
294
|
+
continue
|
|
295
|
+
if _string_value(subcommand.get("name")) != command_name:
|
|
296
|
+
continue
|
|
297
|
+
if command_name != "start":
|
|
298
|
+
return True
|
|
299
|
+
task_ref = args[1] if len(args) > 1 else None
|
|
300
|
+
if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root):
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> bool:
|
|
307
|
+
expires_at = ticket.get("expires_at_epoch")
|
|
308
|
+
if isinstance(expires_at, (int, float)) and expires_at < now:
|
|
309
|
+
_remove_file(ticket_path)
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
created_at = ticket.get("created_at_epoch")
|
|
313
|
+
if isinstance(created_at, (int, float)):
|
|
314
|
+
if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS:
|
|
315
|
+
return True
|
|
316
|
+
_remove_file(ticket_path)
|
|
317
|
+
return False
|
|
318
|
+
return True
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
|
|
322
|
+
cwd = _string_value(ticket.get("cwd"))
|
|
323
|
+
if not cwd:
|
|
324
|
+
return True
|
|
325
|
+
try:
|
|
326
|
+
Path(cwd).resolve().relative_to(repo_root)
|
|
327
|
+
except ValueError:
|
|
328
|
+
return False
|
|
329
|
+
return True
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _matching_cursor_ticket_context_key(
|
|
333
|
+
ticket_path: Path,
|
|
334
|
+
repo_root: Path,
|
|
335
|
+
now: float,
|
|
336
|
+
) -> str | None:
|
|
337
|
+
ticket = _read_json(ticket_path)
|
|
338
|
+
if ticket is None or ticket.get("platform") != "cursor":
|
|
339
|
+
return None
|
|
340
|
+
if not _ticket_is_fresh(ticket, ticket_path, now):
|
|
341
|
+
return None
|
|
342
|
+
if not _ticket_cwd_matches_repo(ticket, repo_root):
|
|
343
|
+
return None
|
|
344
|
+
if not _pending_ticket_matches_args(ticket, repo_root):
|
|
345
|
+
return None
|
|
346
|
+
return _string_value(ticket.get("context_key"))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _lookup_cursor_shell_ticket_context_key() -> str | None:
|
|
350
|
+
"""Resolve Cursor conversation identity from a short-lived shell ticket.
|
|
351
|
+
|
|
352
|
+
Cursor exposes `conversation_id` to `beforeShellExecution`, but does not
|
|
353
|
+
export it into the shell command environment. The Cursor hook writes a
|
|
354
|
+
short-lived ticket just before `task.py` runs. We accept a ticket only when
|
|
355
|
+
the current `task.py` subcommand matches and exactly one fresh context key
|
|
356
|
+
matches, which avoids cross-window pointer contamination.
|
|
357
|
+
"""
|
|
358
|
+
repo_root = _find_repo_root_from_cwd()
|
|
359
|
+
if repo_root is None:
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
ticket_dir = _cursor_shell_ticket_dir(repo_root)
|
|
363
|
+
if not ticket_dir.is_dir():
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
now = time.time()
|
|
367
|
+
candidates: set[str] = set()
|
|
368
|
+
for ticket_path in ticket_dir.glob("*.json"):
|
|
369
|
+
context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now)
|
|
370
|
+
if context_key:
|
|
371
|
+
candidates.add(context_key)
|
|
372
|
+
|
|
373
|
+
if len(candidates) == 1:
|
|
374
|
+
return next(iter(candidates))
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def resolve_context_key(
|
|
379
|
+
platform_input: dict[str, Any] | None = None,
|
|
380
|
+
platform: str | None = None,
|
|
381
|
+
) -> str | None:
|
|
382
|
+
"""Resolve a stable session/window context key, if one is available.
|
|
383
|
+
|
|
384
|
+
`TRELLIS_CONTEXT_ID` is an explicit context-key override used by CLI
|
|
385
|
+
scripts and subprocesses. It does not store the task itself.
|
|
386
|
+
"""
|
|
387
|
+
override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID"))
|
|
388
|
+
if override:
|
|
389
|
+
return _sanitize_key(override) or _hash_value(override)
|
|
390
|
+
|
|
391
|
+
data = _as_dict(platform_input)
|
|
392
|
+
platform_name = _detect_platform(data, platform) if data or platform else None
|
|
393
|
+
|
|
394
|
+
if data:
|
|
395
|
+
session_id = _lookup_string(data, _SESSION_KEYS)
|
|
396
|
+
if session_id:
|
|
397
|
+
return _context_key(platform_name or "session", "session", session_id)
|
|
398
|
+
|
|
399
|
+
conversation_id = _lookup_string(data, _CONVERSATION_KEYS)
|
|
400
|
+
if conversation_id:
|
|
401
|
+
return _context_key(platform_name or "session", "conversation", conversation_id)
|
|
402
|
+
|
|
403
|
+
transcript_path = _lookup_string(data, _TRANSCRIPT_KEYS)
|
|
404
|
+
if transcript_path:
|
|
405
|
+
return _context_key(platform_name or "session", "transcript", transcript_path)
|
|
406
|
+
|
|
407
|
+
env_context_key = _lookup_env_context_key(platform_name)
|
|
408
|
+
if env_context_key:
|
|
409
|
+
return env_context_key
|
|
410
|
+
|
|
411
|
+
if platform_name in (None, "session", "cursor"):
|
|
412
|
+
return _lookup_cursor_shell_ticket_context_key()
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _read_json(path: Path) -> dict[str, Any] | None:
|
|
417
|
+
try:
|
|
418
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
419
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
420
|
+
return None
|
|
421
|
+
return data if isinstance(data, dict) else None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _write_json(path: Path, data: dict[str, Any]) -> bool:
|
|
425
|
+
try:
|
|
426
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
path.write_text(
|
|
428
|
+
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
|
429
|
+
encoding="utf-8",
|
|
430
|
+
)
|
|
431
|
+
return True
|
|
432
|
+
except OSError:
|
|
433
|
+
return False
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None:
|
|
437
|
+
normalized = normalize_task_ref(task_path)
|
|
438
|
+
if not normalized:
|
|
439
|
+
return None
|
|
440
|
+
full_path = resolve_task_ref(normalized, repo_root)
|
|
441
|
+
if full_path is None or not full_path.is_dir():
|
|
442
|
+
return None
|
|
443
|
+
try:
|
|
444
|
+
return full_path.relative_to(repo_root).as_posix()
|
|
445
|
+
except ValueError:
|
|
446
|
+
return str(full_path)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _active_from_ref(
|
|
450
|
+
task_ref: str | None,
|
|
451
|
+
repo_root: Path,
|
|
452
|
+
source_type: str,
|
|
453
|
+
context_key: str | None = None,
|
|
454
|
+
) -> ActiveTask | None:
|
|
455
|
+
if not task_ref:
|
|
456
|
+
return None
|
|
457
|
+
resolved = resolve_task_ref(task_ref, repo_root)
|
|
458
|
+
stale = resolved is None or not resolved.is_dir()
|
|
459
|
+
return ActiveTask(task_ref, source_type, context_key, stale)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _context_path(repo_root: Path, context_key: str) -> Path:
|
|
463
|
+
return _runtime_sessions_dir(repo_root) / f"{context_key}.json"
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def resolve_active_task(
|
|
467
|
+
repo_root: Path,
|
|
468
|
+
platform_input: dict[str, Any] | None = None,
|
|
469
|
+
platform: str | None = None,
|
|
470
|
+
) -> ActiveTask:
|
|
471
|
+
"""Resolve the active task from session runtime state only.
|
|
472
|
+
|
|
473
|
+
A stale session task is returned as stale. Missing context identity or a
|
|
474
|
+
missing/empty session context means there is no active task.
|
|
475
|
+
"""
|
|
476
|
+
context_key = resolve_context_key(platform_input, platform)
|
|
477
|
+
if not context_key:
|
|
478
|
+
return ActiveTask(None, "none")
|
|
479
|
+
|
|
480
|
+
context = _read_json(_context_path(repo_root, context_key)) or {}
|
|
481
|
+
task_ref = _string_value(context.get("current_task"))
|
|
482
|
+
active = _active_from_ref(task_ref, repo_root, "session", context_key)
|
|
483
|
+
if active:
|
|
484
|
+
return active
|
|
485
|
+
|
|
486
|
+
return ActiveTask(None, "none", context_key)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _utc_now() -> str:
|
|
490
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _context_metadata(
|
|
494
|
+
platform_input: dict[str, Any] | None,
|
|
495
|
+
platform: str | None,
|
|
496
|
+
context_key: str | None = None,
|
|
497
|
+
) -> dict[str, Any]:
|
|
498
|
+
data = _as_dict(platform_input) or {}
|
|
499
|
+
platform_name = _detect_platform(data, platform)
|
|
500
|
+
if platform_name == "session" and context_key:
|
|
501
|
+
prefix = context_key.split("_", 1)[0]
|
|
502
|
+
if prefix in _KNOWN_PLATFORMS:
|
|
503
|
+
platform_name = prefix
|
|
504
|
+
metadata: dict[str, Any] = {
|
|
505
|
+
"platform": platform_name,
|
|
506
|
+
"last_seen_at": _utc_now(),
|
|
507
|
+
}
|
|
508
|
+
for key in (*_SESSION_KEYS, *_CONVERSATION_KEYS, *_TRANSCRIPT_KEYS):
|
|
509
|
+
value = _lookup_string(data, (key,))
|
|
510
|
+
if value:
|
|
511
|
+
metadata[key] = value
|
|
512
|
+
return metadata
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def set_active_task(
|
|
516
|
+
task_path: str,
|
|
517
|
+
repo_root: Path,
|
|
518
|
+
platform_input: dict[str, Any] | None = None,
|
|
519
|
+
platform: str | None = None,
|
|
520
|
+
) -> ActiveTask | None:
|
|
521
|
+
"""Set the active task in session scope.
|
|
522
|
+
|
|
523
|
+
Returns None when no context key is available; callers should surface a
|
|
524
|
+
user-facing error that explains how to provide session identity.
|
|
525
|
+
"""
|
|
526
|
+
canonical = _canonical_task_ref(task_path, repo_root)
|
|
527
|
+
if canonical is None:
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
context_key = resolve_context_key(platform_input, platform)
|
|
531
|
+
if not context_key:
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
context_path = _context_path(repo_root, context_key)
|
|
535
|
+
context = _read_json(context_path) or {}
|
|
536
|
+
context.update(_context_metadata(platform_input, platform, context_key))
|
|
537
|
+
context["current_task"] = canonical
|
|
538
|
+
context.setdefault("current_run", None)
|
|
539
|
+
if not _write_json(context_path, context):
|
|
540
|
+
return None
|
|
541
|
+
return ActiveTask(canonical, "session", context_key)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def clear_active_task(
|
|
545
|
+
repo_root: Path,
|
|
546
|
+
platform_input: dict[str, Any] | None = None,
|
|
547
|
+
platform: str | None = None,
|
|
548
|
+
) -> ActiveTask:
|
|
549
|
+
"""Clear the active task by deleting the current session context file."""
|
|
550
|
+
context_key = resolve_context_key(platform_input, platform)
|
|
551
|
+
if not context_key:
|
|
552
|
+
return ActiveTask(None, "none")
|
|
553
|
+
|
|
554
|
+
previous = resolve_active_task(repo_root, platform_input, platform)
|
|
555
|
+
context_path = _context_path(repo_root, context_key)
|
|
556
|
+
if context_path.is_file():
|
|
557
|
+
_remove_file(context_path)
|
|
558
|
+
return previous
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def clear_task_from_sessions(task_path: str, repo_root: Path) -> int:
|
|
562
|
+
"""Delete all session runtime files that point at a task."""
|
|
563
|
+
target = _canonical_task_ref(task_path, repo_root) or normalize_task_ref(task_path)
|
|
564
|
+
if not target:
|
|
565
|
+
return 0
|
|
566
|
+
|
|
567
|
+
cleared = 0
|
|
568
|
+
sessions_dir = _runtime_sessions_dir(repo_root)
|
|
569
|
+
if not sessions_dir.is_dir():
|
|
570
|
+
return cleared
|
|
571
|
+
|
|
572
|
+
for session_path in sessions_dir.glob("*.json"):
|
|
573
|
+
context = _read_json(session_path) or {}
|
|
574
|
+
current = _string_value(context.get("current_task"))
|
|
575
|
+
if not current:
|
|
576
|
+
continue
|
|
577
|
+
current_ref = _canonical_task_ref(current, repo_root) or normalize_task_ref(current)
|
|
578
|
+
if current_ref != target:
|
|
579
|
+
continue
|
|
580
|
+
if session_path.is_file() and _remove_file(session_path):
|
|
581
|
+
cleared += 1
|
|
582
|
+
|
|
583
|
+
return cleared
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def get_current_task_source(
|
|
587
|
+
repo_root: Path,
|
|
588
|
+
platform_input: dict[str, Any] | None = None,
|
|
589
|
+
platform: str | None = None,
|
|
590
|
+
) -> tuple[str, str | None, str | None]:
|
|
591
|
+
"""Return (`source_type`, `context_key`, `task_path`) for compatibility."""
|
|
592
|
+
active = resolve_active_task(repo_root, platform_input, platform)
|
|
593
|
+
return active.source_type, active.context_key, active.task_path
|