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,230 @@
1
+ #!/usr/bin/env python3
2
+ """zcode host policy: the zcode-specific behaviors of the workflow hook.
3
+
4
+ Rendering stays in workflow_state_hook.py (single source shared with the
5
+ other hosts); this module owns only what is zcode-specific — the digest
6
+ policy wording, the newest-session fallback for unbound hook sessions, the
7
+ rebind hints and essential-files check ported from the zcode JS hook, the
8
+ merged scope+spec edit warning, and the PostToolUse transport contract the
9
+ zcode shim relies on.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from adapters.host.workflow_state_hook import HostPolicy
21
+
22
+ # Digest policy wording frozen by context-injection.md (zcode drops the
23
+ # registry-warning line and asks for the fingerprint on every hook).
24
+ ZCODE_DIGEST_POLICY = (
25
+ "policy: repeat fingerprint every hook; "
26
+ "read full spec files only before listed actions."
27
+ )
28
+
29
+ # The zcode shim (hooks/inject-context.js) pre-filters Bash events with the
30
+ # same rule; this entry keeps the transport contract correct standalone.
31
+ LIFECYCLE_BASH_RE = re.compile(r"\brun(?:\.cmd)?\s+(?:task|subagent|resume)\b")
32
+
33
+ # Port of the zcode hook's essential-files check (inject-context.js
34
+ # checkEssentialFiles): appended to the zcode context when any guard file of
35
+ # the workflow install is missing.
36
+ ESSENTIAL_FILES = (
37
+ "AGENTS.md",
38
+ ".cowork-flow/config.yaml",
39
+ ".cowork-flow/run",
40
+ ".cowork-flow/spec/runtime/contract-registry.json",
41
+ ".cowork-flow/spec/contracts/workflow-state-templates.md",
42
+ )
43
+
44
+
45
+ def is_lifecycle_bash(hook_input: dict[str, Any]) -> bool:
46
+ tool_input = hook_input.get("tool_input")
47
+ command = (
48
+ tool_input.get("command") if isinstance(tool_input, dict) else None
49
+ )
50
+ if not isinstance(command, str):
51
+ return False
52
+ normalized = command.replace("\\", "/")
53
+ return ".cowork-flow/run" in normalized or bool(
54
+ LIFECYCLE_BASH_RE.search(normalized)
55
+ )
56
+
57
+
58
+ def zcode_session_alias(data: dict[str, Any]) -> dict[str, Any]:
59
+ # Ported from the zcode hook's resolveSessionKey candidate order
60
+ # (env wins inside session_state; input aliases only fill the gap).
61
+ # Without this, a bare sessionId/session_id would resolve as a codex
62
+ # session and lose the session's own binding.
63
+ existing = data.get("zcode_session_id")
64
+ if isinstance(existing, str) and existing.strip():
65
+ return data
66
+ for key in ("ZCODE_SESSION_ID", "sessionId", "session_id"):
67
+ value = data.get(key)
68
+ if isinstance(value, str) and value.strip():
69
+ data["zcode_session_id"] = value
70
+ break
71
+ return data
72
+
73
+
74
+ def rebind_hints(root: Path) -> str:
75
+ """Port of the zcode hook's formatRebindHints: one-level task scan for
76
+ bindable (non-completed) tasks, appended to no_task/missing bodies."""
77
+ tasks_dir = root / ".cowork-flow" / "tasks"
78
+ try:
79
+ children = sorted(tasks_dir.iterdir())
80
+ except OSError:
81
+ return ""
82
+ entries: list[str] = []
83
+ for child in children:
84
+ if not child.is_dir():
85
+ continue
86
+ relative = f".cowork-flow/tasks/{child.name}"
87
+ task_json = child / "task.json"
88
+ if not task_json.is_file():
89
+ continue
90
+ try:
91
+ data = json.loads(task_json.read_text(encoding="utf-8"))
92
+ except (OSError, json.JSONDecodeError, ValueError):
93
+ status = "unknown"
94
+ else:
95
+ status = str(data.get("status") or "unknown").strip() or "unknown"
96
+ if status == "completed":
97
+ continue
98
+ entries.append(f"- {relative} ({status})")
99
+ if not entries:
100
+ return ""
101
+ return (
102
+ "\n活动任务(可用 ./.cowork-flow/run task next <dir> 改绑):\n"
103
+ + "\n".join(entries)
104
+ )
105
+
106
+
107
+ def essential_files_warning(root: Path) -> str:
108
+ missing = [rel for rel in ESSENTIAL_FILES if not (root / rel).exists()]
109
+ if not missing:
110
+ return ""
111
+ return (
112
+ "\n\n⚠️ 缺少必要文件:"
113
+ + ", ".join(missing)
114
+ + "。\n请立即创建这些文件以保障工作流正常运行。"
115
+ )
116
+
117
+
118
+ def edit_scope_warning(root: Path, hook_input: dict[str, Any]) -> str:
119
+ """Per-edit out-of-scope warning (zcode-only capability). Port of the
120
+ zcode hook's editScopeWarning: at most one line when the edited file is
121
+ outside the task's file-scope whitelist. Silence rules match the JS
122
+ source, including the newest-session display fallback for sessions with
123
+ no resolvable identity. Never raises."""
124
+ from adapters.host.workflow_state_hook import (
125
+ STAGE_CONTRACT_STATES,
126
+ _get_active_task_with_fallback,
127
+ _session_scope,
128
+ )
129
+
130
+ try:
131
+ task_path, status, _source = _get_active_task_with_fallback(
132
+ root, hook_input, POLICY.fallback_for_unbound
133
+ )
134
+ except Exception:
135
+ return ""
136
+ if not task_path or status not in STAGE_CONTRACT_STATES:
137
+ return ""
138
+ if _session_scope(root, hook_input) == "subagent":
139
+ return ""
140
+ tool_input = hook_input.get("tool_input")
141
+ file_path = (
142
+ tool_input.get("file_path") if isinstance(tool_input, dict) else None
143
+ )
144
+ if not isinstance(file_path, str) or not file_path.strip():
145
+ return ""
146
+ # Hosts pass absolute edit paths; whitelist entries are repo-relative.
147
+ # Same normalization the spec path applies in run_edit_checks.
148
+ normalized = file_path.replace("\\", "/").strip()
149
+ try:
150
+ normalized = (
151
+ Path(normalized).resolve().relative_to(root.resolve()).as_posix()
152
+ )
153
+ except (ValueError, OSError):
154
+ pass
155
+ try:
156
+ from services.fact_view import file_scope_whitelist, path_in_scope
157
+
158
+ whitelist = file_scope_whitelist(root, root / task_path)
159
+ if path_in_scope(whitelist, normalized).get("inScope"):
160
+ return ""
161
+ except Exception:
162
+ return ""
163
+ while normalized.startswith("./"):
164
+ normalized = normalized[2:]
165
+ return (
166
+ f"⚠️ {normalized} is outside the task's declared scope. "
167
+ "If intended, add it with `task context add` (agent-mutable) "
168
+ "or revert the edit."
169
+ )
170
+
171
+
172
+ def merged_edit_warning(root: Path, hook_input: dict[str, Any]) -> str:
173
+ """zcode PostToolUse Edit/Write/MultiEdit context: the scope warning and
174
+ the spec-check warning merged into one additionalContext payload, at
175
+ most one line each, joined by a newline (port of the zcode hook's
176
+ mergedEditWarning)."""
177
+ from adapters.host.workflow_state_hook import spec_edit_warning
178
+
179
+ scope_line = edit_scope_warning(root, hook_input)
180
+ spec_line = spec_edit_warning(root, hook_input, POLICY)
181
+ if scope_line and spec_line:
182
+ return f"{scope_line}\n{spec_line}"
183
+ return scope_line or spec_line
184
+
185
+
186
+ def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
187
+ """zcode PostToolUse transport: Edit/Write/MultiEdit get the merged
188
+ edit warning; Bash events refresh the full state only for workflow
189
+ lifecycle commands (the shim pre-filters the common case, this keeps
190
+ the entry correct standalone). Edit storms must not multiply the
191
+ injection payload."""
192
+ tool_name = str(hook_input.get("tool_name") or "")
193
+ tool_input = hook_input.get("tool_input")
194
+ file_path = (
195
+ tool_input.get("file_path") if isinstance(tool_input, dict) else None
196
+ )
197
+ if (
198
+ tool_name in {"Edit", "Write", "MultiEdit"}
199
+ and isinstance(file_path, str)
200
+ and file_path.strip()
201
+ ):
202
+ return merged_edit_warning(root, hook_input), 0
203
+ if not is_lifecycle_bash(hook_input):
204
+ return "", 0
205
+ from adapters.host.workflow_state_hook import build_hook_context
206
+
207
+ context = build_hook_context(
208
+ root,
209
+ hook_input,
210
+ host=POLICY.host,
211
+ adapter="zcode.plugin",
212
+ preamble=(),
213
+ session_start=False,
214
+ policy=POLICY,
215
+ )
216
+ return context, 0
217
+
218
+
219
+ POLICY = HostPolicy(
220
+ host="zcode",
221
+ digest_policy=ZCODE_DIGEST_POLICY,
222
+ digest_warning_silent=True,
223
+ session_alias=zcode_session_alias,
224
+ rebind_hints=rebind_hints,
225
+ essential_files_warning=essential_files_warning,
226
+ fallback_for_unbound=True,
227
+ post_tool_use=post_tool_use,
228
+ emit_indent=True,
229
+ emit_not_initialized=True,
230
+ )
@@ -166,13 +166,10 @@ def _tool_task_scope(root: Path, arguments: dict) -> dict:
166
166
 
167
167
 
168
168
  def _tool_task_specs(root: Path, arguments: dict) -> dict:
169
- from services.context_discovery import implement_spec_entries
170
- from services.fact_view import _read_json
169
+ from services.fact_view import spec_entries_for_task
171
170
 
172
171
  task_dir = _resolve_task_dir(root, arguments.get("task"))
173
- task = _read_json(task_dir / "task.json")
174
- dev_type = task.get("dev_type") if isinstance(task, dict) else None
175
- specs = implement_spec_entries(root, dev_type)
172
+ dev_type, specs = spec_entries_for_task(root, task_dir)
176
173
  return {
177
174
  "taskDir": task_dir.name,
178
175
  "devType": dev_type,
@@ -34,6 +34,14 @@ RUNTIME_CONTEXT_PROMPT_RE = re.compile(
34
34
  HOST_CONTEXT_PROMPT_RE = re.compile(
35
35
  r"(?im)^\s*cowork_host_context_key\s*:\s*([A-Za-z0-9._-]+)\s*$"
36
36
  )
37
+ # Process-label providers: hosts whose Bash spawns carry only a shared
38
+ # process label (no per-session id). Data-driven like the env mapping tables
39
+ # below — adding a host is one row, no host branches in this module. The
40
+ # identity is process-level shared; consumers must treat it as
41
+ # PROVENANCE_PROCESS_FALLBACK and degrade accordingly.
42
+ PROCESS_LABEL_PROVIDERS: tuple[tuple[str, str], ...] = (
43
+ ("zcode", "ZCODE_PROCESS_LABEL"),
44
+ )
37
45
 
38
46
 
39
47
  @dataclass(frozen=True)
@@ -76,15 +84,17 @@ def resolve_context_key_with_provenance(
76
84
  context_key = _resolve_input_context_key(values)
77
85
  if context_key:
78
86
  return context_key, PROVENANCE_HOST_SESSION
79
- # zcode 主会话(Bash CLI)无任何 host session id:用进程标签兜底,
80
- # 保证 task start / session 解析一次成功,不再回落到其它 host。
81
- # 该身份是进程级共享的,消费方必须按 PROVENANCE_PROCESS_FALLBACK 降级处理。
82
- process_label = os.environ.get("ZCODE_PROCESS_LABEL")
83
- if process_label and process_label.strip():
84
- return (
85
- _prefixed_context_key("zcode", process_label),
86
- PROVENANCE_PROCESS_FALLBACK,
87
- )
87
+ # Hosts whose Bash CLI carries no session id fall back to their shared
88
+ # process label so task start / session resolution succeeds once instead
89
+ # of dropping to another host's identity. Process-level shared identity —
90
+ # consumers must degrade per PROVENANCE_PROCESS_FALLBACK.
91
+ for prefix, env_name in PROCESS_LABEL_PROVIDERS:
92
+ process_label = os.environ.get(env_name)
93
+ if process_label and process_label.strip():
94
+ return (
95
+ _prefixed_context_key(prefix, process_label),
96
+ PROVENANCE_PROCESS_FALLBACK,
97
+ )
88
98
  return None, PROVENANCE_MISSING
89
99
 
90
100
 
@@ -85,14 +85,27 @@ def detect_installed_platforms(repo_root: Path | None = None) -> list[str]:
85
85
  return platforms
86
86
 
87
87
 
88
- def use_claude_skill_context(repo_root: Path | None = None) -> bool:
89
- return detect_installed_platforms(repo_root) == ["claude-code"]
88
+ # Platforms that load skills from their own asset dir declare it here; the
89
+ # shared .agents/skills layout is the default for every other platform.
90
+ SKILL_REPLICA_DIR_BY_PLATFORM = {
91
+ "claude-code": ".claude/skills",
92
+ }
93
+
94
+
95
+ def skill_root(repo_root: Path | None = None) -> str:
96
+ """Neutral rule over platform data: when exactly one platform is
97
+ installed and that platform declares a dedicated skill replica dir, the
98
+ skills live there; otherwise the shared .agents/skills layout applies."""
99
+ platforms = detect_installed_platforms(repo_root)
100
+ if len(platforms) == 1:
101
+ dedicated = SKILL_REPLICA_DIR_BY_PLATFORM.get(platforms[0])
102
+ if dedicated:
103
+ return dedicated
104
+ return f"{DIR_AGENTS}/skills"
90
105
 
91
106
 
92
107
  def skill_path(name: str, repo_root: Path | None = None) -> str:
93
- if use_claude_skill_context(repo_root):
94
- return f".claude/skills/{name}/SKILL.md"
95
- return f"{DIR_AGENTS}/skills/{name}/SKILL.md"
108
+ return f"{skill_root(repo_root)}/{name}/SKILL.md"
96
109
 
97
110
 
98
111
  def get_domain_skill_context(
@@ -189,6 +189,20 @@ def path_in_scope(
189
189
  }
190
190
 
191
191
 
192
+ def spec_entries_for_task(
193
+ repo_root: Path, task_dir: Path
194
+ ) -> tuple[Any, list[dict]]:
195
+ """Public facade for the implement reading list: the task's dev_type plus
196
+ the dispatched spec/skill entries. The MCP task_specs tool and the
197
+ `task specs` CLI fact command both consume this, so the two channels
198
+ cannot drift."""
199
+ from services.context_discovery import implement_spec_entries
200
+
201
+ task = _read_json(task_dir / "task.json")
202
+ dev_type = task.get("dev_type") if isinstance(task, dict) else None
203
+ return dev_type, implement_spec_entries(repo_root, dev_type)
204
+
205
+
192
206
  STAGE_CONTRACT_STATES = ("in_progress", "review")
193
207
  STAGE_CONTRACT_SCOPE_LIMIT = 8
194
208
  STAGE_CONTRACT_SPECS_LIMIT = 4
@@ -33,10 +33,11 @@ from typing import Iterable
33
33
  # is kept free of CLI/process plumbing concerns by architecture tests.
34
34
  IS_WINDOWS = os.name == "nt"
35
35
 
36
- # Editor-phase budget: the zcode/claude PostToolUse hook runs on a 5s hard
37
- # budget including interpreter startup, so edit-phase commands get 3s and
38
- # nothing more.
39
- EDIT_PHASE_TIMEOUT = 3.0
36
+ # Editor-phase budget: the zcode/claude PostToolUse hook runs on a ~5s hard
37
+ # budget including interpreter startup, so edit-phase commands get 2.5s —
38
+ # leaving room for Python startup plus cmd.exe wrapper overhead on slow
39
+ # machines. Slow or unfiltered commands belong to the lifecycle phase.
40
+ EDIT_PHASE_TIMEOUT = 2.5
40
41
  DEFAULT_TIMEOUT = 30.0
41
42
  MAX_TIMEOUT = 120.0
42
43
  MAX_FIRST_LINE_CHARS = 200
@@ -215,7 +216,7 @@ def _parse_files(raw: str) -> tuple[tuple[str, ...], list[str]]:
215
216
  errors: list[str] = []
216
217
  matched: list[str] = []
217
218
  for part in raw.split(","):
218
- token = part.strip().replace("\\", "/")
219
+ token = _strip_quotes(part).replace("\\", "/")
219
220
  while token.startswith("./"):
220
221
  token = token[2:]
221
222
  if not token:
@@ -287,12 +288,24 @@ def _first_violation_line(output: Iterable[str]) -> str:
287
288
  def _run_command(decl: CheckDecl, repo_root: Path) -> tuple[str, int | None, str, list[str]]:
288
289
  """Execute one declaration. Returns (status, exit_code, reason, output)."""
289
290
  command = _decl_for_platform(decl)
291
+ tokens: list[str] = []
290
292
  if IS_WINDOWS:
291
293
  # Windows: newer runtimes refuse to spawn .cmd shims directly
292
- # (EINVAL); route through cmd.exe the way npm shims launch. Failure
293
- # to find cmd.exe or the entry degrades to unchecked, never pass.
294
- argv = ["cmd.exe", "/c", command]
295
- popen_kwargs: dict = {}
294
+ # (EINVAL), so the command goes through cmd.exe. Passing an argv list
295
+ # makes list2cmdline escape an already-quoted command into `\"...\"`
296
+ # — cmd then strips the outer quotes and fails to parse — so the
297
+ # raw string runs via shell=True, exactly the way npm shims launch.
298
+ # The entry check keeps "missing command" as unchecked instead of a
299
+ # cmd.exe rc=1 masquerading as a violation.
300
+ try:
301
+ tokens = shlex.split(command, posix=False)
302
+ except ValueError:
303
+ tokens = []
304
+ first_token = tokens[0].strip('"') if tokens else ""
305
+ if not first_token or shutil.which(first_token) is None:
306
+ return "unchecked", None, f"command not found: {first_token}", []
307
+ argv: str | list[str] = command
308
+ popen_kwargs: dict = {"shell": True}
296
309
  else:
297
310
  try:
298
311
  argv = shlex.split(command)
@@ -309,7 +322,11 @@ def _run_command(decl: CheckDecl, repo_root: Path) -> tuple[str, int | None, str
309
322
  argv,
310
323
  cwd=str(repo_root),
311
324
  capture_output=True,
312
- text=True,
325
+ # Explicit UTF-8 with replacement: command output is process
326
+ # noise (first violation line only), never allowed to crash the
327
+ # reader thread or masquerade as a check outcome.
328
+ encoding="utf-8",
329
+ errors="replace",
313
330
  timeout=decl.timeout,
314
331
  check=False,
315
332
  **popen_kwargs,
@@ -418,7 +435,9 @@ def normalized_one_line(report: dict) -> str:
418
435
  # Per-file throttle for editor-phase runs: an edit storm must not multiply
419
436
  # the command cost. State lives in the workflow runtime directory because
420
437
  # every hook invocation is a fresh process — in-memory maps cannot throttle
421
- # across invocations.
438
+ # across invocations. The slot is recorded only after a completed run: an
439
+ # executor crash must not consume the interval, so the next edit within it
440
+ # still runs its checks.
422
441
  EDIT_THROTTLE_INTERVAL_SECONDS = 10.0
423
442
  EDIT_THROTTLE_FILE = ".cowork-flow" / Path(".runtime") / "spec-edit-throttle.json"
424
443
 
@@ -481,11 +500,14 @@ def run_edit_checks(
481
500
  and current - state["ts"] < EDIT_THROTTLE_INTERVAL_SECONDS
482
501
  ):
483
502
  return ""
484
- _write_throttle(repo_root, normalized, current)
485
503
  try:
486
504
  report = run_checks(repo_root, phase="edit", changed_files=[normalized])
487
505
  except Exception:
506
+ # Executor crash (not a check failure): leave the throttle untouched
507
+ # so a retry within the interval is not silently swallowed.
488
508
  return ""
509
+ if throttled:
510
+ _write_throttle(repo_root, normalized, time.time() if now is None else now)
489
511
  return normalized_one_line(report)
490
512
 
491
513
 
@@ -18,7 +18,6 @@ from services.context_discovery import (
18
18
  implement_spec_entries,
19
19
  is_skill_path,
20
20
  skill_path,
21
- use_claude_skill_context,
22
21
  )
23
22
  from services.context_jsonl import (
24
23
  ContextJsonlEntry,
@@ -3,10 +3,11 @@
3
3
  Single source for how every host adapter injects cowork-flow runtime context
4
4
  into a session: the transport shape, the event timing matrix, the contract
5
5
  digest shape rules (full block vs fingerprint line), and the fingerprint
6
- serialization norm. All host implementations — zcode JS hook, the Python
7
- shared core (codex / claude-code / dsh), and the opencode JS plugin — must
8
- follow this document; cross-host behavior tests lock the three independent
9
- implementations to it.
6
+ serialization norm. The workflow facts are rendered by ONE Python source —
7
+ `adapters/host/inject.py` over `workflow_state_hook.py` +
8
+ `services.fact_view.py` — consumed by the zcode transport shim (node),
9
+ claude-code, codex, and dsh; the opencode JS plugin remains the one
10
+ independent mirror, locked to the same bytes by cross-host behavior tests.
10
11
 
11
12
  This document is a meta-protocol: it describes the injection layer itself and
12
13
  is intentionally **not** registered in `contract-registry.json` (registering it
@@ -16,9 +17,9 @@ would make the digest self-referential).
16
17
 
17
18
  | Host | Transport | Shape |
18
19
  |---|---|---|
19
- | zcode | process hook (`inject-context.js`), stdout JSON | `hookSpecificOutput.{hookEventName, additionalContext}` |
20
- | codex | command hook (Python shell), stdout JSON | same shape |
21
- | claude-code | command hook (Python shell), stdout JSON | same shape |
20
+ | zcode | process hook shim (`inject-context.js`) → `inject.py --host zcode`, stdout JSON | `hookSpecificOutput.{hookEventName, additionalContext}` |
21
+ | codex | thin wrapper (`inject-workflow-state.py`) → `inject.py --host codex`, stdout JSON | same shape |
22
+ | claude-code | thin wrapper (`inject-workflow-state.py`) → `inject.py --host claude-code`, stdout JSON | same shape |
22
23
  | opencode | plugin `experimental.chat.system.transform` + `shell.env` | system-prompt section push / env object |
23
24
  | dsh | preset plugin system-prompt section | named section, replace semantics |
24
25
 
@@ -27,7 +28,8 @@ runtime preamble, the contract digest (full block or fingerprint line, see
27
28
  below), optional `decision-anchor` / `stage-contract` blocks, and the
28
29
  `<workflow-state>` block. It is **not** byte-identical across hosts at the
29
30
  string level — the digest policy line differs per host wording and zcode
30
- drops the registry-warning line. Exactly two things are byte-identical and
31
+ drops the registry-warning line; both differences are selected inside the
32
+ single Python source by `--host`. Exactly two things are byte-identical and
31
33
  test-locked: the contract digest **fingerprint value** and the
32
34
  `<stage-contract>` block.
33
35
 
@@ -171,16 +173,17 @@ Verify: npm run test:fast; python3 -m pytest tests/ -q
171
173
  (min 1) — the closing tag and the Gates row always survive; tests assert
172
174
  the block stays well-formed. The scope-filter rules and the stage-contract
173
175
  budget/limits come from `.cowork-flow/spec/runtime/scope-rules.json`, the
174
- single source consumed by Python and both JS mirrors at runtime; a missing
175
- or malformed file degrades to embedded defaults that are byte-identical
176
- with the shipped file (locked by `tests/test_scope_rules.py`).
176
+ single source consumed at runtime; a missing or malformed file degrades to
177
+ embedded defaults that are byte-identical with the shipped file (locked by
178
+ `tests/test_scope_rules.py`).
177
179
  - Absent for `no_task` / `planning` / `completed` states. Block content is
178
- byte-identical across the three host implementations for the whole fixture
179
- matrix (`test/fixtures/stage-contract-matrix.json` drives the cross-host
180
- equality test: canonical, `./`-prefixed, invalid-boundary, over-budget,
181
- emoji, missing-anchor, empty-scope, delegated-readonly). The JS scope
182
- filter is a port of the Python rules and is locked by that matrix — a
183
- port, not a mechanism, so the matrix list grows whenever the rules grow.
180
+ byte-identical across host lines for the whole fixture matrix
181
+ (`test/fixtures/stage-contract-matrix.json` drives the equality test:
182
+ canonical, `./`-prefixed, invalid-boundary, over-budget, emoji,
183
+ missing-anchor, empty-scope, delegated-readonly). The zcode line now runs
184
+ through the same Python source as the python line (transport shim), so the
185
+ matrix's remaining cross-host assertion is opencode's JS mirror against
186
+ the single source — a mirror, not a mechanism.
184
187
 
185
188
  The authoritative per-file verdict lives in the MCP `task_scope` tool
186
189
  (`services.fact_view.file_scope_whitelist` — same semantics, live data).
@@ -189,15 +192,16 @@ The authoritative per-file verdict lives in the MCP `task_scope` tool
189
192
 
190
193
  sha256 over (1) the registry `contracts` array serialized with
191
194
  **recursively sorted keys** (Python `json.dumps(..., sort_keys=True,
192
- ensure_ascii=False)`; the JS implementations use the equivalent
195
+ ensure_ascii=False)`; the opencode plugin uses the equivalent
193
196
  `stableStringify`), then (2) each contract file's raw bytes in registry
194
197
  order; a missing file hashes as the literal string `missing:<path>`. The
195
198
  fingerprint is the first 16 hex chars of the digest.
196
199
 
197
- Serialization must be byte-identical across the three implementations: same
200
+ Serialization must be byte-identical across implementations: same
198
201
  key order, same escaping (ASCII-safe content), same UTF-8 encoding. The
199
202
  cross-host test (`test/contract-fingerprint.test.js`) asserts this and is the
200
- regression lock against drift.
203
+ regression lock against drift between the opencode mirror and the Python
204
+ single source.
201
205
 
202
206
  ## Change guard
203
207
 
@@ -1,7 +1,19 @@
1
1
  # Fact-layer access
2
2
 
3
3
  How external tools (MCP clients today, other ecosystem protocols as they
4
- converge) read cowork-flow task facts. Stage 3 of `docs/direction.md`.
4
+ converge) and agents on hosts without injection hooks read cowork-flow task
5
+ facts. Stage 3 of `docs/direction.md`.
6
+
7
+ ## Three channels, one fact layer
8
+
9
+ All channels consume the same services layer (`services/fact_view.py`) and
10
+ cannot drift on facts:
11
+
12
+ | Channel | Direction | Role |
13
+ |---|---|---|
14
+ | Hooks (host adapter → `inject.py`) | push | Proactive context injection; host-enforced, agents cannot skip it. |
15
+ | CLI lifecycle commands | write + pull | The only write path; enforces the full gate chain. Pull fallback when MCP is unavailable. |
16
+ | MCP tools | pull | Standardized read access for external clients (IDEs, dashboards) and hosts without hook support. |
5
17
 
6
18
  ## Stance
7
19
 
@@ -25,19 +37,42 @@ stderr is kept log-only).
25
37
 
26
38
  Global registration: `cowork-flow mcp-state` (npm CLI) resolves the nearest
27
39
  `.cowork-flow/` from the client's cwd and execs that project's runner with
28
- inherited stdio — register it once (`command: cowork-flow, args:
29
- ["mcp-state"]`) and every cowork-flow project works, because the root
30
- resolution is identical to the server's own (`get_repo_root`). Outside a
31
- project the passthrough fails with a clear error; inside one, nested
32
- subdirectories resolve to the project root.
40
+ inherited stdio. Outside a project the passthrough fails with a clear error;
41
+ inside one, nested subdirectories resolve to the project root.
33
42
 
34
43
  Server info: `cowork-flow-facts`. On `initialize` the server echoes the
35
44
  client's requested `protocolVersion` (falling back to `2025-06-18`).
36
45
 
37
46
  | Tool | Input | Output | Reads |
38
47
  |---|---|---|---|
39
- | `task_state` | `{task?: string}` | The fact view as JSON text (`task`, `decisionAnchor`, `plan`, `sessions`, `snapshot`); without `task`, the session-bound active task, or `{"task": null, "reason": "no-active-task"}` | `services/fact_view.build_fact_view` |
48
+ | `task_state` | `{task?: string}` | The fact view as JSON text (`task`, `decisionAnchor`, `plan`, `sessions`, `snapshot`); without `task`, the session-bound active task when one resolves, or `{"task": null, "reason": "no-active-task"}` | `services/fact_view.build_fact_view` |
40
49
  | `task_list` | `{}` | Active tasks overview: name, path, status, assignee, parent/children, active flag | `TaskTreeService.active_nodes` via the list records |
50
+ | `task_scope` | `{task?: string, path?: string}` | The file-scope whitelist summary, or a per-path `inScope` verdict with `path` | `services/fact_view.file_scope_whitelist` / `path_in_scope` |
51
+ | `task_specs` | `{task?: string}` | The spec/skill reading list dispatched by the task's `dev_type` | `services/fact_view.spec_entries_for_task` |
52
+
53
+ `task_scope` and `task_specs` have CLI fact-command equivalents
54
+ (`task scope`, `task specs`) consuming the same facade — MCP holds no
55
+ exclusive capability.
56
+
57
+ The MCP server process has no session concept: `task_state` without `task`
58
+ resolves the caller's session only when session identity reaches the server
59
+ through the environment (session-bound env keys). Where it cannot, it returns
60
+ `no-active-task` — pass `task` explicitly for deterministic project-level
61
+ queries. Session-bound facts remain the hook/CLI channels' responsibility.
62
+
63
+ ## Registration map (two tiers)
64
+
65
+ | Host | Global (supported default) | Project-level opt-in |
66
+ |---|---|---|
67
+ | claude-code | `claude mcp add cowork-flow -- cowork-flow mcp-state` | `.mcp.json` with an `mcpServers` entry |
68
+ | zcode | user config `mcp.servers.cowork-flow = {command: cowork-flow, args: [mcp-state]}` | plugin format has no MCP field — use the global tier |
69
+ | codex | `~/.codex/config.toml` `[mcp_servers.cowork-flow]` | project config cannot enable user-approval-gated capabilities — use the global tier |
70
+ | opencode | `opencode.json` `mcp` entry | — |
71
+
72
+ `run doctor` reports the registration state (project entry present, absent,
73
+ or duplicated across tiers) as an advisory, read-only health item. Registering
74
+ a new contract changes the digest fingerprint everywhere; see the change
75
+ guard.
41
76
 
42
77
  Tool failures (unknown task, missing task.json) ride the tool result with
43
78
  `isError: true`; the JSON-RPC frame stays valid.
@@ -47,4 +82,4 @@ Tool failures (unknown task, missing task.json) ride the tool result with
47
82
  - New fact sources (fields on the fact view) are additive; consumers must
48
83
  tolerate unknown keys.
49
84
  - Any write-capable tool proposal must re-open this contract first — and is
50
- rejected by default under the current stance.
85
+ rejected by default under the current stance.