cowork-flow 1.1.4 → 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 +48 -0
- package/README.md +4 -3
- package/package.json +1 -1
- package/template/.claude/hooks/inject-workflow-state.py +12 -85
- package/template/.claude/settings.json +12 -0
- package/template/.codex/hooks/inject-workflow-state.py +13 -89
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +121 -0
- 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_lifecycle_commands.py +1 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +30 -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 +305 -25
- 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/run.py +1 -0
- 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 +69 -2
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +75 -2
- package/template/.cowork-flow/scripts/services/lifecycle_policy.py +9 -2
- package/template/.cowork-flow/scripts/services/spec_check.py +515 -0
- package/template/.cowork-flow/scripts/services/task_context.py +0 -1
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +33 -1
- package/template/.cowork-flow/spec/backend/error-handling.md +14 -0
- 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/index.md +1 -0
- package/template/.cowork-flow/spec/contracts/spec-checks.md +106 -0
- 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 +55 -1
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/inject-context.js +106 -864
- package/template/skills/agent-dispatch/SKILL.md +15 -0
- package/template/skills/runtime-health/scripts/doctor.py +139 -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,52 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""claude-code host policy: the claude-code-specific behaviors of the
|
|
3
|
+
workflow hook.
|
|
4
|
+
|
|
5
|
+
Rendering stays in workflow_state_hook.py; this module owns only the
|
|
6
|
+
claude-code transport specifics — the session alias ported from the claude
|
|
7
|
+
wrapper, the runtime preamble block, and the PostToolUse spec-advisory path
|
|
8
|
+
(stderr + exit 2, shared with codex via the neutral helper).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from adapters.host.workflow_state_hook import HostPolicy
|
|
17
|
+
|
|
18
|
+
CLAUDE_PREAMBLE = (
|
|
19
|
+
(
|
|
20
|
+
"<claude-code-runtime>\n"
|
|
21
|
+
"hooks: UserPromptSubmit, SessionStart, PostToolUse\n"
|
|
22
|
+
"</claude-code-runtime>"
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def claude_session_alias(data: dict[str, Any]) -> dict[str, Any]:
|
|
28
|
+
# Ported from the claude-code wrapper: bare session_id must resolve as a
|
|
29
|
+
# claude session, not fall through to the codex session_id rule.
|
|
30
|
+
if "claude_session_id" not in data:
|
|
31
|
+
session_id = data.get("session_id")
|
|
32
|
+
if isinstance(session_id, str) and session_id.strip():
|
|
33
|
+
data["claude_session_id"] = session_id
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def preamble(root: Path) -> tuple[str, ...]:
|
|
38
|
+
return CLAUDE_PREAMBLE
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
|
|
42
|
+
from adapters.host.workflow_state_hook import spec_only_post_tool_use
|
|
43
|
+
|
|
44
|
+
return spec_only_post_tool_use(root, hook_input, POLICY)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
POLICY = HostPolicy(
|
|
48
|
+
host="claude-code",
|
|
49
|
+
session_alias=claude_session_alias,
|
|
50
|
+
preamble=preamble,
|
|
51
|
+
post_tool_use=post_tool_use,
|
|
52
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codex host policy: the codex-specific behaviors of the workflow hook.
|
|
3
|
+
|
|
4
|
+
Rendering stays in workflow_state_hook.py; this module owns only the codex
|
|
5
|
+
transport specifics — the dispatch-mode preamble (config-driven, the codex
|
|
6
|
+
wrapper surfaces it above every injection) and the probe-based session-start
|
|
7
|
+
detection (codex registers UserPromptSubmit only, so the digest shape derives
|
|
8
|
+
from the session state file probe). PostToolUse reuses the neutral spec-only
|
|
9
|
+
stderr path shared with claude-code.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from adapters.host.workflow_state_hook import HostPolicy
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def codex_dispatch_mode(root: Path) -> str:
|
|
22
|
+
from adapters.host.workflow_state_hook import _load_common
|
|
23
|
+
|
|
24
|
+
_load_common(root)
|
|
25
|
+
try:
|
|
26
|
+
from infra.config import get_codex_dispatch_mode
|
|
27
|
+
except Exception:
|
|
28
|
+
return "sub-agent"
|
|
29
|
+
try:
|
|
30
|
+
return get_codex_dispatch_mode(root)
|
|
31
|
+
except Exception:
|
|
32
|
+
return "sub-agent"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def preamble(root: Path) -> tuple[str, ...]:
|
|
36
|
+
dispatch_mode = codex_dispatch_mode(root)
|
|
37
|
+
return (
|
|
38
|
+
f"<codex-dispatch-mode>{dispatch_mode}</codex-dispatch-mode>",
|
|
39
|
+
(
|
|
40
|
+
"<codex-runtime>\n"
|
|
41
|
+
"dispatch_mode_meaning: workflow dispatch hint, not current "
|
|
42
|
+
"thread role\n"
|
|
43
|
+
"runtime_context_identity: formal subagent sessions bind "
|
|
44
|
+
"before workflow-state injection\n"
|
|
45
|
+
"</codex-runtime>"
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
|
|
51
|
+
from adapters.host.workflow_state_hook import spec_only_post_tool_use
|
|
52
|
+
|
|
53
|
+
return spec_only_post_tool_use(root, hook_input, POLICY)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
POLICY = HostPolicy(
|
|
57
|
+
host="codex",
|
|
58
|
+
session_start_event=None, # probe the session state file instead
|
|
59
|
+
preamble=preamble,
|
|
60
|
+
post_tool_use=post_tool_use,
|
|
61
|
+
)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Host-neutral workflow-context injection entry.
|
|
4
|
+
|
|
5
|
+
Every process-hook host funnels through this module so the workflow facts are
|
|
6
|
+
rendered by a single Python source (workflow_state_hook.py + services.fact_view).
|
|
7
|
+
The entry keeps only transport duties: event routing and byte forwarding.
|
|
8
|
+
Per-host behavior differences (session aliases, preamble, digest policy
|
|
9
|
+
wording, edit warnings, envelope pretty-printing) ride the host policy
|
|
10
|
+
modules beside this file, selected by --host, exactly as frozen by
|
|
11
|
+
spec/contracts/context-injection.md.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
# This file lives in <scripts>/adapters/host/; running it as a script puts
|
|
24
|
+
# only its own directory on sys.path. The module root is needed both for the
|
|
25
|
+
# root=None fallback (no project scripts dir exists) and so a stale project
|
|
26
|
+
# copy never shadows the importing module.
|
|
27
|
+
SCRIPTS_DIR = Path(__file__).resolve().parents[2]
|
|
28
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
29
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
30
|
+
|
|
31
|
+
HOST_ADAPTERS = {
|
|
32
|
+
"zcode": "zcode.plugin",
|
|
33
|
+
"claude-code": "claude-code.hooks",
|
|
34
|
+
"codex": "codex.spawn_agent",
|
|
35
|
+
"dsh": "dsh.preset",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
NOT_INITIALIZED_BODY = (
|
|
39
|
+
"<workflow-state>\n"
|
|
40
|
+
"Status: not_initialized\n"
|
|
41
|
+
"Source: cowork-flow-plugin\n"
|
|
42
|
+
"⚠️ 项目未初始化 cowork-flow 工作流。\n"
|
|
43
|
+
"\n"
|
|
44
|
+
"请通过显式 init/sync 安装 cowork-flow 模板后再继续;"
|
|
45
|
+
"hook 不会在注入阶段创建或复制项目文件。\n"
|
|
46
|
+
"</workflow-state>"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _configure_stdio() -> None:
|
|
51
|
+
for stream in (sys.stdout, sys.stderr):
|
|
52
|
+
if hasattr(stream, "reconfigure"):
|
|
53
|
+
# newline="" keeps payload bytes identical across platforms —
|
|
54
|
+
# text-mode translation would turn the pretty JSON indentation
|
|
55
|
+
# into CRLF on Windows.
|
|
56
|
+
stream.reconfigure(encoding="utf-8", errors="replace", newline="")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _read_input() -> dict[str, Any]:
|
|
60
|
+
raw = sys.stdin.buffer.read()
|
|
61
|
+
if not raw.strip():
|
|
62
|
+
return {}
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(raw.decode("utf-8-sig"))
|
|
65
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
66
|
+
return {}
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
return {}
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def detect_event_name(hook_input: dict[str, Any]) -> str:
|
|
73
|
+
name = hook_input.get("hook_event_name") or hook_input.get("hookEventName")
|
|
74
|
+
if isinstance(name, str) and name.strip():
|
|
75
|
+
return name.strip()
|
|
76
|
+
if os.environ.get("CURSOR_PLUGIN_ROOT"):
|
|
77
|
+
return "SessionStart"
|
|
78
|
+
return "UserPromptSubmit"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def output_format() -> str:
|
|
82
|
+
if os.environ.get("CURSOR_PLUGIN_ROOT"):
|
|
83
|
+
return "cursor"
|
|
84
|
+
if os.environ.get("CLAUDE_PLUGIN_ROOT") and not os.environ.get("COPILOT_CLI"):
|
|
85
|
+
return "claude"
|
|
86
|
+
return "generic"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find_workflow_root(start: Path | None) -> Path | None:
|
|
90
|
+
if start is None:
|
|
91
|
+
return None
|
|
92
|
+
current = start.resolve()
|
|
93
|
+
while True:
|
|
94
|
+
if (current / ".cowork-flow").is_dir():
|
|
95
|
+
return current
|
|
96
|
+
if current == current.parent:
|
|
97
|
+
return None
|
|
98
|
+
current = current.parent
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_root(hook_input: dict[str, Any]) -> Path | None:
|
|
102
|
+
candidates = [
|
|
103
|
+
hook_input.get("cwd"),
|
|
104
|
+
os.environ.get("ZCODE_PROJECT_DIR"),
|
|
105
|
+
os.environ.get("CLAUDE_PROJECT_DIR"),
|
|
106
|
+
os.getcwd(),
|
|
107
|
+
]
|
|
108
|
+
for candidate in candidates:
|
|
109
|
+
if isinstance(candidate, str) and candidate.strip():
|
|
110
|
+
root = find_workflow_root(Path(candidate))
|
|
111
|
+
if root is not None:
|
|
112
|
+
return root
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _not_initialized_context(
|
|
117
|
+
hook_input: dict[str, Any],
|
|
118
|
+
event_name: str,
|
|
119
|
+
host: str,
|
|
120
|
+
policy: Any,
|
|
121
|
+
) -> str:
|
|
122
|
+
from adapters.host.workflow_state_hook import (
|
|
123
|
+
_build_contract_digest,
|
|
124
|
+
_load_contract_registry,
|
|
125
|
+
contract_fingerprint,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
env_dir = (
|
|
129
|
+
os.environ.get("ZCODE_PROJECT_DIR")
|
|
130
|
+
or os.environ.get("CLAUDE_PROJECT_DIR")
|
|
131
|
+
or hook_input.get("cwd")
|
|
132
|
+
or os.getcwd()
|
|
133
|
+
)
|
|
134
|
+
digest_root = Path(env_dir)
|
|
135
|
+
if event_name == "SessionStart":
|
|
136
|
+
prefix = _build_contract_digest(digest_root, policy, HOST_ADAPTERS[host])
|
|
137
|
+
else:
|
|
138
|
+
contracts, _warning = _load_contract_registry(digest_root)
|
|
139
|
+
prefix = (
|
|
140
|
+
f'<contract-fingerprint value="{contract_fingerprint(digest_root, contracts)}"/>'
|
|
141
|
+
)
|
|
142
|
+
return f"{prefix}\n\n{NOT_INITIALIZED_BODY}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _emit(
|
|
146
|
+
context: str,
|
|
147
|
+
event_name: str,
|
|
148
|
+
output_format_name: str,
|
|
149
|
+
policy: Any,
|
|
150
|
+
) -> None:
|
|
151
|
+
if output_format_name == "cursor":
|
|
152
|
+
payload: dict[str, Any] = {"additional_context": context}
|
|
153
|
+
else:
|
|
154
|
+
payload = {
|
|
155
|
+
"hookSpecificOutput": {
|
|
156
|
+
"hookEventName": event_name,
|
|
157
|
+
"additionalContext": context,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
indent = 2 if policy.emit_indent else None
|
|
161
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=indent))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _handle_post_tool_use(
|
|
165
|
+
root: Path | None,
|
|
166
|
+
hook_input: dict[str, Any],
|
|
167
|
+
policy: Any,
|
|
168
|
+
output_format_name: str,
|
|
169
|
+
) -> int:
|
|
170
|
+
if root is None:
|
|
171
|
+
return 0
|
|
172
|
+
if policy.post_tool_use is None:
|
|
173
|
+
return 0
|
|
174
|
+
context, exit_code = policy.post_tool_use(root, hook_input)
|
|
175
|
+
if context:
|
|
176
|
+
_emit(context, "PostToolUse", output_format_name, policy)
|
|
177
|
+
return exit_code
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def main(argv: list[str] | None = None) -> int:
|
|
181
|
+
_configure_stdio()
|
|
182
|
+
if (
|
|
183
|
+
os.environ.get("COWORK_FLOW_HOOKS") == "0"
|
|
184
|
+
or os.environ.get("COWORK_FLOW_DISABLE_HOOKS") == "1"
|
|
185
|
+
):
|
|
186
|
+
return 0
|
|
187
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
188
|
+
parser.add_argument("--host", required=True, choices=sorted(HOST_ADAPTERS))
|
|
189
|
+
args = parser.parse_args(argv)
|
|
190
|
+
host = args.host
|
|
191
|
+
|
|
192
|
+
from adapters.host.workflow_state_hook import (
|
|
193
|
+
build_hook_context,
|
|
194
|
+
resolve_policy,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
policy = resolve_policy(host)
|
|
198
|
+
|
|
199
|
+
hook_input = _read_input()
|
|
200
|
+
if policy.session_alias is not None:
|
|
201
|
+
hook_input = policy.session_alias(hook_input)
|
|
202
|
+
event_name = detect_event_name(hook_input)
|
|
203
|
+
output_format_name = output_format()
|
|
204
|
+
root = resolve_root(hook_input)
|
|
205
|
+
|
|
206
|
+
if event_name == "PostToolUse":
|
|
207
|
+
return _handle_post_tool_use(root, hook_input, policy, output_format_name)
|
|
208
|
+
|
|
209
|
+
if root is None:
|
|
210
|
+
if policy.emit_not_initialized:
|
|
211
|
+
_emit(
|
|
212
|
+
_not_initialized_context(hook_input, event_name, host, policy),
|
|
213
|
+
event_name,
|
|
214
|
+
output_format_name,
|
|
215
|
+
policy,
|
|
216
|
+
)
|
|
217
|
+
return 0
|
|
218
|
+
# Hosts without the not-initialized payload exit silently outside a
|
|
219
|
+
# project.
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
scripts_dir = root / ".cowork-flow" / "scripts"
|
|
223
|
+
if str(scripts_dir) not in sys.path:
|
|
224
|
+
sys.path.insert(0, str(scripts_dir))
|
|
225
|
+
|
|
226
|
+
# zcode / claude-code signal session start by event name; codex (and the
|
|
227
|
+
# default policy) register UserPromptSubmit only, so their digest shape
|
|
228
|
+
# derives from the session state file probe (session_start=None).
|
|
229
|
+
if policy.session_start_event is None:
|
|
230
|
+
session_start = None
|
|
231
|
+
else:
|
|
232
|
+
session_start = event_name == policy.session_start_event
|
|
233
|
+
|
|
234
|
+
preamble = policy.preamble(root) if policy.preamble is not None else ()
|
|
235
|
+
context = build_hook_context(
|
|
236
|
+
root,
|
|
237
|
+
hook_input,
|
|
238
|
+
host=host,
|
|
239
|
+
adapter=HOST_ADAPTERS[host],
|
|
240
|
+
preamble=preamble,
|
|
241
|
+
session_start=session_start,
|
|
242
|
+
policy=policy,
|
|
243
|
+
)
|
|
244
|
+
_emit(context, event_name, output_format_name, policy)
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
raise SystemExit(main())
|