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.
- package/CHANGELOG.md +45 -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 +315 -38
- package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +285 -0
- package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +34 -11
- package/template/.cowork-flow/scripts/runtime/session_state.py +54 -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 +40 -23
- 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,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())
|