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.
Files changed (33) hide show
  1. package/CHANGELOG.md +45 -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 +315 -38
  14. package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +285 -0
  15. package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +34 -11
  16. package/template/.cowork-flow/scripts/runtime/session_state.py +54 -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 +40 -23
  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
@@ -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 = (
@@ -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
- 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:
@@ -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, status: str, source: str
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 body."""
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(root: Path, hook_input: dict[str, Any]) -> str:
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
- 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.
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 = _get_active_task(root, hook_input)
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
- host: str,
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
- return bound or context, runtime_context_id
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]: