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
@@ -0,0 +1,285 @@
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
+ # Activation intent inside a lifecycle Bash command: `task next <dir> --run`,
59
+ # `task start <dir>`, `resume <dir>` (the run prefix is optional). Query
60
+ # forms (`task next --json`, `task next --run` without a dir) match nothing
61
+ # claimable — the captured token must be a task dir whose task.json exists.
62
+ ACTIVATION_COMMAND_RE = re.compile(
63
+ r"\btask\s+next\s+(?P<next>\S+)\s+--run\b"
64
+ r"|\btask\s+start\s+(?P<start>\S+?)(?=\s|$)"
65
+ r"|(?<![\w./-])resume\s+(?P<resume>\S+?)(?=\s|$)"
66
+ )
67
+
68
+
69
+ def _resolvable_task_path(root: Path, token: str) -> str | None:
70
+ candidates = [token]
71
+ if not token.startswith(".cowork-flow/"):
72
+ candidates.append(f".cowork-flow/tasks/{token}")
73
+ for candidate in candidates:
74
+ normalized = candidate.replace("\\", "/").strip("/")
75
+ if (root / normalized / "task.json").is_file():
76
+ return normalized
77
+ return None
78
+
79
+
80
+ def claim_after_lifecycle_bash(root: Path, hook_input: dict[str, Any]) -> None:
81
+ """Bind the activated task to this hook session's own identity.
82
+
83
+ The lifecycle Bash CLI resolves a process-fallback identity, so the
84
+ binding it writes is shared across windows; PostToolUse re-binds with the
85
+ hook input's session id via session_state.claim_active_task (explicit /
86
+ host_session provenance only). Commands without an activation intent or
87
+ with an unresolvable task dir claim nothing, and claim failure stays
88
+ silent — the refresh path below still renders the state."""
89
+ tool_input = hook_input.get("tool_input")
90
+ command = (
91
+ tool_input.get("command") if isinstance(tool_input, dict) else None
92
+ )
93
+ if not isinstance(command, str):
94
+ return
95
+ match = ACTIVATION_COMMAND_RE.search(command.replace("\\", "/"))
96
+ if match is None:
97
+ return
98
+ token = next(value for value in match.groupdict().values() if value)
99
+ if token.startswith("-"):
100
+ return
101
+ task_path = _resolvable_task_path(root, token)
102
+ if task_path is None:
103
+ return
104
+ try:
105
+ from runtime.session_state import claim_active_task
106
+
107
+ claim_active_task(root, task_path, hook_input)
108
+ except Exception:
109
+ pass
110
+
111
+
112
+ def zcode_session_alias(data: dict[str, Any]) -> dict[str, Any]:
113
+ # Ported from the zcode hook's resolveSessionKey candidate order
114
+ # (env wins inside session_state; input aliases only fill the gap).
115
+ # Without this, a bare sessionId/session_id would resolve as a codex
116
+ # session and lose the session's own binding.
117
+ existing = data.get("zcode_session_id")
118
+ if isinstance(existing, str) and existing.strip():
119
+ return data
120
+ for key in ("ZCODE_SESSION_ID", "sessionId", "session_id"):
121
+ value = data.get(key)
122
+ if isinstance(value, str) and value.strip():
123
+ data["zcode_session_id"] = value
124
+ break
125
+ return data
126
+
127
+
128
+ def rebind_hints(root: Path) -> str:
129
+ """Port of the zcode hook's formatRebindHints: one-level task scan for
130
+ bindable (non-completed) tasks, appended to no_task/missing bodies."""
131
+ tasks_dir = root / ".cowork-flow" / "tasks"
132
+ try:
133
+ children = sorted(tasks_dir.iterdir())
134
+ except OSError:
135
+ return ""
136
+ entries: list[str] = []
137
+ for child in children:
138
+ if not child.is_dir():
139
+ continue
140
+ relative = f".cowork-flow/tasks/{child.name}"
141
+ task_json = child / "task.json"
142
+ if not task_json.is_file():
143
+ continue
144
+ try:
145
+ data = json.loads(task_json.read_text(encoding="utf-8"))
146
+ except (OSError, json.JSONDecodeError, ValueError):
147
+ status = "unknown"
148
+ else:
149
+ status = str(data.get("status") or "unknown").strip() or "unknown"
150
+ if status == "completed":
151
+ continue
152
+ entries.append(f"- {relative} ({status})")
153
+ if not entries:
154
+ return ""
155
+ return (
156
+ "\n活动任务(可用 ./.cowork-flow/run task next <dir> 改绑):\n"
157
+ + "\n".join(entries)
158
+ )
159
+
160
+
161
+ def essential_files_warning(root: Path) -> str:
162
+ missing = [rel for rel in ESSENTIAL_FILES if not (root / rel).exists()]
163
+ if not missing:
164
+ return ""
165
+ return (
166
+ "\n\n⚠️ 缺少必要文件:"
167
+ + ", ".join(missing)
168
+ + "。\n请立即创建这些文件以保障工作流正常运行。"
169
+ )
170
+
171
+
172
+ def edit_scope_warning(root: Path, hook_input: dict[str, Any]) -> str:
173
+ """Per-edit out-of-scope warning (zcode-only capability). Port of the
174
+ zcode hook's editScopeWarning: at most one line when the edited file is
175
+ outside the task's file-scope whitelist. Silence rules match the JS
176
+ source, including the newest-session display fallback for sessions with
177
+ no resolvable identity. Never raises."""
178
+ from adapters.host.workflow_state_hook import (
179
+ STAGE_CONTRACT_STATES,
180
+ _get_active_task_with_fallback,
181
+ _session_scope,
182
+ )
183
+
184
+ try:
185
+ task_path, status, _source = _get_active_task_with_fallback(
186
+ root, hook_input, POLICY.fallback_for_unbound
187
+ )
188
+ except Exception:
189
+ return ""
190
+ if not task_path or status not in STAGE_CONTRACT_STATES:
191
+ return ""
192
+ if _session_scope(root, hook_input) == "subagent":
193
+ return ""
194
+ tool_input = hook_input.get("tool_input")
195
+ file_path = (
196
+ tool_input.get("file_path") if isinstance(tool_input, dict) else None
197
+ )
198
+ if not isinstance(file_path, str) or not file_path.strip():
199
+ return ""
200
+ # Hosts pass absolute edit paths; whitelist entries are repo-relative.
201
+ # Same normalization the spec path applies in run_edit_checks.
202
+ normalized = file_path.replace("\\", "/").strip()
203
+ try:
204
+ normalized = (
205
+ Path(normalized).resolve().relative_to(root.resolve()).as_posix()
206
+ )
207
+ except (ValueError, OSError):
208
+ pass
209
+ try:
210
+ from services.fact_view import file_scope_whitelist, path_in_scope
211
+
212
+ whitelist = file_scope_whitelist(root, root / task_path)
213
+ if path_in_scope(whitelist, normalized).get("inScope"):
214
+ return ""
215
+ except Exception:
216
+ return ""
217
+ while normalized.startswith("./"):
218
+ normalized = normalized[2:]
219
+ return (
220
+ f"⚠️ {normalized} is outside the task's declared scope. "
221
+ "If intended, add it with `task context add` (agent-mutable) "
222
+ "or revert the edit."
223
+ )
224
+
225
+
226
+ def merged_edit_warning(root: Path, hook_input: dict[str, Any]) -> str:
227
+ """zcode PostToolUse Edit/Write/MultiEdit context: the scope warning and
228
+ the spec-check warning merged into one additionalContext payload, at
229
+ most one line each, joined by a newline (port of the zcode hook's
230
+ mergedEditWarning)."""
231
+ from adapters.host.workflow_state_hook import spec_edit_warning
232
+
233
+ scope_line = edit_scope_warning(root, hook_input)
234
+ spec_line = spec_edit_warning(root, hook_input, POLICY)
235
+ if scope_line and spec_line:
236
+ return f"{scope_line}\n{spec_line}"
237
+ return scope_line or spec_line
238
+
239
+
240
+ def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
241
+ """zcode PostToolUse transport: Edit/Write/MultiEdit get the merged
242
+ edit warning; Bash events refresh the full state only for workflow
243
+ lifecycle commands (the shim pre-filters the common case, this keeps
244
+ the entry correct standalone). Edit storms must not multiply the
245
+ injection payload."""
246
+ tool_name = str(hook_input.get("tool_name") or "")
247
+ tool_input = hook_input.get("tool_input")
248
+ file_path = (
249
+ tool_input.get("file_path") if isinstance(tool_input, dict) else None
250
+ )
251
+ if (
252
+ tool_name in {"Edit", "Write", "MultiEdit"}
253
+ and isinstance(file_path, str)
254
+ and file_path.strip()
255
+ ):
256
+ return merged_edit_warning(root, hook_input), 0
257
+ if not is_lifecycle_bash(hook_input):
258
+ return "", 0
259
+ claim_after_lifecycle_bash(root, hook_input)
260
+ from adapters.host.workflow_state_hook import build_hook_context
261
+
262
+ context = build_hook_context(
263
+ root,
264
+ hook_input,
265
+ host=POLICY.host,
266
+ adapter="zcode.plugin",
267
+ preamble=(),
268
+ session_start=False,
269
+ policy=POLICY,
270
+ )
271
+ return context, 0
272
+
273
+
274
+ POLICY = HostPolicy(
275
+ host="zcode",
276
+ digest_policy=ZCODE_DIGEST_POLICY,
277
+ digest_warning_silent=True,
278
+ session_alias=zcode_session_alias,
279
+ rebind_hints=rebind_hints,
280
+ essential_files_warning=essential_files_warning,
281
+ fallback_for_unbound=True,
282
+ post_tool_use=post_tool_use,
283
+ emit_indent=True,
284
+ emit_not_initialized=True,
285
+ )
@@ -99,16 +99,36 @@ TOOLS = [
99
99
  ]
