cowork-flow 1.2.0 → 1.3.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +4 -3
  3. package/package.json +1 -1
  4. package/template/.claude/hooks/inject-workflow-state.py +12 -98
  5. package/template/.codex/hooks/inject-workflow-state.py +13 -89
  6. package/template/.cowork-flow/.version +1 -1
  7. package/template/.cowork-flow/scripts/adapters/cli/task.py +4 -1
  8. package/template/.cowork-flow/scripts/adapters/cli/task_context_commands.py +65 -0
  9. package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +23 -0
  10. package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +52 -0
  11. package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +61 -0
  12. package/template/.cowork-flow/scripts/adapters/host/inject.py +249 -0
  13. package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +280 -35
  14. package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +230 -0
  15. package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +2 -5
  16. package/template/.cowork-flow/scripts/runtime/session_state.py +19 -9
  17. package/template/.cowork-flow/scripts/services/context_discovery.py +18 -5
  18. package/template/.cowork-flow/scripts/services/fact_view.py +14 -0
  19. package/template/.cowork-flow/scripts/services/spec_check.py +34 -12
  20. package/template/.cowork-flow/scripts/services/task_context.py +0 -1
  21. package/template/.cowork-flow/spec/contracts/context-injection.md +24 -20
  22. package/template/.cowork-flow/spec/contracts/fact-layer-access.md +43 -8
  23. package/template/.cowork-flow/spec/contracts/spec-checks.md +40 -8
  24. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +21 -0
  25. package/template/.cowork-flow/spec/runtime/contract-registry.json +12 -0
  26. package/template/.opencode/plugins/cowork-flow.js +12 -0
  27. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  28. package/template/.zcode/hooks/inject-context.js +95 -971
  29. package/template/skills/agent-dispatch/SKILL.md +15 -0
  30. package/template/skills/runtime-health/scripts/doctor.py +90 -0
  31. package/template/skills/task-planning/SKILL.md +1 -0
  32. package/template/skills/task-review/SKILL.md +1 -0
  33. package/template/.zcode/hooks/inject-context.selfcheck.mjs +0 -795
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Host-neutral workflow-context injection entry.
4
+
5
+ Every process-hook host funnels through this module so the workflow facts are
6
+ rendered by a single Python source (workflow_state_hook.py + services.fact_view).
7
+ The entry keeps only transport duties: event routing and byte forwarding.
8
+ Per-host behavior differences (session aliases, preamble, digest policy
9
+ wording, edit warnings, envelope pretty-printing) ride the host policy
10
+ modules beside this file, selected by --host, exactly as frozen by
11
+ spec/contracts/context-injection.md.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ # This file lives in <scripts>/adapters/host/; running it as a script puts
24
+ # only its own directory on sys.path. The module root is needed both for the
25
+ # root=None fallback (no project scripts dir exists) and so a stale project
26
+ # copy never shadows the importing module.
27
+ SCRIPTS_DIR = Path(__file__).resolve().parents[2]
28
+ if str(SCRIPTS_DIR) not in sys.path:
29
+ sys.path.insert(0, str(SCRIPTS_DIR))
30
+
31
+ HOST_ADAPTERS = {
32
+ "zcode": "zcode.plugin",
33
+ "claude-code": "claude-code.hooks",
34
+ "codex": "codex.spawn_agent",
35
+ "dsh": "dsh.preset",
36
+ }
37
+
38
+ NOT_INITIALIZED_BODY = (
39
+ "<workflow-state>\n"
40
+ "Status: not_initialized\n"
41
+ "Source: cowork-flow-plugin\n"
42
+ "⚠️ 项目未初始化 cowork-flow 工作流。\n"
43
+ "\n"
44
+ "请通过显式 init/sync 安装 cowork-flow 模板后再继续;"
45
+ "hook 不会在注入阶段创建或复制项目文件。\n"
46
+ "</workflow-state>"
47
+ )
48
+
49
+
50
+ def _configure_stdio() -> None:
51
+ for stream in (sys.stdout, sys.stderr):
52
+ if hasattr(stream, "reconfigure"):
53
+ # newline="" keeps payload bytes identical across platforms —
54
+ # text-mode translation would turn the pretty JSON indentation
55
+ # into CRLF on Windows.
56
+ stream.reconfigure(encoding="utf-8", errors="replace", newline="")
57
+
58
+
59
+ def _read_input() -> dict[str, Any]:
60
+ raw = sys.stdin.buffer.read()
61
+ if not raw.strip():
62
+ return {}
63
+ try:
64
+ data = json.loads(raw.decode("utf-8-sig"))
65
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
66
+ return {}
67
+ if not isinstance(data, dict):
68
+ return {}
69
+ return data
70
+
71
+
72
+ def detect_event_name(hook_input: dict[str, Any]) -> str:
73
+ name = hook_input.get("hook_event_name") or hook_input.get("hookEventName")
74
+ if isinstance(name, str) and name.strip():
75
+ return name.strip()
76
+ if os.environ.get("CURSOR_PLUGIN_ROOT"):
77
+ return "SessionStart"
78
+ return "UserPromptSubmit"
79
+
80
+
81
+ def output_format() -> str:
82
+ if os.environ.get("CURSOR_PLUGIN_ROOT"):
83
+ return "cursor"
84
+ if os.environ.get("CLAUDE_PLUGIN_ROOT") and not os.environ.get("COPILOT_CLI"):
85
+ return "claude"
86
+ return "generic"
87
+
88
+
89
+ def find_workflow_root(start: Path | None) -> Path | None:
90
+ if start is None:
91
+ return None
92
+ current = start.resolve()
93
+ while True:
94
+ if (current / ".cowork-flow").is_dir():
95
+ return current
96
+ if current == current.parent:
97
+ return None
98
+ current = current.parent
99
+
100
+
101
+ def resolve_root(hook_input: dict[str, Any]) -> Path | None:
102
+ candidates = [
103
+ hook_input.get("cwd"),
104
+ os.environ.get("ZCODE_PROJECT_DIR"),
105
+ os.environ.get("CLAUDE_PROJECT_DIR"),
106
+ os.getcwd(),
107
+ ]
108
+ for candidate in candidates:
109
+ if isinstance(candidate, str) and candidate.strip():
110
+ root = find_workflow_root(Path(candidate))
111
+ if root is not None:
112
+ return root
113
+ return None
114
+
115
+
116
+ def _not_initialized_context(
117
+ hook_input: dict[str, Any],
118
+ event_name: str,
119
+ host: str,
120
+ policy: Any,
121
+ ) -> str:
122
+ from adapters.host.workflow_state_hook import (
123
+ _build_contract_digest,
124
+ _load_contract_registry,
125
+ contract_fingerprint,
126
+ )
127
+
128
+ env_dir = (
129
+ os.environ.get("ZCODE_PROJECT_DIR")
130
+ or os.environ.get("CLAUDE_PROJECT_DIR")
131
+ or hook_input.get("cwd")
132
+ or os.getcwd()
133
+ )
134
+ digest_root = Path(env_dir)
135
+ if event_name == "SessionStart":
136
+ prefix = _build_contract_digest(digest_root, policy, HOST_ADAPTERS[host])
137
+ else:
138
+ contracts, _warning = _load_contract_registry(digest_root)
139
+ prefix = (
140
+ f'<contract-fingerprint value="{contract_fingerprint(digest_root, contracts)}"/>'
141
+ )
142
+ return f"{prefix}\n\n{NOT_INITIALIZED_BODY}"
143
+
144
+
145
+ def _emit(
146
+ context: str,
147
+ event_name: str,
148
+ output_format_name: str,
149
+ policy: Any,
150
+ ) -> None:
151
+ if output_format_name == "cursor":
152
+ payload: dict[str, Any] = {"additional_context": context}
153
+ else:
154
+ payload = {
155
+ "hookSpecificOutput": {
156
+ "hookEventName": event_name,
157
+ "additionalContext": context,
158
+ }
159
+ }
160
+ indent = 2 if policy.emit_indent else None
161
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=indent))
162
+
163
+
164
+ def _handle_post_tool_use(
165
+ root: Path | None,
166
+ hook_input: dict[str, Any],
167
+ policy: Any,
168
+ output_format_name: str,
169
+ ) -> int:
170
+ if root is None:
171
+ return 0
172
+ if policy.post_tool_use is None:
173
+ return 0
174
+ context, exit_code = policy.post_tool_use(root, hook_input)
175
+ if context:
176
+ _emit(context, "PostToolUse", output_format_name, policy)
177
+ return exit_code
178
+
179
+
180
+ def main(argv: list[str] | None = None) -> int:
181
+ _configure_stdio()
182
+ if (
183
+ os.environ.get("COWORK_FLOW_HOOKS") == "0"
184
+ or os.environ.get("COWORK_FLOW_DISABLE_HOOKS") == "1"
185
+ ):
186
+ return 0
187
+ parser = argparse.ArgumentParser(description=__doc__)
188
+ parser.add_argument("--host", required=True, choices=sorted(HOST_ADAPTERS))
189
+ args = parser.parse_args(argv)
190
+ host = args.host
191
+
192
+ from adapters.host.workflow_state_hook import (
193
+ build_hook_context,
194
+ resolve_policy,
195
+ )
196
+
197
+ policy = resolve_policy(host)
198
+
199
+ hook_input = _read_input()
200
+ if policy.session_alias is not None:
201
+ hook_input = policy.session_alias(hook_input)
202
+ event_name = detect_event_name(hook_input)
203
+ output_format_name = output_format()
204
+ root = resolve_root(hook_input)
205
+
206
+ if event_name == "PostToolUse":
207
+ return _handle_post_tool_use(root, hook_input, policy, output_format_name)
208
+
209
+ if root is None:
210
+ if policy.emit_not_initialized:
211
+ _emit(
212
+ _not_initialized_context(hook_input, event_name, host, policy),
213
+ event_name,
214
+ output_format_name,
215
+ policy,
216
+ )
217
+ return 0
218
+ # Hosts without the not-initialized payload exit silently outside a
219
+ # project.
220
+ return 0
221
+
222
+ scripts_dir = root / ".cowork-flow" / "scripts"
223
+ if str(scripts_dir) not in sys.path:
224
+ sys.path.insert(0, str(scripts_dir))
225
+
226
+ # zcode / claude-code signal session start by event name; codex (and the
227
+ # default policy) register UserPromptSubmit only, so their digest shape
228
+ # derives from the session state file probe (session_start=None).
229
+ if policy.session_start_event is None:
230
+ session_start = None
231
+ else:
232
+ session_start = event_name == policy.session_start_event
233
+
234
+ preamble = policy.preamble(root) if policy.preamble is not None else ()
235
+ context = build_hook_context(
236
+ root,
237
+ hook_input,
238
+ host=host,
239
+ adapter=HOST_ADAPTERS[host],
240
+ preamble=preamble,
241
+ session_start=session_start,
242
+ policy=policy,
243
+ )
244
+ _emit(context, event_name, output_format_name, policy)
245
+ return 0
246
+
247
+
248
+ if __name__ == "__main__":
249
+ raise SystemExit(main())
@@ -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
- def codex_dispatch_mode(root: Path) -> str:
48
- _load_common(root)
49
- try:
50
- from infra.config import get_codex_dispatch_mode
51
- except Exception:
52
- return "sub-agent"
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
- return get_codex_dispatch_mode(root)
151
+ module = importlib.import_module(module_name)
55
152
  except Exception:
