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,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())
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Shared workflow-state protocol used by host hook adapters.
|
|
2
|
+
"""Shared workflow-state protocol used by host hook adapters.
|
|
3
|
+
|
|
4
|
+
This module is host-neutral: it renders the workflow facts and consumes a
|
|
5
|
+
HostPolicy object for the per-host deltas (digest wording, preamble,
|
|
6
|
+
edit warnings, unbound-session fallback). Host behaviors live in the
|
|
7
|
+
per-host policy modules beside this file (zcode_policy.py,
|
|
8
|
+
claude_code_policy.py, codex_policy.py); hosts without a module (dsh) run
|
|
9
|
+
on default_policy().
|
|
10
|
+
"""
|
|
3
11
|
|
|
4
12
|
from __future__ import annotations
|
|
5
13
|
|
|
6
14
|
import hashlib
|
|
15
|
+
import importlib
|
|
7
16
|
import json
|
|
8
17
|
import re
|
|
9
18
|
import sys
|
|
10
19
|
from pathlib import Path
|
|
11
|
-
from typing import Any
|
|
20
|
+
from typing import Any, Callable
|
|
12
21
|
|
|
13
22
|
|
|
14
23
|
TAG_RE = re.compile(
|
|
@@ -30,6 +39,18 @@ DEFAULT_CONTRACT_REGISTRY = {
|
|
|
30
39
|
"when checking subagent health",
|
|
31
40
|
],
|
|
32
41
|
},
|
|
42
|
+
{
|
|
43
|
+
"id": "FACT_LAYER_ACCESS_V1",
|
|
44
|
+
"path": ".cowork-flow/spec/contracts/fact-layer-access.md",
|
|
45
|
+
"digest": [
|
|
46
|
+
"Task/scope/spec facts are read-only via MCP tools or CLI; writes only flow through CLI lifecycle gates.",
|
|
47
|
+
"Prefer MCP task_state/task_scope/task_specs for fact queries when available; CLI is the fallback.",
|
|
48
|
+
],
|
|
49
|
+
"readWhen": [
|
|
50
|
+
"when querying task facts or file scope",
|
|
51
|
+
"when registering the fact layer for a new host or external client",
|
|
52
|
+
],
|
|
53
|
+
},
|
|
33
54
|
]
|
|
34
55
|
}
|
|
35
56
|
|
|
@@ -44,16 +65,96 @@ def find_repo_root(start: Path) -> Path | None:
|
|
|
44
65
|
current = current.parent
|
|
45
66
|
|
|
46
67
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
68
|
+
# Digest policy wording is a per-host contract fact (context-injection.md
|
|
69
|
+
# transport table); hosts carry their line in their policy module, unknown
|
|
70
|
+
# hosts fall back to the default line below.
|
|
71
|
+
DEFAULT_DIGEST_POLICY = (
|
|
72
|
+
"policy: repeat this short digest every hook; "
|
|
73
|
+
"read full spec files only before listed actions."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class HostPolicy:
|
|
78
|
+
"""Per-host deltas consumed by the neutral renderer.
|
|
79
|
+
|
|
80
|
+
Fields left at their defaults mean "no such behavior for this host" —
|
|
81
|
+
the renderer checks callables instead of branching on host names.
|
|
82
|
+
Plain class on purpose: this file is also loaded standalone by tests
|
|
83
|
+
under ad-hoc module names, where @dataclass processing crashes on the
|
|
84
|
+
unregistered module lookup.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
__slots__ = (
|
|
88
|
+
"host",
|
|
89
|
+
"digest_policy",
|
|
90
|
+
"digest_warning_silent",
|
|
91
|
+
"session_start_event",
|
|
92
|
+
"session_alias",
|
|
93
|
+
"preamble",
|
|
94
|
+
"rebind_hints",
|
|
95
|
+
"essential_files_warning",
|
|
96
|
+
"fallback_for_unbound",
|
|
97
|
+
"post_tool_use",
|
|
98
|
+
"emit_indent",
|
|
99
|
+
"emit_not_initialized",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
host: str,
|
|
105
|
+
digest_policy: str = DEFAULT_DIGEST_POLICY,
|
|
106
|
+
digest_warning_silent: bool = False,
|
|
107
|
+
session_start_event: str | None = "SessionStart",
|
|
108
|
+
session_alias: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
|
109
|
+
preamble: Callable[[Path], tuple[str, ...]] | None = None,
|
|
110
|
+
rebind_hints: Callable[[Path], str] | None = None,
|
|
111
|
+
essential_files_warning: Callable[[Path], str] | None = None,
|
|
112
|
+
fallback_for_unbound: bool = False,
|
|
113
|
+
post_tool_use: Callable[[Path, dict[str, Any]], tuple[str, int]] | None = None,
|
|
114
|
+
emit_indent: bool = False,
|
|
115
|
+
emit_not_initialized: bool = False,
|
|
116
|
+
) -> None:
|
|
117
|
+
self.host = host
|
|
118
|
+
self.digest_policy = digest_policy
|
|
119
|
+
self.digest_warning_silent = digest_warning_silent
|
|
120
|
+
self.session_start_event = session_start_event
|
|
121
|
+
self.session_alias = session_alias
|
|
122
|
+
self.preamble = preamble
|
|
123
|
+
self.rebind_hints = rebind_hints
|
|
124
|
+
self.essential_files_warning = essential_files_warning
|
|
125
|
+
self.fallback_for_unbound = fallback_for_unbound
|
|
126
|
+
self.post_tool_use = post_tool_use
|
|
127
|
+
self.emit_indent = emit_indent
|
|
128
|
+
self.emit_not_initialized = emit_not_initialized
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def default_policy(host: str = "generic") -> HostPolicy:
|
|
132
|
+
return HostPolicy(host=host)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Host → policy module dispatch. Data-driven: adding a host is one row plus
|
|
136
|
+
# its policy module; the neutral renderer never branches on host names.
|
|
137
|
+
_HOST_POLICY_MODULES = {
|
|
138
|
+
"zcode": "adapters.host.zcode_policy",
|
|
139
|
+
"claude-code": "adapters.host.claude_code_policy",
|
|
140
|
+
"codex": "adapters.host.codex_policy",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def resolve_policy(host: str, policy: HostPolicy | None = None) -> HostPolicy:
|
|
145
|
+
if policy is not None:
|
|
146
|
+
return policy
|
|
147
|
+
module_name = _HOST_POLICY_MODULES.get(host)
|
|
148
|
+
if not module_name:
|
|
149
|
+
return default_policy(host)
|
|
53
150
|
try:
|
|
54
|
-
|
|
151
|
+
module = importlib.import_module(module_name)
|
|
55
152
|
except Exception:
|
|
56
|
-
|
|
153
|
+
# A broken host policy module degrades to the neutral default
|
|
154
|
+
# instead of killing the injection.
|
|
155
|
+
return default_policy(host)
|
|
156
|
+
policy = module.POLICY
|
|
157
|
+
return policy if isinstance(policy, HostPolicy) else default_policy(host)
|
|
57
158
|
|
|
58
159
|
|
|
59
160
|
def build_hook_context(
|
|
@@ -64,7 +165,9 @@ def build_hook_context(
|
|
|
64
165
|
adapter: str,
|
|
65
166
|
preamble: tuple[str, ...],
|
|
66
167
|
session_start: bool | None = None,
|
|
168
|
+
policy: HostPolicy | None = None,
|
|
67
169
|
) -> str:
|
|
170
|
+
policy = resolve_policy(host, policy)
|
|
68
171
|
breadcrumbs = _load_breadcrumbs(root)
|
|
69
172
|
runtime_context, runtime_context_id = _resolve_runtime_context(
|
|
70
173
|
root,
|
|
@@ -95,12 +198,25 @@ def build_hook_context(
|
|
|
95
198
|
),
|
|
96
199
|
]
|
|
97
200
|
else:
|
|
98
|
-
task_path, status, source =
|
|
201
|
+
task_path, status, source = _get_active_task_with_fallback(
|
|
202
|
+
root,
|
|
203
|
+
hook_input,
|
|
204
|
+
policy.fallback_for_unbound,
|
|
205
|
+
)
|
|
99
206
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
207
|
+
if status == "stale" and task_path:
|
|
208
|
+
# Unified missing-task semantics (previously zcode JS-only): a bound
|
|
209
|
+
# task whose directory or task.json vanished renders the no_task
|
|
210
|
+
# family message instead of a generic fallback breadcrumb.
|
|
211
|
+
status = "no_task"
|
|
212
|
+
body = MISSING_TASK_BODY.format(task_path=task_path)
|
|
213
|
+
else:
|
|
214
|
+
body = (
|
|
215
|
+
breadcrumbs.get(status)
|
|
216
|
+
or "Run ./.cowork-flow/run task next --json for the current workflow route."
|
|
217
|
+
)
|
|
218
|
+
if policy.rebind_hints is not None:
|
|
219
|
+
body += policy.rebind_hints(root)
|
|
104
220
|
if extra_lines:
|
|
105
221
|
body = "\n".join([body, *extra_lines])
|
|
106
222
|
if session_start is None:
|
|
@@ -109,7 +225,7 @@ def build_hook_context(
|
|
|
109
225
|
# exists (start), so their absence keeps every injection full.
|
|
110
226
|
session_start = not _session_has_started(root, hook_input)
|
|
111
227
|
if session_start:
|
|
112
|
-
digest_block = _build_contract_digest(root,
|
|
228
|
+
digest_block = _build_contract_digest(root, policy, adapter)
|
|
113
229
|
else:
|
|
114
230
|
contracts, _warning = _load_contract_registry(root)
|
|
115
231
|
digest_block = (
|
|
@@ -126,7 +242,13 @@ def build_hook_context(
|
|
|
126
242
|
f"<workflow-state{_workflow_state_attrs(task_path, status, source)}>"
|
|
127
243
|
f"\n{body}\n</workflow-state>"
|
|
128
244
|
)
|
|
129
|
-
|
|
245
|
+
context = "\n\n".join(blocks)
|
|
246
|
+
if policy.essential_files_warning is not None:
|
|
247
|
+
# Appended after the whole context so a broken install is visible
|
|
248
|
+
# without a second hook pass (host policy decides whether the check
|
|
249
|
+
# exists at all).
|
|
250
|
+
context += policy.essential_files_warning(root)
|
|
251
|
+
return context
|
|
130
252
|
|
|
131
253
|
|
|
132
254
|
def _session_has_started(root: Path, hook_input: dict[str, Any]) -> bool:
|
|
@@ -289,25 +411,32 @@ def _stage_contract_block(
|
|
|
289
411
|
return None
|
|
290
412
|
|
|
291
413
|
|
|
292
|
-
def spec_edit_warning(
|
|
414
|
+
def spec_edit_warning(
|
|
415
|
+
root: Path,
|
|
416
|
+
hook_input: dict[str, Any],
|
|
417
|
+
policy: HostPolicy,
|
|
418
|
+
) -> str:
|
|
293
419
|
"""Editor-phase spec-check single-line warning (PostToolUse short path).
|
|
294
420
|
|
|
295
|
-
|
|
296
|
-
an active in_progress/review
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
421
|
+
Delegated subagents are the primary coders, so spec violations must
|
|
422
|
+
reach them too: only an active in_progress/review task is required —
|
|
423
|
+
session scope is not consulted here (scope warnings stay main-only in
|
|
424
|
+
the zcode policy's edit_scope_warning). Hosts with
|
|
425
|
+
fallback_for_unbound (zcode) follow the newest main session for their
|
|
426
|
+
never-bound hook sessions; strict-identity hosts (claude-code/codex)
|
|
427
|
+
require the calling session's own binding. no_task, planning, and
|
|
428
|
+
completed stay silent. Empty string means silent. Never raises — the
|
|
429
|
+
editor path must not break edits.
|
|
300
430
|
"""
|
|
301
431
|
try:
|
|
302
|
-
task_path, status, _source =
|
|
432
|
+
task_path, status, _source = _get_active_task_with_fallback(
|
|
433
|
+
root, hook_input, policy.fallback_for_unbound
|
|
434
|
+
)
|
|
303
435
|
except Exception:
|
|
304
436
|
return ""
|
|
305
437
|
if not task_path or status not in STAGE_CONTRACT_STATES:
|
|
306
438
|
return ""
|
|
307
|
-
tool_name = hook_input.get("tool_name")
|
|
308
439
|
tool_input = hook_input.get("tool_input")
|
|
309
|
-
if tool_name not in {"Edit", "Write", "MultiEdit"}:
|
|
310
|
-
return ""
|
|
311
440
|
file_path = (
|
|
312
441
|
tool_input.get("file_path") if isinstance(tool_input, dict) else None
|
|
313
442
|
)
|
|
@@ -321,6 +450,21 @@ def spec_edit_warning(root: Path, hook_input: dict[str, Any]) -> str:
|
|
|
321
450
|
return ""
|
|
322
451
|
|
|
323
452
|
|
|
453
|
+
def spec_only_post_tool_use(
|
|
454
|
+
root: Path,
|
|
455
|
+
hook_input: dict[str, Any],
|
|
456
|
+
policy: HostPolicy,
|
|
457
|
+
) -> tuple[str, int]:
|
|
458
|
+
"""claude-code / codex PostToolUse transport: spec-check advisory on
|
|
459
|
+
stderr only (exit 2 surfaces it to the model); the edit itself has
|
|
460
|
+
already happened. No additionalContext payload."""
|
|
461
|
+
warning = spec_edit_warning(root, hook_input, policy)
|
|
462
|
+
if warning:
|
|
463
|
+
print(warning, file=sys.stderr)
|
|
464
|
+
return "", 2
|
|
465
|
+
return "", 0
|
|
466
|
+
|
|
467
|
+
|
|
324
468
|
def _load_breadcrumbs(root: Path) -> dict[str, str]:
|
|
325
469
|
path = (
|
|
326
470
|
root
|
|
@@ -396,20 +540,17 @@ def _load_contract_registry(
|
|
|
396
540
|
|
|
397
541
|
def _build_contract_digest(
|
|
398
542
|
root: Path,
|
|
399
|
-
|
|
543
|
+
policy: HostPolicy,
|
|
400
544
|
adapter: str,
|
|
401
545
|
) -> str:
|
|
402
546
|
contracts, warning = _load_contract_registry(root)
|
|
403
547
|
fingerprint = contract_fingerprint(root, contracts)
|
|
404
548
|
lines = [
|
|
405
|
-
f'<cowork-runtime host="{host}" adapter="{adapter}">',
|
|
549
|
+
f'<cowork-runtime host="{policy.host}" adapter="{adapter}">',
|
|
406
550
|
f'<contract-digest fingerprint="{fingerprint}">',
|
|
407
|
-
|
|
408
|
-
"policy: repeat this short digest every hook; "
|
|
409
|
-
"read full spec files only before listed actions."
|
|
410
|
-
),
|
|
551
|
+
policy.digest_policy,
|
|
411
552
|
]
|
|
412
|
-
if warning:
|
|
553
|
+
if warning and not policy.digest_warning_silent:
|
|
413
554
|
lines.append(f"warning: {warning}")
|
|
414
555
|
for contract in contracts:
|
|
415
556
|
contract_id = contract.get("id")
|
|
@@ -452,6 +593,105 @@ def _get_active_task(
|
|
|
452
593
|
return active.task_path, status.strip(), active.source
|
|
453
594
|
|
|
454
595
|
|
|
596
|
+
MISSING_TASK_BODY = (
|
|
597
|
+
"Session 指向的任务目录不存在({task_path})。\n"
|
|
598
|
+
"当前项目无有效任务。请运行 ./.cowork-flow/run task next --run "
|
|
599
|
+
'--title "<title>" --slug <task-name> --assignee <name> 创建新任务。'
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _get_active_task_with_fallback(
|
|
604
|
+
root: Path,
|
|
605
|
+
hook_input: dict[str, Any],
|
|
606
|
+
fallback_for_unbound: bool,
|
|
607
|
+
) -> tuple[str | None, str, str]:
|
|
608
|
+
"""zcode parity: a hook session that carries no binding follows the
|
|
609
|
+
newest valid main session's binding. Two sources qualify: no session
|
|
610
|
+
identity at all (missing-context), and a session id that resolves but
|
|
611
|
+
was never bound (empty-session) — the zcode hook always carries the
|
|
612
|
+
conversation's own session id, while task activation happens in the
|
|
613
|
+
Bash CLI under a separate explicit identity, so the hook session file
|
|
614
|
+
never exists. Gated by the host policy's fallback_for_unbound flag;
|
|
615
|
+
display/warning-only — the CLI lifecycle commands keep their strict
|
|
616
|
+
identity semantics. Multi-window setups may cross-read another window's
|
|
617
|
+
binding; accepted for advisory output."""
|
|
618
|
+
task_path, status, source = _get_active_task(root, hook_input)
|
|
619
|
+
if (
|
|
620
|
+
not fallback_for_unbound
|
|
621
|
+
or task_path
|
|
622
|
+
or source not in {"missing-context", "empty-session"}
|
|
623
|
+
):
|
|
624
|
+
return task_path, status, source
|
|
625
|
+
fallback = _newest_session_task(root)
|
|
626
|
+
if fallback is None:
|
|
627
|
+
return task_path, status, source
|
|
628
|
+
task_json = root / fallback / "task.json"
|
|
629
|
+
try:
|
|
630
|
+
data = json.loads(task_json.read_text(encoding="utf-8"))
|
|
631
|
+
except (OSError, json.JSONDecodeError):
|
|
632
|
+
return fallback, "stale", "session-fallback"
|
|
633
|
+
status = data.get("status")
|
|
634
|
+
if not isinstance(status, str) or not status.strip():
|
|
635
|
+
status = "unknown"
|
|
636
|
+
return fallback, status.strip(), "session-fallback"
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _newest_session_task(root: Path) -> str | None:
|
|
640
|
+
try:
|
|
641
|
+
from runtime.session_state import sessions_dir
|
|
642
|
+
except Exception:
|
|
643
|
+
return None
|
|
644
|
+
sessions = sessions_dir(root)
|
|
645
|
+
if not sessions.is_dir():
|
|
646
|
+
return None
|
|
647
|
+
entries: list[tuple[str, str]] = []
|
|
648
|
+
try:
|
|
649
|
+
paths = sorted(sessions.glob("*.json"))
|
|
650
|
+
except OSError:
|
|
651
|
+
return None
|
|
652
|
+
for path in paths:
|
|
653
|
+
try:
|
|
654
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
655
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
656
|
+
continue
|
|
657
|
+
if not isinstance(data, dict):
|
|
658
|
+
continue
|
|
659
|
+
task_path = data.get("active_task_path")
|
|
660
|
+
if not isinstance(task_path, str) or not task_path.strip():
|
|
661
|
+
continue
|
|
662
|
+
if data.get("scope") == "subagent":
|
|
663
|
+
continue
|
|
664
|
+
entries.append(
|
|
665
|
+
(str(data.get("last_seen_at") or ""), task_path.strip())
|
|
666
|
+
)
|
|
667
|
+
entries.sort(reverse=True)
|
|
668
|
+
for _seen_at, task_path in entries:
|
|
669
|
+
task_dir = root / task_path
|
|
670
|
+
if task_dir.is_dir() and (task_dir / "task.json").is_file():
|
|
671
|
+
return task_path
|
|
672
|
+
return None
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _session_scope(root: Path, hook_input: dict[str, Any]) -> str:
|
|
676
|
+
try:
|
|
677
|
+
from runtime.session_state import resolve_context_key, sessions_dir
|
|
678
|
+
except Exception:
|
|
679
|
+
return ""
|
|
680
|
+
context_key = resolve_context_key(hook_input)
|
|
681
|
+
if not context_key:
|
|
682
|
+
return ""
|
|
683
|
+
try:
|
|
684
|
+
data = json.loads(
|
|
685
|
+
(sessions_dir(root) / f"{context_key}.json").read_text(
|
|
686
|
+
encoding="utf-8"
|
|
687
|
+
)
|
|
688
|
+
)
|
|
689
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
690
|
+
return ""
|
|
691
|
+
scope = data.get("scope")
|
|
692
|
+
return scope if isinstance(scope, str) else ""
|
|
693
|
+
|
|
694
|
+
|
|
455
695
|
def _resolve_runtime_context(
|
|
456
696
|
root: Path,
|
|
457
697
|
hook_input: dict[str, Any],
|
|
@@ -480,7 +720,12 @@ def _resolve_runtime_context(
|
|
|
480
720
|
runtime_context_id,
|
|
481
721
|
values=hook_input,
|
|
482
722
|
)
|
|
483
|
-
|
|
723
|
+
result = bound or context
|
|
724
|
+
if not result.get("runtime_context_id"):
|
|
725
|
+
# Legacy context files predate the id field; the detected id is the
|
|
726
|
+
# identity that opened this context, so display it.
|
|
727
|
+
result = {**result, "runtime_context_id": runtime_context_id}
|
|
728
|
+
return result, runtime_context_id
|
|
484
729
|
|
|
485
730
|
|
|
486
731
|
def _subagent_runtime_lines(context: dict[str, Any]) -> list[str]:
|