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,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())