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.
- package/CHANGELOG.md +33 -0
- package/README.md +4 -3
- package/package.json +1 -1
- package/template/.claude/hooks/inject-workflow-state.py +12 -98
- package/template/.codex/hooks/inject-workflow-state.py +13 -89
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task.py +4 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_context_commands.py +65 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +23 -0
- package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +52 -0
- package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +61 -0
- package/template/.cowork-flow/scripts/adapters/host/inject.py +249 -0
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +280 -35
- package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +230 -0
- package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +2 -5
- package/template/.cowork-flow/scripts/runtime/session_state.py +19 -9
- package/template/.cowork-flow/scripts/services/context_discovery.py +18 -5
- package/template/.cowork-flow/scripts/services/fact_view.py +14 -0
- package/template/.cowork-flow/scripts/services/spec_check.py +34 -12
- package/template/.cowork-flow/scripts/services/task_context.py +0 -1
- package/template/.cowork-flow/spec/contracts/context-injection.md +24 -20
- package/template/.cowork-flow/spec/contracts/fact-layer-access.md +43 -8
- package/template/.cowork-flow/spec/contracts/spec-checks.md +40 -8
- package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +21 -0
- package/template/.cowork-flow/spec/runtime/contract-registry.json +12 -0
- package/template/.opencode/plugins/cowork-flow.js +12 -0
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/inject-context.js +95 -971
- package/template/skills/agent-dispatch/SKILL.md +15 -0
- package/template/skills/runtime-health/scripts/doctor.py +90 -0
- package/template/skills/task-planning/SKILL.md +1 -0
- package/template/skills/task-review/SKILL.md +1 -0
- package/template/.zcode/hooks/inject-context.selfcheck.mjs +0 -795
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
#
|
|
39
|
-
|
|
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
|
|
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)
|
|
293
|
-
#
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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`)
|
|
20
|
-
| codex |
|
|
21
|
-
| claude-code |
|
|
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
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
29
|
-
|
|
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.
|