@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.
Files changed (119) 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 +15 -12
  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/pi.d.ts +3 -0
  34. package/dist/configurators/pi.d.ts.map +1 -0
  35. package/dist/configurators/pi.js +39 -0
  36. package/dist/configurators/pi.js.map +1 -0
  37. package/dist/configurators/qoder.d.ts.map +1 -1
  38. package/dist/configurators/qoder.js +1 -3
  39. package/dist/configurators/qoder.js.map +1 -1
  40. package/dist/configurators/shared.d.ts +2 -4
  41. package/dist/configurators/shared.d.ts.map +1 -1
  42. package/dist/configurators/shared.js +6 -9
  43. package/dist/configurators/shared.js.map +1 -1
  44. package/dist/migrations/manifests/0.5.0-beta.14.json +9 -0
  45. package/dist/migrations/manifests/0.5.0-beta.15.json +126 -0
  46. package/dist/templates/claude/agents/trellis-research.md +1 -1
  47. package/dist/templates/claude/settings.json +0 -4
  48. package/dist/templates/codebuddy/agents/trellis-research.md +1 -1
  49. package/dist/templates/codex/agents/trellis-check.toml +0 -16
  50. package/dist/templates/codex/agents/trellis-implement.toml +0 -16
  51. package/dist/templates/codex/agents/trellis-research.toml +3 -2
  52. package/dist/templates/codex/hooks/session-start.py +82 -22
  53. package/dist/templates/codex/skills/start/SKILL.md +1 -1
  54. package/dist/templates/copilot/hooks/session-start.py +84 -26
  55. package/dist/templates/copilot/prompts/start.prompt.md +1 -1
  56. package/dist/templates/cursor/agents/trellis-check.md +1 -1
  57. package/dist/templates/cursor/agents/trellis-implement.md +1 -1
  58. package/dist/templates/cursor/agents/trellis-research.md +2 -2
  59. package/dist/templates/cursor/hooks.json +7 -1
  60. package/dist/templates/droid/droids/trellis-research.md +1 -1
  61. package/dist/templates/extract.d.ts +6 -0
  62. package/dist/templates/extract.d.ts.map +1 -1
  63. package/dist/templates/extract.js +14 -0
  64. package/dist/templates/extract.js.map +1 -1
  65. package/dist/templates/gemini/agents/trellis-research.md +1 -1
  66. package/dist/templates/kiro/agents/trellis-research.json +1 -1
  67. package/dist/templates/markdown/agents.md +11 -12
  68. package/dist/templates/markdown/gitignore.txt +3 -0
  69. package/dist/templates/opencode/agents/trellis-check.md +1 -1
  70. package/dist/templates/opencode/agents/trellis-implement.md +1 -1
  71. package/dist/templates/opencode/agents/trellis-research.md +2 -2
  72. package/dist/templates/opencode/lib/trellis-context.js +100 -13
  73. package/dist/templates/opencode/plugins/inject-subagent-context.js +54 -4
  74. package/dist/templates/opencode/plugins/inject-workflow-state.js +50 -23
  75. package/dist/templates/opencode/plugins/session-start.js +46 -21
  76. package/dist/templates/pi/agents/trellis-check.md +28 -0
  77. package/dist/templates/pi/agents/trellis-implement.md +33 -0
  78. package/dist/templates/pi/agents/trellis-research.md +25 -0
  79. package/dist/templates/pi/extensions/trellis/index.ts.txt +549 -0
  80. package/dist/templates/pi/index.d.ts +5 -0
  81. package/dist/templates/pi/index.d.ts.map +1 -0
  82. package/dist/templates/pi/index.js +12 -0
  83. package/dist/templates/pi/index.js.map +1 -0
  84. package/dist/templates/pi/settings.json +12 -0
  85. package/dist/templates/qoder/agents/trellis-research.md +1 -1
  86. package/dist/templates/shared-hooks/index.d.ts +31 -0
  87. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  88. package/dist/templates/shared-hooks/index.js +59 -0
  89. package/dist/templates/shared-hooks/index.js.map +1 -1
  90. package/dist/templates/shared-hooks/inject-shell-session-context.py +180 -0
  91. package/dist/templates/shared-hooks/inject-subagent-context.py +128 -26
  92. package/dist/templates/shared-hooks/inject-workflow-state.py +101 -61
  93. package/dist/templates/shared-hooks/session-start.py +151 -28
  94. package/dist/templates/trellis/gitignore.txt +3 -0
  95. package/dist/templates/trellis/index.d.ts +1 -0
  96. package/dist/templates/trellis/index.d.ts.map +1 -1
  97. package/dist/templates/trellis/index.js +2 -0
  98. package/dist/templates/trellis/index.js.map +1 -1
  99. package/dist/templates/trellis/scripts/common/__init__.py +8 -0
  100. package/dist/templates/trellis/scripts/common/active_task.py +593 -0
  101. package/dist/templates/trellis/scripts/common/cli_adapter.py +43 -8
  102. package/dist/templates/trellis/scripts/common/paths.py +61 -58
  103. package/dist/templates/trellis/scripts/common/session_context.py +12 -0
  104. package/dist/templates/trellis/scripts/common/task_store.py +4 -6
  105. package/dist/templates/trellis/scripts/task.py +56 -14
  106. package/dist/templates/trellis/workflow.md +31 -26
  107. package/dist/types/ai-tools.d.ts +3 -3
  108. package/dist/types/ai-tools.d.ts.map +1 -1
  109. package/dist/types/ai-tools.js +16 -0
  110. package/dist/types/ai-tools.js.map +1 -1
  111. package/dist/utils/template-fetcher.d.ts +22 -6
  112. package/dist/utils/template-fetcher.d.ts.map +1 -1
  113. package/dist/utils/template-fetcher.js +405 -27
  114. package/dist/utils/template-fetcher.js.map +1 -1
  115. package/dist/utils/template-hash.d.ts.map +1 -1
  116. package/dist/utils/template-hash.js +3 -2
  117. package/dist/utils/template-hash.js.map +1 -1
  118. package/package.json +1 -1
  119. 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