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,285 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""zcode host policy: the zcode-specific behaviors of the workflow hook.
|
|
3
|
+
|
|
4
|
+
Rendering stays in workflow_state_hook.py (single source shared with the
|
|
5
|
+
other hosts); this module owns only what is zcode-specific — the digest
|
|
6
|
+
policy wording, the newest-session fallback for unbound hook sessions, the
|
|
7
|
+
rebind hints and essential-files check ported from the zcode JS hook, the
|
|
8
|
+
merged scope+spec edit warning, and the PostToolUse transport contract the
|
|
9
|
+
zcode shim relies on.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from adapters.host.workflow_state_hook import HostPolicy
|
|
21
|
+
|
|
22
|
+
# Digest policy wording frozen by context-injection.md (zcode drops the
|
|
23
|
+
# registry-warning line and asks for the fingerprint on every hook).
|
|
24
|
+
ZCODE_DIGEST_POLICY = (
|
|
25
|
+
"policy: repeat fingerprint every hook; "
|
|
26
|
+
"read full spec files only before listed actions."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# The zcode shim (hooks/inject-context.js) pre-filters Bash events with the
|
|
30
|
+
# same rule; this entry keeps the transport contract correct standalone.
|
|
31
|
+
LIFECYCLE_BASH_RE = re.compile(r"\brun(?:\.cmd)?\s+(?:task|subagent|resume)\b")
|
|
32
|
+
|
|
33
|
+
# Port of the zcode hook's essential-files check (inject-context.js
|
|
34
|
+
# checkEssentialFiles): appended to the zcode context when any guard file of
|
|
35
|
+
# the workflow install is missing.
|
|
36
|
+
ESSENTIAL_FILES = (
|
|
37
|
+
"AGENTS.md",
|
|
38
|
+
".cowork-flow/config.yaml",
|
|
39
|
+
".cowork-flow/run",
|
|
40
|
+
".cowork-flow/spec/runtime/contract-registry.json",
|
|
41
|
+
".cowork-flow/spec/contracts/workflow-state-templates.md",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_lifecycle_bash(hook_input: dict[str, Any]) -> bool:
|
|
46
|
+
tool_input = hook_input.get("tool_input")
|
|
47
|
+
command = (
|
|
48
|
+
tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
49
|
+
)
|
|
50
|
+
if not isinstance(command, str):
|
|
51
|
+
return False
|
|
52
|
+
normalized = command.replace("\\", "/")
|
|
53
|
+
return ".cowork-flow/run" in normalized or bool(
|
|
54
|
+
LIFECYCLE_BASH_RE.search(normalized)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Activation intent inside a lifecycle Bash command: `task next <dir> --run`,
|
|
59
|
+
# `task start <dir>`, `resume <dir>` (the run prefix is optional). Query
|
|
60
|
+
# forms (`task next --json`, `task next --run` without a dir) match nothing
|
|
61
|
+
# claimable — the captured token must be a task dir whose task.json exists.
|
|
62
|
+
ACTIVATION_COMMAND_RE = re.compile(
|
|
63
|
+
r"\btask\s+next\s+(?P<next>\S+)\s+--run\b"
|
|
64
|
+
r"|\btask\s+start\s+(?P<start>\S+?)(?=\s|$)"
|
|
65
|
+
r"|(?<![\w./-])resume\s+(?P<resume>\S+?)(?=\s|$)"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolvable_task_path(root: Path, token: str) -> str | None:
|
|
70
|
+
candidates = [token]
|
|
71
|
+
if not token.startswith(".cowork-flow/"):
|
|
72
|
+
candidates.append(f".cowork-flow/tasks/{token}")
|
|
73
|
+
for candidate in candidates:
|
|
74
|
+
normalized = candidate.replace("\\", "/").strip("/")
|
|
75
|
+
if (root / normalized / "task.json").is_file():
|
|
76
|
+
return normalized
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def claim_after_lifecycle_bash(root: Path, hook_input: dict[str, Any]) -> None:
|
|
81
|
+
"""Bind the activated task to this hook session's own identity.
|
|
82
|
+
|
|
83
|
+
The lifecycle Bash CLI resolves a process-fallback identity, so the
|
|
84
|
+
binding it writes is shared across windows; PostToolUse re-binds with the
|
|
85
|
+
hook input's session id via session_state.claim_active_task (explicit /
|
|
86
|
+
host_session provenance only). Commands without an activation intent or
|
|
87
|
+
with an unresolvable task dir claim nothing, and claim failure stays
|
|
88
|
+
silent — the refresh path below still renders the state."""
|
|
89
|
+
tool_input = hook_input.get("tool_input")
|
|
90
|
+
command = (
|
|
91
|
+
tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
92
|
+
)
|
|
93
|
+
if not isinstance(command, str):
|
|
94
|
+
return
|
|
95
|
+
match = ACTIVATION_COMMAND_RE.search(command.replace("\\", "/"))
|
|
96
|
+
if match is None:
|
|
97
|
+
return
|
|
98
|
+
token = next(value for value in match.groupdict().values() if value)
|
|
99
|
+
if token.startswith("-"):
|
|
100
|
+
return
|
|
101
|
+
task_path = _resolvable_task_path(root, token)
|
|
102
|
+
if task_path is None:
|
|
103
|
+
return
|
|
104
|
+
try:
|
|
105
|
+
from runtime.session_state import claim_active_task
|
|
106
|
+
|
|
107
|
+
claim_active_task(root, task_path, hook_input)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def zcode_session_alias(data: dict[str, Any]) -> dict[str, Any]:
|
|
113
|
+
# Ported from the zcode hook's resolveSessionKey candidate order
|
|
114
|
+
# (env wins inside session_state; input aliases only fill the gap).
|
|
115
|
+
# Without this, a bare sessionId/session_id would resolve as a codex
|
|
116
|
+
# session and lose the session's own binding.
|
|
117
|
+
existing = data.get("zcode_session_id")
|
|
118
|
+
if isinstance(existing, str) and existing.strip():
|
|
119
|
+
return data
|
|
120
|
+
for key in ("ZCODE_SESSION_ID", "sessionId", "session_id"):
|
|
121
|
+
value = data.get(key)
|
|
122
|
+
if isinstance(value, str) and value.strip():
|
|
123
|
+
data["zcode_session_id"] = value
|
|
124
|
+
break
|
|
125
|
+
return data
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def rebind_hints(root: Path) -> str:
|
|
129
|
+
"""Port of the zcode hook's formatRebindHints: one-level task scan for
|
|
130
|
+
bindable (non-completed) tasks, appended to no_task/missing bodies."""
|
|
131
|
+
tasks_dir = root / ".cowork-flow" / "tasks"
|
|
132
|
+
try:
|
|
133
|
+
children = sorted(tasks_dir.iterdir())
|
|
134
|
+
except OSError:
|
|
135
|
+
return ""
|
|
136
|
+
entries: list[str] = []
|
|
137
|
+
for child in children:
|
|
138
|
+
if not child.is_dir():
|
|
139
|
+
continue
|
|
140
|
+
relative = f".cowork-flow/tasks/{child.name}"
|
|
141
|
+
task_json = child / "task.json"
|
|
142
|
+
if not task_json.is_file():
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
data = json.loads(task_json.read_text(encoding="utf-8"))
|
|
146
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
147
|
+
status = "unknown"
|
|
148
|
+
else:
|
|
149
|
+
status = str(data.get("status") or "unknown").strip() or "unknown"
|
|
150
|
+
if status == "completed":
|
|
151
|
+
continue
|
|
152
|
+
entries.append(f"- {relative} ({status})")
|
|
153
|
+
if not entries:
|
|
154
|
+
return ""
|
|
155
|
+
return (
|
|
156
|
+
"\n活动任务(可用 ./.cowork-flow/run task next <dir> 改绑):\n"
|
|
157
|
+
+ "\n".join(entries)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def essential_files_warning(root: Path) -> str:
|
|
162
|
+
missing = [rel for rel in ESSENTIAL_FILES if not (root / rel).exists()]
|
|
163
|
+
if not missing:
|
|
164
|
+
return ""
|
|
165
|
+
return (
|
|
166
|
+
"\n\n⚠️ 缺少必要文件:"
|
|
167
|
+
+ ", ".join(missing)
|
|
168
|
+
+ "。\n请立即创建这些文件以保障工作流正常运行。"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def edit_scope_warning(root: Path, hook_input: dict[str, Any]) -> str:
|
|
173
|
+
"""Per-edit out-of-scope warning (zcode-only capability). Port of the
|
|
174
|
+
zcode hook's editScopeWarning: at most one line when the edited file is
|
|
175
|
+
outside the task's file-scope whitelist. Silence rules match the JS
|
|
176
|
+
source, including the newest-session display fallback for sessions with
|
|
177
|
+
no resolvable identity. Never raises."""
|
|
178
|
+
from adapters.host.workflow_state_hook import (
|
|
179
|
+
STAGE_CONTRACT_STATES,
|
|
180
|
+
_get_active_task_with_fallback,
|
|
181
|
+
_session_scope,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
task_path, status, _source = _get_active_task_with_fallback(
|
|
186
|
+
root, hook_input, POLICY.fallback_for_unbound
|
|
187
|
+
)
|
|
188
|
+
except Exception:
|
|
189
|
+
return ""
|
|
190
|
+
if not task_path or status not in STAGE_CONTRACT_STATES:
|
|
191
|
+
return ""
|
|
192
|
+
if _session_scope(root, hook_input) == "subagent":
|
|
193
|
+
return ""
|
|
194
|
+
tool_input = hook_input.get("tool_input")
|
|
195
|
+
file_path = (
|
|
196
|
+
tool_input.get("file_path") if isinstance(tool_input, dict) else None
|
|
197
|
+
)
|
|
198
|
+
if not isinstance(file_path, str) or not file_path.strip():
|
|
199
|
+
return ""
|
|
200
|
+
# Hosts pass absolute edit paths; whitelist entries are repo-relative.
|
|
201
|
+
# Same normalization the spec path applies in run_edit_checks.
|
|
202
|
+
normalized = file_path.replace("\\", "/").strip()
|
|
203
|
+
try:
|
|
204
|
+
normalized = (
|
|
205
|
+
Path(normalized).resolve().relative_to(root.resolve()).as_posix()
|
|
206
|
+
)
|
|
207
|
+
except (ValueError, OSError):
|
|
208
|
+
pass
|
|
209
|
+
try:
|
|
210
|
+
from services.fact_view import file_scope_whitelist, path_in_scope
|
|
211
|
+
|
|
212
|
+
whitelist = file_scope_whitelist(root, root / task_path)
|
|
213
|
+
if path_in_scope(whitelist, normalized).get("inScope"):
|
|
214
|
+
return ""
|
|
215
|
+
except Exception:
|
|
216
|
+
return ""
|
|
217
|
+
while normalized.startswith("./"):
|
|
218
|
+
normalized = normalized[2:]
|
|
219
|
+
return (
|
|
220
|
+
f"⚠️ {normalized} is outside the task's declared scope. "
|
|
221
|
+
"If intended, add it with `task context add` (agent-mutable) "
|
|
222
|
+
"or revert the edit."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def merged_edit_warning(root: Path, hook_input: dict[str, Any]) -> str:
|
|
227
|
+
"""zcode PostToolUse Edit/Write/MultiEdit context: the scope warning and
|
|
228
|
+
the spec-check warning merged into one additionalContext payload, at
|
|
229
|
+
most one line each, joined by a newline (port of the zcode hook's
|
|
230
|
+
mergedEditWarning)."""
|
|
231
|
+
from adapters.host.workflow_state_hook import spec_edit_warning
|
|
232
|
+
|
|
233
|
+
scope_line = edit_scope_warning(root, hook_input)
|
|
234
|
+
spec_line = spec_edit_warning(root, hook_input, POLICY)
|
|
235
|
+
if scope_line and spec_line:
|
|
236
|
+
return f"{scope_line}\n{spec_line}"
|
|
237
|
+
return scope_line or spec_line
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
|
|
241
|
+
"""zcode PostToolUse transport: Edit/Write/MultiEdit get the merged
|
|
242
|
+
edit warning; Bash events refresh the full state only for workflow
|
|
243
|
+
lifecycle commands (the shim pre-filters the common case, this keeps
|
|
244
|
+
the entry correct standalone). Edit storms must not multiply the
|
|
245
|
+
injection payload."""
|
|
246
|
+
tool_name = str(hook_input.get("tool_name") or "")
|
|
247
|
+
tool_input = hook_input.get("tool_input")
|
|
248
|
+
file_path = (
|
|
249
|
+
tool_input.get("file_path") if isinstance(tool_input, dict) else None
|
|
250
|
+
)
|
|
251
|
+
if (
|
|
252
|
+
tool_name in {"Edit", "Write", "MultiEdit"}
|
|
253
|
+
and isinstance(file_path, str)
|
|
254
|
+
and file_path.strip()
|
|
255
|
+
):
|
|
256
|
+
return merged_edit_warning(root, hook_input), 0
|
|
257
|
+
if not is_lifecycle_bash(hook_input):
|
|
258
|
+
return "", 0
|
|
259
|
+
claim_after_lifecycle_bash(root, hook_input)
|
|
260
|
+
from adapters.host.workflow_state_hook import build_hook_context
|
|
261
|
+
|
|
262
|
+
context = build_hook_context(
|
|
263
|
+
root,
|
|
264
|
+
hook_input,
|
|
265
|
+
host=POLICY.host,
|
|
266
|
+
adapter="zcode.plugin",
|
|
267
|
+
preamble=(),
|
|
268
|
+
session_start=False,
|
|
269
|
+
policy=POLICY,
|
|
270
|
+
)
|
|
271
|
+
return context, 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
POLICY = HostPolicy(
|
|
275
|
+
host="zcode",
|
|
276
|
+
digest_policy=ZCODE_DIGEST_POLICY,
|
|
277
|
+
digest_warning_silent=True,
|
|
278
|
+
session_alias=zcode_session_alias,
|
|
279
|
+
rebind_hints=rebind_hints,
|
|
280
|
+
essential_files_warning=essential_files_warning,
|
|
281
|
+
fallback_for_unbound=True,
|
|
282
|
+
post_tool_use=post_tool_use,
|
|
283
|
+
emit_indent=True,
|
|
284
|
+
emit_not_initialized=True,
|
|
285
|
+
)
|
|
@@ -99,16 +99,36 @@ TOOLS = [
|
|
|
99
99
|
]
|
|
100
100
|
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
TRUSTED_PROVENANCE = ("explicit", "host_session")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _implicit_active_task(root: Path) -> tuple[str | None, str]:
|
|
106
|
+
"""Implicit resolution (no explicit `task` argument): the caller's own
|
|
107
|
+
session-bound active task. A process-fallback identity is shared across
|
|
108
|
+
every window on the host and a missing identity is no identity at all —
|
|
109
|
+
neither may impersonate "the current task", so both return no task with
|
|
110
|
+
an identity-untrusted reason. Returns (task_path, no_task_reason)."""
|
|
103
111
|
from runtime.session_state import get_active_task
|
|
112
|
+
|
|
113
|
+
active = get_active_task(root)
|
|
114
|
+
if active.provenance not in TRUSTED_PROVENANCE:
|
|
115
|
+
return None, (
|
|
116
|
+
f"no-active-task (identity-untrusted: {active.provenance})"
|
|
117
|
+
)
|
|
118
|
+
if not active.task_path:
|
|
119
|
+
return None, "no-active-task"
|
|
120
|
+
return active.task_path, "no-active-task"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _tool_task_state(root: Path, arguments: dict) -> dict:
|
|
104
124
|
from services.fact_view import build_fact_view
|
|
105
125
|
from services.task_repository import TaskRepository, TaskRepositoryError
|
|
106
126
|
|
|
107
127
|
target = arguments.get("task")
|
|
108
128
|
if not target:
|
|
109
|
-
target =
|
|
129
|
+
target, reason = _implicit_active_task(root)
|
|
110
130
|
if not target:
|
|
111
|
-
return {"schemaVersion": 1, "task": None, "reason":
|
|
131
|
+
return {"schemaVersion": 1, "task": None, "reason": reason}
|
|
112
132
|
try:
|
|
113
133
|
task_dir = TaskRepository(root).resolve(target)
|
|
114
134
|
except TaskRepositoryError as error:
|
|
@@ -124,17 +144,23 @@ def _tool_task_list(root: Path, _arguments: dict) -> dict:
|
|
|
124
144
|
records, error = _list_task_records(root, mine=False, status=None)
|
|
125
145
|
if error:
|
|
126
146
|
return {"tasks": [], "count": 0, "error": error}
|
|
147
|
+
# records[].active is a per-session fact: only a trusted caller identity
|
|
148
|
+
# whose bound path matches the record may carry it.
|
|
149
|
+
trusted_active = _implicit_active_task(root)[0]
|
|
150
|
+
for record in records:
|
|
151
|
+
record["active"] = (
|
|
152
|
+
bool(trusted_active) and record.get("path") == trusted_active
|
|
153
|
+
)
|
|
127
154
|
return {"tasks": records, "count": len(records)}
|
|
128
155
|
|
|
129
156
|
|
|
130
157
|
def _resolve_task_dir(root: Path, target: str | None) -> Path:
|
|
131
|
-
from runtime.session_state import get_active_task
|
|
132
158
|
from services.task_repository import TaskRepository, TaskRepositoryError
|
|
133
159
|
|
|
134
160
|
if not target:
|
|
135
|
-
target =
|
|
161
|
+
target, reason = _implicit_active_task(root)
|
|
136
162
|
if not target:
|
|
137
|
-
raise ValueError(
|
|
163
|
+
raise ValueError(reason)
|
|
138
164
|
try:
|
|
139
165
|
task_dir = TaskRepository(root).resolve(target)
|
|
140
166
|
except TaskRepositoryError as error:
|
|
@@ -166,13 +192,10 @@ def _tool_task_scope(root: Path, arguments: dict) -> dict:
|
|
|
166
192
|
|
|
167
193
|
|
|
168
194
|
def _tool_task_specs(root: Path, arguments: dict) -> dict:
|
|
169
|
-
from services.
|
|
170
|
-
from services.fact_view import _read_json
|
|
195
|
+
from services.fact_view import spec_entries_for_task
|
|
171
196
|
|
|
172
197
|
task_dir = _resolve_task_dir(root, arguments.get("task"))
|
|
173
|
-
|
|
174
|
-
dev_type = task.get("dev_type") if isinstance(task, dict) else None
|
|
175
|
-
specs = implement_spec_entries(root, dev_type)
|
|
198
|
+
dev_type, specs = spec_entries_for_task(root, task_dir)
|
|
176
199
|
return {
|
|
177
200
|
"taskDir": task_dir.name,
|
|
178
201
|
"devType": dev_type,
|
|
@@ -34,6 +34,14 @@ RUNTIME_CONTEXT_PROMPT_RE = re.compile(
|
|
|
34
34
|
HOST_CONTEXT_PROMPT_RE = re.compile(
|
|
35
35
|
r"(?im)^\s*cowork_host_context_key\s*:\s*([A-Za-z0-9._-]+)\s*$"
|
|
36
36
|
)
|
|
37
|
+
# Process-label providers: hosts whose Bash spawns carry only a shared
|
|
38
|
+
# process label (no per-session id). Data-driven like the env mapping tables
|
|
39
|
+
# below — adding a host is one row, no host branches in this module. The
|
|
40
|
+
# identity is process-level shared; consumers must treat it as
|
|
41
|
+
# PROVENANCE_PROCESS_FALLBACK and degrade accordingly.
|
|
42
|
+
PROCESS_LABEL_PROVIDERS: tuple[tuple[str, str], ...] = (
|
|
43
|
+
("zcode", "ZCODE_PROCESS_LABEL"),
|
|
44
|
+
)
|
|
37
45
|
|
|
38
46
|
|
|
39
47
|
@dataclass(frozen=True)
|
|
@@ -76,15 +84,17 @@ def resolve_context_key_with_provenance(
|
|
|
76
84
|
context_key = _resolve_input_context_key(values)
|
|
77
85
|
if context_key:
|
|
78
86
|
return context_key, PROVENANCE_HOST_SESSION
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
# Hosts whose Bash CLI carries no session id fall back to their shared
|
|
88
|
+
# process label so task start / session resolution succeeds once instead
|
|
89
|
+
# of dropping to another host's identity. Process-level shared identity —
|
|
90
|
+
# consumers must degrade per PROVENANCE_PROCESS_FALLBACK.
|
|
91
|
+
for prefix, env_name in PROCESS_LABEL_PROVIDERS:
|
|
92
|
+
process_label = os.environ.get(env_name)
|
|
93
|
+
if process_label and process_label.strip():
|
|
94
|
+
return (
|
|
95
|
+
_prefixed_context_key(prefix, process_label),
|
|
96
|
+
PROVENANCE_PROCESS_FALLBACK,
|
|
97
|
+
)
|
|
88
98
|
return None, PROVENANCE_MISSING
|
|
89
99
|
|
|
90
100
|
|
|
@@ -320,6 +330,41 @@ def set_active_task(repo_root: Path, task_path: str) -> ActiveTask | None:
|
|
|
320
330
|
return active
|
|
321
331
|
|
|
322
332
|
|
|
333
|
+
def claim_active_task(
|
|
334
|
+
repo_root: Path,
|
|
335
|
+
task_path: str,
|
|
336
|
+
values: Mapping[str, object] | None = None,
|
|
337
|
+
) -> ActiveTask | None:
|
|
338
|
+
"""Bind the task to the caller's own resolved session identity.
|
|
339
|
+
|
|
340
|
+
Used by host PostToolUse hooks after a lifecycle Bash activation: the CLI
|
|
341
|
+
process often only carries a shared process-fallback identity, so the hook
|
|
342
|
+
re-binds with the session id parsed from the hook input. Only explicit /
|
|
343
|
+
host_session provenance may claim — process_fallback and missing
|
|
344
|
+
identities are shared or absent, and a main binding written with them
|
|
345
|
+
would hijack the binding for every other window. Claim failures return
|
|
346
|
+
None and write nothing.
|
|
347
|
+
"""
|
|
348
|
+
context_key, provenance = resolve_context_key_with_provenance(values)
|
|
349
|
+
if not context_key or provenance not in (
|
|
350
|
+
PROVENANCE_EXPLICIT,
|
|
351
|
+
PROVENANCE_HOST_SESSION,
|
|
352
|
+
):
|
|
353
|
+
return None
|
|
354
|
+
normalized = task_path.replace("\\", "/")
|
|
355
|
+
target = repo_root / normalized
|
|
356
|
+
if not target.is_dir():
|
|
357
|
+
return None
|
|
358
|
+
data: dict[str, object] = {
|
|
359
|
+
FIELD_ACTIVE_TASK_PATH: normalized,
|
|
360
|
+
FIELD_SCOPE: SCOPE_MAIN,
|
|
361
|
+
"platform": platform_from_context_key(context_key),
|
|
362
|
+
"last_seen_at": _now(),
|
|
363
|
+
}
|
|
364
|
+
_write_json(_session_path(repo_root, context_key), data)
|
|
365
|
+
return ActiveTask(normalized, context_key, "session", provenance=provenance)
|
|
366
|
+
|
|
367
|
+
|
|
323
368
|
def get_active_task(repo_root: Path, values: Mapping[str, object] | None = None) -> ActiveTask:
|
|
324
369
|
context_key, provenance = resolve_context_key_with_provenance(values)
|
|
325
370
|
if not context_key:
|
|
@@ -85,14 +85,27 @@ def detect_installed_platforms(repo_root: Path | None = None) -> list[str]:
|
|
|
85
85
|
return platforms
|
|
86
86
|
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
# Platforms that load skills from their own asset dir declare it here; the
|
|
89
|
+
# shared .agents/skills layout is the default for every other platform.
|
|
90
|
+
SKILL_REPLICA_DIR_BY_PLATFORM = {
|
|
91
|
+
"claude-code": ".claude/skills",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def skill_root(repo_root: Path | None = None) -> str:
|
|
96
|
+
"""Neutral rule over platform data: when exactly one platform is
|
|
97
|
+
installed and that platform declares a dedicated skill replica dir, the
|
|
98
|
+
skills live there; otherwise the shared .agents/skills layout applies."""
|
|
99
|
+
platforms = detect_installed_platforms(repo_root)
|
|
100
|
+
if len(platforms) == 1:
|
|
101
|
+
dedicated = SKILL_REPLICA_DIR_BY_PLATFORM.get(platforms[0])
|
|
102
|
+
if dedicated:
|
|
103
|
+
return dedicated
|
|
104
|
+
return f"{DIR_AGENTS}/skills"
|
|
90
105
|
|
|
91
106
|
|
|
92
107
|
def skill_path(name: str, repo_root: Path | None = None) -> str:
|
|
93
|
-
|
|
94
|
-
return f".claude/skills/{name}/SKILL.md"
|
|
95
|
-
return f"{DIR_AGENTS}/skills/{name}/SKILL.md"
|
|
108
|
+
return f"{skill_root(repo_root)}/{name}/SKILL.md"
|
|
96
109
|
|
|
97
110
|
|
|
98
111
|
def get_domain_skill_context(
|
|
@@ -189,6 +189,20 @@ def path_in_scope(
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
|
|
192
|
+
def spec_entries_for_task(
|
|
193
|
+
repo_root: Path, task_dir: Path
|
|
194
|
+
) -> tuple[Any, list[dict]]:
|
|
195
|
+
"""Public facade for the implement reading list: the task's dev_type plus
|
|
196
|
+
the dispatched spec/skill entries. The MCP task_specs tool and the
|
|
197
|
+
`task specs` CLI fact command both consume this, so the two channels
|
|
198
|
+
cannot drift."""
|
|
199
|
+
from services.context_discovery import implement_spec_entries
|
|
200
|
+
|
|
201
|
+
task = _read_json(task_dir / "task.json")
|
|
202
|
+
dev_type = task.get("dev_type") if isinstance(task, dict) else None
|
|
203
|
+
return dev_type, implement_spec_entries(repo_root, dev_type)
|
|
204
|
+
|
|
205
|
+
|
|
192
206
|
STAGE_CONTRACT_STATES = ("in_progress", "review")
|
|
193
207
|
STAGE_CONTRACT_SCOPE_LIMIT = 8
|
|
194
208
|
STAGE_CONTRACT_SPECS_LIMIT = 4
|
|
@@ -33,10 +33,11 @@ from typing import Iterable
|
|
|
33
33
|
# is kept free of CLI/process plumbing concerns by architecture tests.
|
|
34
34
|
IS_WINDOWS = os.name == "nt"
|
|
35
35
|
|
|
36
|
-
# Editor-phase budget: the zcode/claude PostToolUse hook runs on a 5s hard
|
|
37
|
-
# budget including interpreter startup, so edit-phase commands get
|
|
38
|
-
#
|
|
39
|
-
|
|
36
|
+
# Editor-phase budget: the zcode/claude PostToolUse hook runs on a ~5s hard
|
|
37
|
+
# budget including interpreter startup, so edit-phase commands get 2.5s —
|
|
38
|
+
# leaving room for Python startup plus cmd.exe wrapper overhead on slow
|
|
39
|
+
# machines. Slow or unfiltered commands belong to the lifecycle phase.
|
|
40
|
+
EDIT_PHASE_TIMEOUT = 2.5
|
|
40
41
|
DEFAULT_TIMEOUT = 30.0
|
|
41
42
|
MAX_TIMEOUT = 120.0
|
|
42
43
|
MAX_FIRST_LINE_CHARS = 200
|
|
@@ -215,7 +216,7 @@ def _parse_files(raw: str) -> tuple[tuple[str, ...], list[str]]:
|
|
|
215
216
|
errors: list[str] = []
|
|
216
217
|
matched: list[str] = []
|
|
217
218
|
for part in raw.split(","):
|
|
218
|
-
token = part
|
|
219
|
+
token = _strip_quotes(part).replace("\\", "/")
|
|
219
220
|
while token.startswith("./"):
|
|
220
221
|
token = token[2:]
|
|
221
222
|
if not token:
|
|
@@ -287,12 +288,24 @@ def _first_violation_line(output: Iterable[str]) -> str:
|
|
|
287
288
|
def _run_command(decl: CheckDecl, repo_root: Path) -> tuple[str, int | None, str, list[str]]:
|
|
288
289
|
"""Execute one declaration. Returns (status, exit_code, reason, output)."""
|
|
289
290
|
command = _decl_for_platform(decl)
|
|
291
|
+
tokens: list[str] = []
|
|
290
292
|
if IS_WINDOWS:
|
|
291
293
|
# Windows: newer runtimes refuse to spawn .cmd shims directly
|
|
292
|
-
# (EINVAL)
|
|
293
|
-
#
|
|
294
|
-
|
|
295
|
-
|
|
294
|
+
# (EINVAL), so the command goes through cmd.exe. Passing an argv list
|
|
295
|
+
# makes list2cmdline escape an already-quoted command into `\"...\"`
|
|
296
|
+
# — cmd then strips the outer quotes and fails to parse — so the
|
|
297
|
+
# raw string runs via shell=True, exactly the way npm shims launch.
|
|
298
|
+
# The entry check keeps "missing command" as unchecked instead of a
|
|
299
|
+
# cmd.exe rc=1 masquerading as a violation.
|
|
300
|
+
try:
|
|
301
|
+
tokens = shlex.split(command, posix=False)
|
|
302
|
+
except ValueError:
|
|
303
|
+
tokens = []
|
|
304
|
+
first_token = tokens[0].strip('"') if tokens else ""
|
|
305
|
+
if not first_token or shutil.which(first_token) is None:
|
|
306
|
+
return "unchecked", None, f"command not found: {first_token}", []
|
|
307
|
+
argv: str | list[str] = command
|
|
308
|
+
popen_kwargs: dict = {"shell": True}
|
|
296
309
|
else:
|
|
297
310
|
try:
|
|
298
311
|
argv = shlex.split(command)
|
|
@@ -309,7 +322,11 @@ def _run_command(decl: CheckDecl, repo_root: Path) -> tuple[str, int | None, str
|
|
|
309
322
|
argv,
|
|
310
323
|
cwd=str(repo_root),
|
|
311
324
|
capture_output=True,
|
|
312
|
-
|
|
325
|
+
# Explicit UTF-8 with replacement: command output is process
|
|
326
|
+
# noise (first violation line only), never allowed to crash the
|
|
327
|
+
# reader thread or masquerade as a check outcome.
|
|
328
|
+
encoding="utf-8",
|
|
329
|
+
errors="replace",
|
|
313
330
|
timeout=decl.timeout,
|
|
314
331
|
check=False,
|
|
315
332
|
**popen_kwargs,
|
|
@@ -418,7 +435,9 @@ def normalized_one_line(report: dict) -> str:
|
|
|
418
435
|
# Per-file throttle for editor-phase runs: an edit storm must not multiply
|
|
419
436
|
# the command cost. State lives in the workflow runtime directory because
|
|
420
437
|
# every hook invocation is a fresh process — in-memory maps cannot throttle
|
|
421
|
-
# across invocations.
|
|
438
|
+
# across invocations. The slot is recorded only after a completed run: an
|
|
439
|
+
# executor crash must not consume the interval, so the next edit within it
|
|
440
|
+
# still runs its checks.
|
|
422
441
|
EDIT_THROTTLE_INTERVAL_SECONDS = 10.0
|
|
423
442
|
EDIT_THROTTLE_FILE = ".cowork-flow" / Path(".runtime") / "spec-edit-throttle.json"
|
|
424
443
|
|
|
@@ -481,11 +500,14 @@ def run_edit_checks(
|
|
|
481
500
|
and current - state["ts"] < EDIT_THROTTLE_INTERVAL_SECONDS
|
|
482
501
|
):
|
|
483
502
|
return ""
|
|
484
|
-
_write_throttle(repo_root, normalized, current)
|
|
485
503
|
try:
|
|
486
504
|
report = run_checks(repo_root, phase="edit", changed_files=[normalized])
|
|
487
505
|
except Exception:
|
|
506
|
+
# Executor crash (not a check failure): leave the throttle untouched
|
|
507
|
+
# so a retry within the interval is not silently swallowed.
|
|
488
508
|
return ""
|
|
509
|
+
if throttled:
|
|
510
|
+
_write_throttle(repo_root, normalized, time.time() if now is None else now)
|
|
489
511
|
return normalized_one_line(report)
|
|
490
512
|
|
|
491
513
|
|