100
100
 
101
101
 
102
- def _tool_task_state(root: Path, arguments: dict) -> dict:
102
+ TRUSTED_PROVENANCE = ("explicit", "host_session")
103
+
104
+
105
+ def _implicit_active_task(root: Path) -> tuple[str | None, str]:
106
+ """Implicit resolution (no explicit `task` argument): the caller's own
107
+ session-bound active task. A process-fallback identity is shared across
108
+ every window on the host and a missing identity is no identity at all —
109
+ neither may impersonate "the current task", so both return no task with
110
+ an identity-untrusted reason. Returns (task_path, no_task_reason)."""
103
111
  from runtime.session_state import get_active_task
112
+
113
+ active = get_active_task(root)
114
+ if active.provenance not in TRUSTED_PROVENANCE:
115
+ return None, (
116
+ f"no-active-task (identity-untrusted: {active.provenance})"
117
+ )
118
+ if not active.task_path:
119
+ return None, "no-active-task"
120
+ return active.task_path, "no-active-task"
121
+
122
+
123
+ def _tool_task_state(root: Path, arguments: dict) -> dict:
104
124
  from services.fact_view import build_fact_view
105
125
  from services.task_repository import TaskRepository, TaskRepositoryError
106
126
 
107
127
  target = arguments.get("task")