56
- return "sub-agent"
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 = _get_active_task(root, hook_input)
201
+ task_path, status, source = _get_active_task_with_fallback(
202
+ root,
203
+ hook_input,
204
+ policy.fallback_for_unbound,
205
+ )
99
206
 
100
- body = (
101
- breadcrumbs.get(status)
102
- or "Run ./.cowork-flow/run task next --json for the current workflow route."
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, host, adapter)
228
+ digest_block = _build_contract_digest(root, policy, adapter)
113
229
  else:
114
230
  contracts, _warning = _load_contract_registry(root)
115
231
  digest_block = (
@@ -126,7 +242,13 @@ def build_hook_context(
126
242
  f"<workflow-state{_workflow_state_attrs(task_path, status, source)}>"
127
243
  f"\n{body}\n</workflow-state>"
128
244
  )
129
- return "\n\n".join(blocks)
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:
@@ -289,25 +411,32 @@ def _stage_contract_block(
289
411
  return None
290
412
 
291
413
 
292
- def spec_edit_warning(root: Path, hook_input: dict[str, Any]) -> str:
414
+ def spec_edit_warning(
415
+ root: Path,
416
+ hook_input: dict[str, Any],
417
+ policy: HostPolicy,
418
+ ) -> str:
293
419
  """Editor-phase spec-check single-line warning (PostToolUse short path).
294
420
 
295
- Silence rules match the zcode JS mirror (editScopeWarning family): only
296
- an active in_progress/review main-session task gets advisories —
297
- no_task, planning, completed, and delegated sessions stay silent. Empty
298
- string means silent. Never raises — the editor path must not break
299
- edits.
421
+ Delegated subagents are the primary coders, so spec violations must
422
+ reach them too: only an active in_progress/review task is required —
423
+ session scope is not consulted here (scope warnings stay main-only in
424
+ the zcode policy's edit_scope_warning). Hosts with
425
+ fallback_for_unbound (zcode) follow the newest main session for their
426
+ never-bound hook sessions; strict-identity hosts (claude-code/codex)
427
+ require the calling session's own binding. no_task, planning, and
428
+ completed stay silent. Empty string means silent. Never raises — the
429
+ editor path must not break edits.
300
430
  """
301
431
  try:
302
- task_path, status, _source = _get_active_task(root, hook_input)
432
+ task_path, status, _source = _get_active_task_with_fallback(
433
+ root, hook_input, policy.fallback_for_unbound
434
+ )
303
435
  except Exception:
304
436
  return ""
305
437
  if not task_path or status not in STAGE_CONTRACT_STATES:
306
438
  return ""
307
- tool_name = hook_input.get("tool_name")
308
439
  tool_input = hook_input.get("tool_input")
309
- if tool_name not in {"Edit", "Write", "MultiEdit"}:
310
- return ""
311
440
  file_path = (
312
441
  tool_input.get("file_path") if isinstance(tool_input, dict) else None
313
442
  )
@@ -321,6 +450,21 @@ def spec_edit_warning(root: Path, hook_input: dict[str, Any]) -> str:
321
450
  return ""
322
451
 
323
452
 
453
+ def spec_only_post_tool_use(
454
+ root: Path,
455
+ hook_input: dict[str, Any],
456
+ policy: HostPolicy,
457
+ ) -> tuple[str, int]:
458
+ """claude-code / codex PostToolUse transport: spec-check advisory on
459
+ stderr only (exit 2 surfaces it to the model); the edit itself has
460
+ already happened. No additionalContext payload."""
461
+ warning = spec_edit_warning(root, hook_input, policy)
462
+ if warning:
463
+ print(warning, file=sys.stderr)
464
+ return "", 2
465
+ return "", 0
466
+
467
+
324
468
  def _load_breadcrumbs(root: Path) -> dict[str, str]:
325
469
  path = (
326
470
  root
@@ -396,20 +540,17 @@ def _load_contract_registry(
396
540
 
397
541
  def _build_contract_digest(
398
542
  root: Path,
399
- host: str,
543
+ policy: HostPolicy,
400
544
  adapter: str,
401
545
  ) -> str:
402
546
  contracts, warning = _load_contract_registry(root)
403
547
  fingerprint = contract_fingerprint(root, contracts)
404
548
  lines = [
405
- f'<cowork-runtime host="{host}" adapter="{adapter}">',
549
+ f'<cowork-runtime host="{policy.host}" adapter="{adapter}">',
406
550
  f'<contract-digest fingerprint="{fingerprint}">',
407
- (
408
- "policy: repeat this short digest every hook; "
409
- "read full spec files only before listed actions."
410
- ),
551
+ policy.digest_policy,
411
552
  ]
412
- if warning:
553
+ if warning and not policy.digest_warning_silent:
413
554
  lines.append(f"warning: {warning}")
414
555
  for contract in contracts:
415
556
  contract_id = contract.get("id")
@@ -452,6 +593,105 @@ def _get_active_task(
452
593
  return active.task_path, status.strip(), active.source
453
594
 
454
595
 
596
+ MISSING_TASK_BODY = (
597
+ "Session 指向的任务目录不存在({task_path})。\n"
598
+ "当前项目无有效任务。请运行 ./.cowork-flow/run task next --run "
599
+ '--title "<title>" --slug <task-name> --assignee <name> 创建新任务。'
600
+ )
601
+
602
+
603
+ def _get_active_task_with_fallback(
604
+ root: Path,
605
+ hook_input: dict[str, Any],
606
+ fallback_for_unbound: bool,
607
+ ) -> tuple[str | None, str, str]:
608
+ """zcode parity: a hook session that carries no binding follows the
609
+ newest valid main session's binding. Two sources qualify: no session
610
+ identity at all (missing-context), and a session id that resolves but
611
+ was never bound (empty-session) — the zcode hook always carries the
612
+ conversation's own session id, while task activation happens in the
613
+ Bash CLI under a separate explicit identity, so the hook session file
614
+ never exists. Gated by the host policy's fallback_for_unbound flag;
615
+ display/warning-only — the CLI lifecycle commands keep their strict
616
+ identity semantics. Multi-window setups may cross-read another window's
617
+ binding; accepted for advisory output."""
618
+ task_path, status, source = _get_active_task(root, hook_input)
619
+ if (
620
+ not fallback_for_unbound
621
+ or task_path
622
+ or source not in {"missing-context", "empty-session"}
623
+ ):
624
+ return task_path, status, source
625
+ fallback = _newest_session_task(root)
626
+ if fallback is None:
627
+ return task_path, status, source
628
+ task_json = root / fallback / "task.json"
629
+ try:
630
+ data = json.loads(task_json.read_text(encoding="utf-8"))
631
+ except (OSError, json.JSONDecodeError):
632
+ return fallback, "stale", "session-fallback"
633
+ status = data.get("status")
634
+ if not isinstance(status, str) or not status.strip():
635
+ status = "unknown"
636
+ return fallback, status.strip(), "session-fallback"
637
+
638
+
639
+ def _newest_session_task(root: Path) -> str | None:
640
+ try:
641
+ from runtime.session_state import sessions_dir
642
+ except Exception:
643
+ return None
644
+ sessions = sessions_dir(root)
645
+ if not sessions.is_dir():
646
+ return None
647
+ entries: list[tuple[str, str]] = []
648
+ try:
649
+ paths = sorted(sessions.glob("*.json"))
650
+ except OSError:
651
+ return None
652
+ for path in paths:
653
+ try:
654
+ data = json.loads(path.read_text(encoding="utf-8"))
655
+ except (OSError, json.JSONDecodeError, ValueError):
656
+ continue
657
+ if not isinstance(data, dict):
658
+ continue
659
+ task_path = data.get("active_task_path")
660
+ if not isinstance(task_path, str) or not task_path.strip():
661
+ continue
662
+ if data.get("scope") == "subagent":
663
+ continue
664
+ entries.append(
665
+ (str(data.get("last_seen_at") or ""), task_path.strip())
666
+ )
667
+ entries.sort(reverse=True)
668
+ for _seen_at, task_path in entries:
669
+ task_dir = root / task_path
670
+ if task_dir.is_dir() and (task_dir / "task.json").is_file():
671
+ return task_path
672
+ return None
673
+
674
+
675
+ def _session_scope(root: Path, hook_input: dict[str, Any]) -> str:
676
+ try:
677
+ from runtime.session_state import resolve_context_key, sessions_dir
678
+ except Exception:
679
+ return ""
680
+ context_key = resolve_context_key(hook_input)
681
+ if not context_key:
682
+ return ""
683
+ try:
684
+ data = json.loads(
685
+ (sessions_dir(root) / f"{context_key}.json").read_text(
686
+ encoding="utf-8"
687
+ )
688
+ )
689
+ except (OSError, json.JSONDecodeError, ValueError):
690
+ return ""
691
+ scope = data.get("scope")
692
+ return scope if isinstance(scope, str) else ""
693
+
694
+
455
695
  def _resolve_runtime_context(
456
696
  root: Path,
457
697
  hook_input: dict[str, Any],
@@ -480,7 +720,12 @@ def _resolve_runtime_context(
480
720
  runtime_context_id,
481
721
  values=hook_input,
482
722
  )
483
- return bound or context, runtime_context_id
723
+ result = bound or context
724
+ if not result.get("runtime_context_id"):
725
+ # Legacy context files predate the id field; the detected id is the
726
+ # identity that opened this context, so display it.
727
+ result = {**result, "runtime_context_id": runtime_context_id}
728
+ return result, runtime_context_id
484
729
 
485
730
 
486
731
  def _subagent_runtime_lines(context: dict[str, Any]) -> list[str]: