cowork-flow 1.2.0 → 1.4.0
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/CHANGELOG.md +45 -0
- package/README.md +4 -3
- package/package.json +1 -1
- package/template/.claude/hooks/inject-workflow-state.py +12 -98
- package/template/.codex/hooks/inject-workflow-state.py +13 -89
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task.py +4 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_context_commands.py +65 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +23 -0
- package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +52 -0
- package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +61 -0
- package/template/.cowork-flow/scripts/adapters/host/inject.py +249 -0
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +315 -38
- package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +285 -0
- package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +34 -11
- package/template/.cowork-flow/scripts/runtime/session_state.py +54 -9
- package/template/.cowork-flow/scripts/services/context_discovery.py +18 -5
- package/template/.cowork-flow/scripts/services/fact_view.py +14 -0
- package/template/.cowork-flow/scripts/services/spec_check.py +34 -12
- package/template/.cowork-flow/scripts/services/task_context.py +0 -1
- package/template/.cowork-flow/spec/contracts/context-injection.md +40 -23
- package/template/.cowork-flow/spec/contracts/fact-layer-access.md +43 -8
- package/template/.cowork-flow/spec/contracts/spec-checks.md +40 -8
- package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +21 -0
- package/template/.cowork-flow/spec/runtime/contract-registry.json +12 -0
- package/template/.opencode/plugins/cowork-flow.js +12 -0
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/inject-context.js +95 -971
- package/template/skills/agent-dispatch/SKILL.md +15 -0
- package/template/skills/runtime-health/scripts/doctor.py +90 -0
- package/template/skills/task-planning/SKILL.md +1 -0
- package/template/skills/task-review/SKILL.md +1 -0
- package/template/.zcode/hooks/inject-context.selfcheck.mjs +0 -795
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Shared workflow-state protocol used by host hook adapters.
|
|
2
|
+
"""Shared workflow-state protocol used by host hook adapters.
|
|
3
|
+
|
|
4
|
+
This module is host-neutral: it renders the workflow facts and consumes a
|
|
5
|
+
HostPolicy object for the per-host deltas (digest wording, preamble,
|
|
6
|
+
edit warnings, unbound-session fallback). Host behaviors live in the
|
|
7
|
+
per-host policy modules beside this file (zcode_policy.py,
|
|
8
|
+
claude_code_policy.py, codex_policy.py); hosts without a module (dsh) run
|
|
9
|
+
on default_policy().
|
|
10
|
+
"""
|
|
3
11
|
|
|
4
12
|
from __future__ import annotations
|
|
5
13
|
|
|
6
14
|
import hashlib
|
|
15
|
+
import importlib
|
|
7
16
|
import json
|
|
8
17
|
import re
|
|
9
18
|
import sys
|
|
10
19
|
from pathlib import Path
|
|
11
|
-
from typing import Any
|
|
20
|
+
from typing import Any, Callable
|
|
12
21
|
|
|
13
22
|
|
|
14
23
|
TAG_RE = re.compile(
|
|
@@ -30,6 +39,18 @@ DEFAULT_CONTRACT_REGISTRY = {
|
|
|
30
39
|
"when checking subagent health",
|
|
31
40
|
],
|
|
32
41
|
},
|
|
42
|
+
{
|
|
43
|
+
"id": "FACT_LAYER_ACCESS_V1",
|
|
44
|
+
"path": ".cowork-flow/spec/contracts/fact-layer-access.md",
|
|
45
|
+
"digest": [
|
|
46
|
+
"Task/scope/spec facts are read-only via MCP tools or CLI; writes only flow through CLI lifecycle gates.",
|
|
47
|
+
"Prefer MCP task_state/task_scope/task_specs for fact queries when available; CLI is the fallback.",
|
|
48
|
+
],
|
|
49
|
+
"readWhen": [
|
|
50
|
+
"when querying task facts or file scope",
|
|
51
|
+
"when registering the fact layer for a new host or external client",
|
|
52
|
+
],
|
|
53
|
+
},
|
|
33
54
|
]
|
|
34
55
|
}
|
|
35
56
|
|
|
@@ -44,16 +65,96 @@ def find_repo_root(start: Path) -> Path | None:
|
|
|
44
65
|
current = current.parent
|
|
45
66
|
|
|
46
67
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
68
|
+
# Digest policy wording is a per-host contract fact (context-injection.md
|
|
69
|
+
# transport table); hosts carry their line in their policy module, unknown
|
|
70
|
+
# hosts fall back to the default line below.
|
|
71
|
+
DEFAULT_DIGEST_POLICY = (
|
|
72
|
+
"policy: repeat this short digest every hook; "
|
|
73
|
+
"read full spec files only before listed actions."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class HostPolicy:
|
|
78
|
+
"""Per-host deltas consumed by the neutral renderer.
|
|
79
|
+
|
|
80
|
+
Fields left at their defaults mean "no such behavior for this host" —
|
|
81
|
+
the renderer checks callables instead of branching on host names.
|
|
82
|
+
Plain class on purpose: this file is also loaded standalone by tests
|
|
83
|
+
under ad-hoc module names, where @dataclass processing crashes on the
|
|
84
|
+
unregistered module lookup.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
__slots__ = (
|
|
88
|
+
"host",
|
|
89
|
+
"digest_policy",
|
|
90
|
+
"digest_warning_silent",
|
|
91
|
+
"session_start_event",
|
|
92
|
+
"session_alias",
|
|
93
|
+
"preamble",
|
|
94
|
+
"rebind_hints",
|
|
95
|
+
"essential_files_warning",
|
|
96
|
+
"fallback_for_unbound",
|
|
97
|
+
"post_tool_use",
|
|
98
|
+
"emit_indent",
|
|
99
|
+
"emit_not_initialized",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
host: str,
|
|
105
|
+
digest_policy: str = DEFAULT_DIGEST_POLICY,
|
|
106
|
+
digest_warning_silent: bool = False,
|
|
107
|
+
session_start_event: str | None = "SessionStart",
|
|
108
|
+
session_alias: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
|
109
|
+
preamble: Callable[[Path], tuple[str, ...]] | None = None,
|
|
110
|
+
rebind_hints: Callable[[Path], str] | None = None,
|
|
111
|
+
essential_files_warning: Callable[[Path], str] | None = None,
|
|
112
|
+
fallback_for_unbound: bool = False,
|
|
113
|
+
post_tool_use: Callable[[Path, dict[str, Any]], tuple[str, int]] | None = None,
|
|
114
|
+
emit_indent: bool = False,
|
|
115
|
+
emit_not_initialized: bool = False,
|
|
116
|
+
) -> None:
|
|
117
|
+
self.host = host
|
|
118
|
+
self.digest_policy = digest_policy
|
|
119
|
+
self.digest_warning_silent = digest_warning_silent
|
|
120
|
+
self.session_start_event = session_start_event
|
|
121
|
+
self.session_alias = session_alias
|
|
122
|
+
self.preamble = preamble
|
|
123
|
+
self.rebind_hints = rebind_hints
|
|
124
|
+
self.essential_files_warning = essential_files_warning
|
|
125
|
+
self.fallback_for_unbound = fallback_for_unbound
|
|
126
|
+
self.post_tool_use = post_tool_use
|
|
127
|
+
self.emit_indent = emit_indent
|
|
128
|
+
self.emit_not_initialized = emit_not_initialized
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def default_policy(host: str = "generic") -> HostPolicy:
|
|
132
|
+
return HostPolicy(host=host)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Host → policy module dispatch. Data-driven: adding a host is one row plus
|
|
136
|
+
# its policy module; the neutral renderer never branches on host names.
|
|
137
|
+
_HOST_POLICY_MODULES = {
|
|
138
|
+
"zcode": "adapters.host.zcode_policy",
|
|
139
|
+
"claude-code": "adapters.host.claude_code_policy",
|
|
140
|
+
"codex": "adapters.host.codex_policy",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def resolve_policy(host: str, policy: HostPolicy | None = None) -> HostPolicy:
|
|
145
|
+
if policy is not None:
|
|
146
|
+
return policy
|
|
147
|
+
module_name = _HOST_POLICY_MODULES.get(host)
|
|
148
|
+
if not module_name:
|
|
149
|
+
return default_policy(host)
|
|
53
150
|
try:
|
|
54
|
-
|
|
151
|
+
module = importlib.import_module(module_name)
|
|
55
152
|
except Exception:
|
|
56
|
-
|
|
153
|
+
# A broken host policy module degrades to the neutral default
|
|
154
|
+
# instead of killing the injection.
|
|
155
|
+
return default_policy(host)
|
|
156
|
+
policy = module.POLICY
|
|
157
|
+
return policy if isinstance(policy, HostPolicy) else default_policy(host)
|
|
57
158
|
|
|
58
159
|
|
|
59
160
|
def build_hook_context(
|
|
@@ -64,7 +165,9 @@ def build_hook_context(
|
|
|
64
165
|
adapter: str,
|
|
65
166
|
preamble: tuple[str, ...],
|
|
66
167
|
session_start: bool | None = None,
|
|
168
|
+
policy: HostPolicy | None = None,
|
|
67
169
|
) -> str:
|
|
170
|
+
policy = resolve_policy(host, policy)
|
|
68
171
|
breadcrumbs = _load_breadcrumbs(root)
|
|
69
172
|
runtime_context, runtime_context_id = _resolve_runtime_context(
|
|
70
173
|
root,
|
|
@@ -95,12 +198,25 @@ def build_hook_context(
|
|
|
95
198
|
),
|
|
96
199
|
]
|
|
97
200
|
else:
|
|
98
|
-
task_path, status, source =
|
|
201
|
+
task_path, status, source = _get_active_task_with_fallback(
|
|
202
|
+
root,
|
|
203
|
+
hook_input,
|
|
204
|
+
policy.fallback_for_unbound,
|
|
205
|
+
)
|
|
99
206
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
207
|
+
if status == "stale" and task_path:
|
|
208
|
+
# Unified missing-task semantics (previously zcode JS-only): a bound
|
|
209
|
+
# task whose directory or task.json vanished renders the no_task
|
|
210
|
+
# family message instead of a generic fallback breadcrumb.
|
|
211
|
+
status = "no_task"
|
|
212
|
+
body = MISSING_TASK_BODY.format(task_path=task_path)
|
|
213
|
+
else:
|
|
214
|
+
body = (
|
|
215
|
+
breadcrumbs.get(status)
|
|
216
|
+
or "Run ./.cowork-flow/run task next --json for the current workflow route."
|
|
217
|
+
)
|
|
218
|
+
if policy.rebind_hints is not None:
|
|
219
|
+
body += policy.rebind_hints(root)
|
|
104
220
|
if extra_lines:
|
|
105
221
|
body = "\n".join([body, *extra_lines])
|
|
106
222
|
if session_start is None:
|
|
@@ -109,7 +225,7 @@ def build_hook_context(
|
|
|
109
225
|
# exists (start), so their absence keeps every injection full.
|
|
110
226
|
session_start = not _session_has_started(root, hook_input)
|
|
111
227
|
if session_start:
|
|
112
|
-
digest_block = _build_contract_digest(root,
|
|
228
|
+
digest_block = _build_contract_digest(root, policy, adapter)
|
|
113
229
|
else:
|
|
114
230
|
contracts, _warning = _load_contract_registry(root)
|
|
115
231
|
digest_block = (
|
|
@@ -123,10 +239,16 @@ def build_hook_context(
|
|
|
123
239
|
if contract_block:
|
|
124
240
|
blocks.append(contract_block)
|
|
125
241
|
blocks.append(
|
|
126
|
-
f"<workflow-state{_workflow_state_attrs(task_path, status, source)}>"
|
|
242
|
+
f"<workflow-state{_workflow_state_attrs(task_path, status, source, session=_resolve_session_identity(root, hook_input))}>"
|
|
127
243
|
f"\n{body}\n</workflow-state>"
|
|
128
244
|
)
|
|
129
|
-
|
|
245
|
+
context = "\n\n".join(blocks)
|
|
246
|
+
if policy.essential_files_warning is not None:
|
|
247
|
+
# Appended after the whole context so a broken install is visible
|
|
248
|
+
# without a second hook pass (host policy decides whether the check
|
|
249
|
+
# exists at all).
|
|
250
|
+
context += policy.essential_files_warning(root)
|
|
251
|
+
return context
|
|
130
252
|
|
|
131
253
|
|
|
132
254
|
def _session_has_started(root: Path, hook_input: dict[str, Any]) -> bool:
|
|
@@ -157,14 +279,38 @@ def _xml_attr(value: Any) -> str:
|
|
|
157
279
|
return xml_attr(value)
|
|
158
280
|
|
|
159
281
|
|
|
282
|
+
def _resolve_session_identity(
|
|
283
|
+
root: Path, hook_input: dict[str, Any]
|
|
284
|
+
) -> str | None:
|
|
285
|
+
"""The caller's resolved context key for the session attribute
|
|
286
|
+
(context-injection.md, stage 1). None when no identity is resolvable —
|
|
287
|
+
the attribute is omitted rather than guessed."""
|
|
288
|
+
_load_common(root)
|
|
289
|
+
try:
|
|
290
|
+
from runtime.session_state import resolve_context_key
|
|
291
|
+
except Exception:
|
|
292
|
+
return None
|
|
293
|
+
try:
|
|
294
|
+
return resolve_context_key(hook_input)
|
|
295
|
+
except Exception:
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
160
299
|
def _workflow_state_attrs(
|
|
161
|
-
task_path: str | None,
|
|
300
|
+
task_path: str | None,
|
|
301
|
+
status: str,
|
|
302
|
+
source: str,
|
|
303
|
+
session: str | None = None,
|
|
162
304
|
) -> str:
|
|
163
305
|
"""Structured fact header (context-injection.md, stage 1): the machine
|
|
164
|
-
picks task/status/source off the attributes; humans read the
|
|
306
|
+
picks task/status/source/session off the attributes; humans read the
|
|
307
|
+
body. session is appended last and only when a session identity was
|
|
308
|
+
resolvable from the hook input or environment."""
|
|
165
309
|
attrs = [f'status="{_xml_attr(status)}"', f'source="{_xml_attr(source)}"']
|
|
166
310
|
if task_path:
|
|
167
311
|
attrs.insert(0, f'task="{_xml_attr(task_path)}"')
|
|
312
|
+
if session:
|
|
313
|
+
attrs.append(f'session="{_xml_attr(session)}"')
|
|
168
314
|
return "".join(f" {attr}" for attr in attrs)
|
|
169
315
|
|
|
170
316
|
|
|
@@ -289,25 +435,33 @@ def _stage_contract_block(
|
|
|
289
435
|
return None
|
|
290
436
|
|
|
291
437
|
|
|
292
|
-
def spec_edit_warning(
|
|
438
|
+
def spec_edit_warning(
|
|
439
|
+
root: Path,
|
|
440
|
+
hook_input: dict[str, Any],
|
|
441
|
+
policy: HostPolicy,
|
|
442
|
+
) -> str:
|
|
293
443
|
"""Editor-phase spec-check single-line warning (PostToolUse short path).
|
|
294
444
|
|
|
295
|
-
|
|
296
|
-
an active in_progress/review
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
445
|
+
Delegated subagents are the primary coders, so spec violations must
|
|
446
|
+
reach them too: only an active in_progress/review task is required —
|
|
447
|
+
session scope is not consulted here (scope warnings stay main-only in
|
|
448
|
+
the zcode policy's edit_scope_warning). Hosts with
|
|
449
|
+
fallback_for_unbound (zcode) follow another main session's binding for
|
|
450
|
+
their never-bound hook sessions only while exactly one main-session
|
|
451
|
+
binding exists (see _get_active_task_with_fallback); strict-identity
|
|
452
|
+
hosts (claude-code/codex) require the calling session's own binding.
|
|
453
|
+
no_task, planning, and completed stay silent. Empty string means
|
|
454
|
+
silent. Never raises — the editor path must not break edits.
|
|
300
455
|
"""
|
|
301
456
|
try:
|
|
302
|
-
task_path, status, _source =
|
|
457
|
+
task_path, status, _source = _get_active_task_with_fallback(
|
|
458
|
+
root, hook_input, policy.fallback_for_unbound
|
|
459
|
+
)
|
|
303
460
|
except Exception:
|
|
304
461
|
return ""
|
|
305
462
|
if not task_path or status not in STAGE_CONTRACT_STATES:
|
|
306
463
|
return ""
|
|
307
|
-
tool_name = hook_input.get("tool_name")
|
|
308
464
|
tool_input = hook_input.get("tool_input")
|
|
309
|
-
if tool_name not in {"Edit", "Write", "MultiEdit"}:
|
|
310
|
-
return ""
|
|
311
465
|
file_path = (
|
|
312
466
|
tool_input.get("file_path") if isinstance(tool_input, dict) else None
|
|
313
467
|
)
|
|
@@ -321,6 +475,21 @@ def spec_edit_warning(root: Path, hook_input: dict[str, Any]) -> str:
|
|
|
321
475
|
return ""
|
|
322
476
|
|
|
323
477
|
|
|
478
|
+
def spec_only_post_tool_use(
|
|
479
|
+
root: Path,
|
|
480
|
+
hook_input: dict[str, Any],
|
|
481
|
+
policy: HostPolicy,
|
|
482
|
+
) -> tuple[str, int]:
|
|
483
|
+
"""claude-code / codex PostToolUse transport: spec-check advisory on
|
|
484
|
+
stderr only (exit 2 surfaces it to the model); the edit itself has
|
|
485
|
+
already happened. No additionalContext payload."""
|
|
486
|
+
warning = spec_edit_warning(root, hook_input, policy)
|
|
487
|
+
if warning:
|
|
488
|
+
print(warning, file=sys.stderr)
|
|
489
|
+
return "", 2
|
|
490
|
+
return "", 0
|
|
491
|
+
|
|
492
|
+
|
|
324
493
|
def _load_breadcrumbs(root: Path) -> dict[str, str]:
|
|
325
494
|
path = (
|
|
326
495
|
root
|
|
@@ -396,20 +565,17 @@ def _load_contract_registry(
|
|
|
396
565
|
|
|
397
566
|
def _build_contract_digest(
|
|
398
567
|
root: Path,
|
|
399
|
-
|
|
568
|
+
policy: HostPolicy,
|
|
400
569
|
adapter: str,
|
|
401
570
|
) -> str:
|
|
402
571
|
contracts, warning = _load_contract_registry(root)
|
|
403
572
|
fingerprint = contract_fingerprint(root, contracts)
|
|
404
573
|
lines = [
|
|
405
|
-
f'<cowork-runtime host="{host}" adapter="{adapter}">',
|
|
574
|
+
f'<cowork-runtime host="{policy.host}" adapter="{adapter}">',
|
|
406
575
|
f'<contract-digest fingerprint="{fingerprint}">',
|
|
407
|
-
|
|
408
|
-
"policy: repeat this short digest every hook; "
|
|
409
|
-
"read full spec files only before listed actions."
|
|
410
|
-
),
|
|
576
|
+
policy.digest_policy,
|
|
411
577
|
]
|
|
412
|
-
if warning:
|
|
578
|
+
if warning and not policy.digest_warning_silent:
|
|
413
579
|
lines.append(f"warning: {warning}")
|
|
414
580
|
for contract in contracts:
|
|
415
581
|
contract_id = contract.get("id")
|
|
@@ -452,6 +618,112 @@ def _get_active_task(
|
|
|
452
618
|
return active.task_path, status.strip(), active.source
|
|
453
619
|
|
|
454
620
|
|
|
621
|
+
MISSING_TASK_BODY = (
|
|
622
|
+
"Session 指向的任务目录不存在({task_path})。\n"
|
|
623
|
+
"当前项目无有效任务。请运行 ./.cowork-flow/run task next --run "
|
|
624
|
+
'--title "<title>" --slug <task-name> --assignee <name> 创建新任务。'
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _get_active_task_with_fallback(
|
|
629
|
+
root: Path,
|
|
630
|
+
hook_input: dict[str, Any],
|
|
631
|
+
fallback_for_unbound: bool,
|
|
632
|
+
) -> tuple[str | None, str, str]:
|
|
633
|
+
"""zcode parity: a hook session that carries no binding follows another
|
|
634
|
+
main session's binding — but only when exactly one main-session binding
|
|
635
|
+
exists. Two sources qualify for the fallback: no session identity at all
|
|
636
|
+
(missing-context), and a session id that resolves but was never bound
|
|
637
|
+
(empty-session) — the zcode hook always carries the conversation's own
|
|
638
|
+
session id, while task activation happens in the Bash CLI under a
|
|
639
|
+
separate explicit identity, so the hook session file may not exist yet.
|
|
640
|
+
Gated by the host policy's fallback_for_unbound flag; display/warning-only
|
|
641
|
+
— the CLI lifecycle commands keep their strict identity semantics. With
|
|
642
|
+
two or more main-session bindings the newest one belongs to some other
|
|
643
|
+
window, so the fallback refuses and the caller renders no_task plus the
|
|
644
|
+
rebind hints instead."""
|
|
645
|
+
task_path, status, source = _get_active_task(root, hook_input)
|
|
646
|
+
if (
|
|
647
|
+
not fallback_for_unbound
|
|
648
|
+
or task_path
|
|
649
|
+
or source not in {"missing-context", "empty-session"}
|
|
650
|
+
):
|
|
651
|
+
return task_path, status, source
|
|
652
|
+
candidates = _main_session_task_candidates(root)
|
|
653
|
+
if len(candidates) != 1:
|
|
654
|
+
return task_path, status, source
|
|
655
|
+
fallback = candidates[0]
|
|
656
|
+
task_json = root / fallback / "task.json"
|
|
657
|
+
try:
|
|
658
|
+
data = json.loads(task_json.read_text(encoding="utf-8"))
|
|
659
|
+
except (OSError, json.JSONDecodeError):
|
|
660
|
+
return fallback, "stale", "session-fallback"
|
|
661
|
+
status = data.get("status")
|
|
662
|
+
if not isinstance(status, str) or not status.strip():
|
|
663
|
+
status = "unknown"
|
|
664
|
+
return fallback, status.strip(), "session-fallback"
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _main_session_task_candidates(root: Path) -> list[str]:
|
|
668
|
+
"""All main-scope session bindings whose task directory is still valid,
|
|
669
|
+
newest first. Subagent scopes never qualify: delegated sessions follow
|
|
670
|
+
their runtime context, not another session's file."""
|
|
671
|
+
try:
|
|
672
|
+
from runtime.session_state import sessions_dir
|
|
673
|
+
except Exception:
|
|
674
|
+
return []
|
|
675
|
+
sessions = sessions_dir(root)
|
|
676
|
+
if not sessions.is_dir():
|
|
677
|
+
return []
|
|
678
|
+
entries: list[tuple[str, str]] = []
|
|
679
|
+
try:
|
|
680
|
+
paths = sorted(sessions.glob("*.json"))
|
|
681
|
+
except OSError:
|
|
682
|
+
return []
|
|
683
|
+
for path in paths:
|
|
684
|
+
try:
|
|
685
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
686
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
687
|
+
continue
|
|
688
|
+
if not isinstance(data, dict):
|
|
689
|
+
continue
|
|
690
|
+
task_path = data.get("active_task_path")
|
|
691
|
+
if not isinstance(task_path, str) or not task_path.strip():
|
|
692
|
+
continue
|
|
693
|
+
if data.get("scope") == "subagent":
|
|
694
|
+
continue
|
|
695
|
+
entries.append(
|
|
696
|
+
(str(data.get("last_seen_at") or ""), task_path.strip())
|
|
697
|
+
)
|
|
698
|
+
entries.sort(reverse=True)
|
|
699
|
+
candidates: list[str] = []
|
|
700
|
+
for _seen_at, task_path in entries:
|
|
701
|
+
task_dir = root / task_path
|
|
702
|
+
if task_dir.is_dir() and (task_dir / "task.json").is_file():
|
|
703
|
+
candidates.append(task_path)
|
|
704
|
+
return candidates
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _session_scope(root: Path, hook_input: dict[str, Any]) -> str:
|
|
708
|
+
try:
|
|
709
|
+
from runtime.session_state import resolve_context_key, sessions_dir
|
|
710
|
+
except Exception:
|
|
711
|
+
return ""
|
|
712
|
+
context_key = resolve_context_key(hook_input)
|
|
713
|
+
if not context_key:
|
|
714
|
+
return ""
|
|
715
|
+
try:
|
|
716
|
+
data = json.loads(
|
|
717
|
+
(sessions_dir(root) / f"{context_key}.json").read_text(
|
|
718
|
+
encoding="utf-8"
|
|
719
|
+
)
|
|
720
|
+
)
|
|
721
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
722
|
+
return ""
|
|
723
|
+
scope = data.get("scope")
|
|
724
|
+
return scope if isinstance(scope, str) else ""
|
|
725
|
+
|
|
726
|
+
|
|
455
727
|
def _resolve_runtime_context(
|
|
456
728
|
root: Path,
|
|
457
729
|
hook_input: dict[str, Any],
|
|
@@ -480,7 +752,12 @@ def _resolve_runtime_context(
|
|
|
480
752
|
runtime_context_id,
|
|
481
753
|
values=hook_input,
|
|
482
754
|
)
|
|
483
|
-
|
|
755
|
+
result = bound or context
|
|
756
|
+
if not result.get("runtime_context_id"):
|
|
757
|
+
# Legacy context files predate the id field; the detected id is the
|
|
758
|
+
# identity that opened this context, so display it.
|
|
759
|
+
result = {**result, "runtime_context_id": runtime_context_id}
|
|
760
|
+
return result, runtime_context_id
|
|
484
761
|
|
|
485
762
|
|
|
486
763
|
def _subagent_runtime_lines(context: dict[str, Any]) -> list[str]:
|