108
128
  if not target:
109
- target = get_active_task(root).task_path
129
+ target, reason = _implicit_active_task(root)
110
130
  if not target:
111
- return {"schemaVersion": 1, "task": None, "reason": "no-active-task"}
131
+ return {"schemaVersion": 1, "task": None, "reason": reason}
112
132
  try:
113
133
  task_dir = TaskRepository(root).resolve(target)
114
134
  except TaskRepositoryError as error:
@@ -124,17 +144,23 @@ def _tool_task_list(root: Path, _arguments: dict) -> dict:
124
144
  records, error = _list_task_records(root, mine=False, status=None)
125
145
  if error:
126
146
  return {"tasks": [], "count": 0, "error": error}
147
+ # records[].active is a per-session fact: only a trusted caller identity
148
+ # whose bound path matches the record may carry it.
149
+ trusted_active = _implicit_active_task(root)[0]
150
+ for record in records:
151
+ record["active"] = (
152
+ bool(trusted_active) and record.get("path") == trusted_active
153
+ )
127
154
  return {"tasks": records, "count": len(records)}
128
155
 
129
156
 
130
157
  def _resolve_task_dir(root: Path, target: str | None) -> Path:
131
- from runtime.session_state import get_active_task
132
158
  from services.task_repository import TaskRepository, TaskRepositoryError
133
159
 
134
160
  if not target:
135
- target = get_active_task(root).task_path
161
+ target, reason = _implicit_active_task(root)
136
162
  if not target:
137
- raise ValueError("no-active-task")
163
+ raise ValueError(reason)
138
164
  try:
139
165
  task_dir = TaskRepository(root).resolve(target)
140
166
  except TaskRepositoryError as error:
@@ -166,13 +192,10 @@ def _tool_task_scope(root: Path, arguments: dict) -> dict:
166
192
 
167
193
 
168
194
  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
195
+ from services.fact_view import spec_entries_for_task
171
196
 
172
197
  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)
198
+ dev_type, specs = spec_entries_for_task(root, task_dir)
176
199
  return {
177
200
  "taskDir": task_dir.name,
178
201
  "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
 
@@ -320,6 +330,41 @@ def set_active_task(repo_root: Path, task_path: str) -> ActiveTask | None:
320
330
  return active
321
331
 
322
332
 
333
+ def claim_active_task(
334
+ repo_root: Path,
335
+ task_path: str,
336
+ values: Mapping[str, object] | None = None,
337
+ ) -> ActiveTask | None:
338
+ """Bind the task to the caller's own resolved session identity.
339
+
340
+ Used by host PostToolUse hooks after a lifecycle Bash activation: the CLI
341
+ process often only carries a shared process-fallback identity, so the hook
342
+ re-binds with the session id parsed from the hook input. Only explicit /
343
+ host_session provenance may claim — process_fallback and missing
344
+ identities are shared or absent, and a main binding written with them
345
+ would hijack the binding for every other window. Claim failures return
346
+ None and write nothing.
347
+ """
348
+ context_key, provenance = resolve_context_key_with_provenance(values)
349
+ if not context_key or provenance not in (
350
+ PROVENANCE_EXPLICIT,
351
+ PROVENANCE_HOST_SESSION,
352
+ ):
353
+ return None
354
+ normalized = task_path.replace("\\", "/")
355
+ target = repo_root / normalized
356
+ if not target.is_dir():
357
+ return None
358
+ data: dict[str, object] = {
359
+ FIELD_ACTIVE_TASK_PATH: normalized,
360
+ FIELD_SCOPE: SCOPE_MAIN,
361
+ "platform": platform_from_context_key(context_key),
362
+ "last_seen_at": _now(),
363
+ }
364
+ _write_json(_session_path(repo_root, context_key), data)
365
+ return ActiveTask(normalized, context_key, "session", provenance=provenance)
366
+
367
+
323
368
  def get_active_task(repo_root: Path, values: Mapping[str, object] | None = None) -> ActiveTask:
324
369
  context_key, provenance = resolve_context_key_with_provenance(values)
325
370
  if not context_key:
@@ -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,