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.
Files changed (42) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +4 -3
  3. package/package.json +1 -1
  4. package/template/.claude/hooks/inject-workflow-state.py +12 -85
  5. package/template/.claude/settings.json +12 -0
  6. package/template/.codex/hooks/inject-workflow-state.py +13 -89
  7. package/template/.cowork-flow/.version +1 -1
  8. package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +121 -0
  9. package/template/.cowork-flow/scripts/adapters/cli/task.py +4 -1
  10. package/template/.cowork-flow/scripts/adapters/cli/task_context_commands.py +65 -0
  11. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +1 -0
  12. package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +30 -0
  13. package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +52 -0
  14. package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +61 -0
  15. package/template/.cowork-flow/scripts/adapters/host/inject.py +249 -0
  16. package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +305 -25
  17. package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +230 -0
  18. package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +2 -5
  19. package/template/.cowork-flow/scripts/run.py +1 -0
  20. package/template/.cowork-flow/scripts/runtime/session_state.py +19 -9
  21. package/template/.cowork-flow/scripts/services/context_discovery.py +18 -5
  22. package/template/.cowork-flow/scripts/services/fact_view.py +69 -2
  23. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +75 -2
  24. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +9 -2
  25. package/template/.cowork-flow/scripts/services/spec_check.py +515 -0
  26. package/template/.cowork-flow/scripts/services/task_context.py +0 -1
  27. package/template/.cowork-flow/scripts/services/task_lifecycle.py +33 -1
  28. package/template/.cowork-flow/spec/backend/error-handling.md +14 -0
  29. package/template/.cowork-flow/spec/contracts/context-injection.md +24 -20
  30. package/template/.cowork-flow/spec/contracts/fact-layer-access.md +43 -8
  31. package/template/.cowork-flow/spec/contracts/index.md +1 -0
  32. package/template/.cowork-flow/spec/contracts/spec-checks.md +106 -0
  33. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +21 -0
  34. package/template/.cowork-flow/spec/runtime/contract-registry.json +12 -0
  35. package/template/.opencode/plugins/cowork-flow.js +55 -1
  36. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  37. package/template/.zcode/hooks/inject-context.js +106 -864
  38. package/template/skills/agent-dispatch/SKILL.md +15 -0
  39. package/template/skills/runtime-health/scripts/doctor.py +139 -0
  40. package/template/skills/task-planning/SKILL.md +1 -0
  41. package/template/skills/task-review/SKILL.md +1 -0
  42. 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())