cowork-flow 1.9.2 → 2.0.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 +23 -1
- package/docs/architecture.md +1 -34
- package/docs/direction.md +1 -1
- package/docs/hosts.md +13 -9
- package/package.json +1 -1
- package/presets/claude-code/.claude-plugin/plugin.json +1 -1
- package/presets/codex/.codex-plugin/plugin.json +1 -1
- package/presets/dsh/plugins/workflow-state.js +31 -16
- package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
- package/presets/opencode/cowork-flow/plugin-core.js +42 -87
- package/presets/opencode/plugins/cowork-flow.js +7 -9
- package/presets/qoder/.qoder-plugin/plugin.json +1 -1
- package/presets/qoder/hooks/hooks.json +1 -1
- package/presets/zcode/.zcode-plugin/plugin.json +1 -1
- package/src/commands/host.js +23 -2
- package/src/commands/registry.js +4 -2
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/adapters/claude-code/adapter.yaml +1 -1
- package/template/.cowork-flow/adapters/codex/adapter.yaml +1 -1
- package/template/.cowork-flow/adapters/dsh/adapter.yaml +1 -1
- package/template/.cowork-flow/adapters/opencode/adapter.yaml +1 -1
- package/template/.cowork-flow/adapters/qoder/adapter.yaml +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +0 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +0 -7
- package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +6 -4
- package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +8 -6
- package/template/.cowork-flow/scripts/adapters/host/edit_scope.py +113 -0
- package/template/.cowork-flow/scripts/adapters/host/opencode_policy.py +12 -0
- package/template/.cowork-flow/scripts/adapters/host/qoder_policy.py +7 -5
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +0 -91
- package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +15 -77
- package/template/.cowork-flow/scripts/run.py +0 -2
- package/template/.cowork-flow/scripts/services/context_paths.py +2 -2
- package/template/.cowork-flow/scripts/services/fact_view.py +49 -21
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +1 -216
- package/template/.cowork-flow/scripts/services/lifecycle_policy.py +0 -5
- package/template/.cowork-flow/scripts/services/task_gate_profiles.py +1 -1
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +14 -31
- package/template/.cowork-flow/spec/backend/error-handling.md +0 -14
- package/template/.cowork-flow/spec/contracts/context-injection.md +49 -22
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +3 -3
- package/template/.cowork-flow/spec/contracts/error-codes.md +1 -3
- package/template/.cowork-flow/spec/contracts/evidence-completion.md +6 -8
- package/template/.cowork-flow/spec/contracts/index.md +0 -1
- package/template/.cowork-flow/spec/contracts/plan-binding.md +3 -2
- package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +6 -21
- package/template/.cowork-flow/spec/contracts/task-adopt.md +1 -1
- package/template/.cowork-flow/spec/runtime/contract-registry.json +0 -13
- package/template/.cowork-flow/spec/runtime/host-assets.json +10 -7
- package/template/.cowork-flow/spec/runtime/scope-rules.json +2 -2
- package/template/.opencode/cowork-flow/plugin-core.js +42 -87
- package/template/.opencode/plugins/cowork-flow.js +7 -9
- package/template/AGENTS.md +0 -1
- package/template/skills/agent-dispatch/SKILL.md +0 -15
- package/template/skills/runtime-health/scripts/doctor.py +0 -49
- package/template/skills/spec-sync/SKILL.md +0 -1
- package/template/skills/task-planning/SKILL.md +1 -1
- package/template/skills/task-review/SKILL.md +3 -3
- package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +0 -178
- package/template/.cowork-flow/scripts/services/spec_check.py +0 -573
- package/template/.cowork-flow/spec/contracts/spec-checks.md +0 -281
|
@@ -1,573 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
"""Spec check executor: user-declared checks in spec frontmatter.
|
|
4
|
-
|
|
5
|
-
Mechanism consumes declarations, never spec prose. Each spec file may carry a
|
|
6
|
-
`checks:` list in its YAML frontmatter; this module parses that minimal subset,
|
|
7
|
-
matches declarations against changed files (directory-prefix or extension
|
|
8
|
-
forms only), and executes the commands with a three-state outcome:
|
|
9
|
-
|
|
10
|
-
- ``pass`` exit code 0
|
|
11
|
-
- ``violation`` exit code != 0
|
|
12
|
-
- ``unchecked`` command missing / interpreter missing / timeout — never
|
|
13
|
-
reported as pass (lifecycle gates treat it as its own blocking state)
|
|
14
|
-
|
|
15
|
-
Editor-phase runs are clamped to a short timeout and only touch declarations
|
|
16
|
-
whose ``files`` match the edited path; slow or unfiltered commands belong to
|
|
17
|
-
the lifecycle phase. Output passed back to agents is normalized to one line;
|
|
18
|
-
full command output is only shown on an explicit ``--file`` query.
|
|
19
|
-
|
|
20
|
-
Every report also carries a one-word ``summary.classification`` (see
|
|
21
|
-
``classify_spec_check_result``), so "nothing declared", "declared but nothing
|
|
22
|
-
ran in this phase", "parse failure", "executor failure" and "unchecked" are
|
|
23
|
-
distinct queryable facts instead of one grey not-passing state. Reports
|
|
24
|
-
written before this field existed read back as ``legacy`` through
|
|
25
|
-
``report_classification`` rather than being guessed at.
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
from __future__ import annotations
|
|
29
|
-
|
|
30
|
-
import json
|
|
31
|
-
import os
|
|
32
|
-
import shlex
|
|
33
|
-
import shutil
|
|
34
|
-
import subprocess
|
|
35
|
-
from dataclasses import dataclass, field
|
|
36
|
-
from pathlib import Path
|
|
37
|
-
from typing import Iterable
|
|
38
|
-
|
|
39
|
-
# os.name keeps this layer free of a sys import.
|
|
40
|
-
IS_WINDOWS = os.name == "nt"
|
|
41
|
-
|
|
42
|
-
# Edit phase gets 2.5s (hook budget ~5s incl. startup);
|
|
43
|
-
# slow checks belong to the lifecycle phase.
|
|
44
|
-
EDIT_PHASE_TIMEOUT = 2.5
|
|
45
|
-
DEFAULT_TIMEOUT = 30.0
|
|
46
|
-
MAX_TIMEOUT = 120.0
|
|
47
|
-
MAX_FIRST_LINE_CHARS = 200
|
|
48
|
-
|
|
49
|
-
# Machine-owned subtrees; user checks never scan these.
|
|
50
|
-
EXCLUDED_SPEC_SUBDIRS = ("contracts", "runtime", "schemas")
|
|
51
|
-
|
|
52
|
-
SCHEMA_VERSION = 1
|
|
53
|
-
|
|
54
|
-
# One word per distinct outcome, most severe first. `empty` is the legal
|
|
55
|
-
# "declarations exist but none ran in this phase" case (an edit-only
|
|
56
|
-
# declaration under a lifecycle run); `legacy` is the read-back value for a
|
|
57
|
-
# report written before this field existed, never a produced outcome.
|
|
58
|
-
CLASSIFICATIONS = (
|
|
59
|
-
"parse-error",
|
|
60
|
-
"executor-failure",
|
|
61
|
-
"zero-declaration",
|
|
62
|
-
"violation",
|
|
63
|
-
"unchecked",
|
|
64
|
-
"pass",
|
|
65
|
-
"empty",
|
|
66
|
-
)
|
|
67
|
-
LEGACY_CLASSIFICATION = "legacy"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
class SpecCheckError(Exception):
|
|
71
|
-
"""Raised for structural failures that callers must surface."""
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
@dataclass(frozen=True)
|
|
75
|
-
class CheckDecl:
|
|
76
|
-
"""One user-declared check from a spec frontmatter block."""
|
|
77
|
-
|
|
78
|
-
cmd: str
|
|
79
|
-
files: tuple[str, ...] = ()
|
|
80
|
-
timeout: float = DEFAULT_TIMEOUT
|
|
81
|
-
when: str = "both"
|
|
82
|
-
cmd_win: str | None = None
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
@dataclass(frozen=True)
|
|
86
|
-
class SpecDeclarations:
|
|
87
|
-
spec: str
|
|
88
|
-
decls: tuple[CheckDecl, ...] = ()
|
|
89
|
-
errors: tuple[str, ...] = ()
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
@dataclass
|
|
93
|
-
class CheckResult:
|
|
94
|
-
spec: str
|
|
95
|
-
cmd: str
|
|
96
|
-
status: str # pass | violation | unchecked
|
|
97
|
-
exit_code: int | None = None
|
|
98
|
-
first_violation_line: str = ""
|
|
99
|
-
reason: str = ""
|
|
100
|
-
output: list[str] = field(default_factory=list)
|
|
101
|
-
|
|
102
|
-
def to_json(self) -> dict:
|
|
103
|
-
payload = {
|
|
104
|
-
"spec": self.spec,
|
|
105
|
-
"cmd": self.cmd,
|
|
106
|
-
"status": self.status,
|
|
107
|
-
}
|
|
108
|
-
if self.exit_code is not None:
|
|
109
|
-
payload["exitCode"] = self.exit_code
|
|
110
|
-
if self.first_violation_line:
|
|
111
|
-
payload["firstViolationLine"] = self.first_violation_line
|
|
112
|
-
if self.reason:
|
|
113
|
-
payload["reason"] = self.reason
|
|
114
|
-
return payload
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _strip_quotes(value: str) -> str:
|
|
118
|
-
value = value.strip()
|
|
119
|
-
# Unwrap only true single-pair quoting ("src/").
|
|
120
|
-
# "python" -c "..." quotes twice; pass that value intact.
|
|
121
|
-
for quote in ("\"", "'"):
|
|
122
|
-
if (
|
|
123
|
-
len(value) >= 2
|
|
124
|
-
and value[0] == quote
|
|
125
|
-
and value[-1] == quote
|
|
126
|
-
and value.count(quote) == 2
|
|
127
|
-
):
|
|
128
|
-
return value[1:-1].strip()
|
|
129
|
-
return value
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def parse_frontmatter_checks(text: str) -> tuple[list[CheckDecl], list[str]]:
|
|
133
|
-
"""Parse the minimal frontmatter subset this contract supports.
|
|
134
|
-
|
|
135
|
-
Returns (declarations, errors). A missing frontmatter block or a block
|
|
136
|
-
without ``checks:`` is not an error; malformed content inside a declared
|
|
137
|
-
block is, so doctor can point at the offending spec.
|
|
138
|
-
"""
|
|
139
|
-
lines = text.splitlines()
|
|
140
|
-
if not lines or lines[0].strip() != "---":
|
|
141
|
-
return [], []
|
|
142
|
-
end = None
|
|
143
|
-
for index in range(1, len(lines)):
|
|
144
|
-
if lines[index].strip() == "---":
|
|
145
|
-
end = index
|
|
146
|
-
break
|
|
147
|
-
if end is None:
|
|
148
|
-
return [], ["frontmatter block not closed"]
|
|
149
|
-
|
|
150
|
-
decls: list[CheckDecl] = []
|
|
151
|
-
errors: list[str] = []
|
|
152
|
-
in_checks = False
|
|
153
|
-
current: dict[str, str] | None = None
|
|
154
|
-
|
|
155
|
-
for raw in lines[1:end]:
|
|
156
|
-
stripped = raw.strip()
|
|
157
|
-
if not stripped:
|
|
158
|
-
continue
|
|
159
|
-
if not in_checks:
|
|
160
|
-
if stripped == "checks:":
|
|
161
|
-
in_checks = True
|
|
162
|
-
elif stripped.endswith(":") and not stripped.startswith("#"):
|
|
163
|
-
# Another frontmatter key; ignore its body silently.
|
|
164
|
-
in_checks = False
|
|
165
|
-
continue
|
|
166
|
-
if stripped.startswith("- "):
|
|
167
|
-
if current is not None:
|
|
168
|
-
parsed = _decl_from_pairs(current, errors)
|
|
169
|
-
if parsed is not None:
|
|
170
|
-
decls.append(parsed)
|
|
171
|
-
current = {}
|
|
172
|
-
stripped = stripped[2:].strip()
|
|
173
|
-
if not stripped:
|
|
174
|
-
continue
|
|
175
|
-
if current is None:
|
|
176
|
-
errors.append(f"unexpected line outside a check item: {stripped!r}")
|
|
177
|
-
in_checks = False
|
|
178
|
-
continue
|
|
179
|
-
if ":" not in stripped:
|
|
180
|
-
errors.append(f"malformed check line: {stripped!r}")
|
|
181
|
-
current = None
|
|
182
|
-
in_checks = False
|
|
183
|
-
continue
|
|
184
|
-
key, _, value = stripped.partition(":")
|
|
185
|
-
key = key.strip()
|
|
186
|
-
if not key:
|
|
187
|
-
errors.append(f"malformed check line: {stripped!r}")
|
|
188
|
-
continue
|
|
189
|
-
current[key] = _strip_quotes(value)
|
|
190
|
-
parsed = _decl_from_pairs(current, errors)
|
|
191
|
-
if parsed is not None:
|
|
192
|
-
decls.append(parsed)
|
|
193
|
-
return decls, errors
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def _decl_from_pairs(pairs: dict[str, str], errors: list[str]) -> CheckDecl | None:
|
|
197
|
-
cmd = pairs.pop("cmd", "")
|
|
198
|
-
if not cmd:
|
|
199
|
-
errors.append("check item without cmd")
|
|
200
|
-
return None
|
|
201
|
-
when = pairs.pop("when", "both") or "both"
|
|
202
|
-
if when not in {"edit", "lifecycle", "both"}:
|
|
203
|
-
errors.append(f"unknown when value: {when!r}")
|
|
204
|
-
when = "both"
|
|
205
|
-
timeout_raw = pairs.pop("timeout", "")
|
|
206
|
-
try:
|
|
207
|
-
timeout = float(timeout_raw) if timeout_raw else DEFAULT_TIMEOUT
|
|
208
|
-
except ValueError:
|
|
209
|
-
errors.append(f"non-numeric timeout: {timeout_raw!r}")
|
|
210
|
-
timeout = DEFAULT_TIMEOUT
|
|
211
|
-
timeout = min(max(timeout, 0.5), MAX_TIMEOUT)
|
|
212
|
-
files_raw = pairs.pop("files", "")
|
|
213
|
-
files, file_errors = _parse_files(files_raw)
|
|
214
|
-
errors.extend(file_errors)
|
|
215
|
-
if file_errors:
|
|
216
|
-
# Broken files filter: drop the declaration (never widen to
|
|
217
|
-
# "matches everything"); doctor surfaces it.
|
|
218
|
-
return None
|
|
219
|
-
cmd_win = pairs.pop("cmd.win", None)
|
|
220
|
-
unknown = sorted(pairs)
|
|
221
|
-
if unknown:
|
|
222
|
-
errors.append(f"unknown check keys ignored: {', '.join(unknown)}")
|
|
223
|
-
return CheckDecl(
|
|
224
|
-
cmd=cmd,
|
|
225
|
-
files=files,
|
|
226
|
-
timeout=timeout,
|
|
227
|
-
when=when,
|
|
228
|
-
cmd_win=cmd_win,
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def _parse_files(raw: str) -> tuple[tuple[str, ...], list[str]]:
|
|
233
|
-
if not raw:
|
|
234
|
-
return (), []
|
|
235
|
-
errors: list[str] = []
|
|
236
|
-
matched: list[str] = []
|
|
237
|
-
for part in raw.split(","):
|
|
238
|
-
token = _strip_quotes(part).replace("\\", "/")
|
|
239
|
-
while token.startswith("./"):
|
|
240
|
-
token = token[2:]
|
|
241
|
-
if not token:
|
|
242
|
-
continue
|
|
243
|
-
if token.endswith("/**"):
|
|
244
|
-
token = token[:-2]
|
|
245
|
-
if token.startswith("*."):
|
|
246
|
-
if "*" in token[2:] or "?" in token:
|
|
247
|
-
errors.append(f"unsupported files form (use dir/ or *.ext): {token!r}")
|
|
248
|
-
continue
|
|
249
|
-
matched.append(token)
|
|
250
|
-
continue
|
|
251
|
-
if "*" in token or "?" in token or "[" in token:
|
|
252
|
-
errors.append(f"unsupported files form (use dir/ or *.ext): {token!r}")
|
|
253
|
-
continue
|
|
254
|
-
matched.append(token if token.endswith("/") else f"{token}/")
|
|
255
|
-
return tuple(matched), errors
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def path_matches_decl_files(relative_path: str, decl: CheckDecl) -> bool:
|
|
259
|
-
if not decl.files:
|
|
260
|
-
return True
|
|
261
|
-
normalized = relative_path.replace("\\", "/")
|
|
262
|
-
for token in decl.files:
|
|
263
|
-
if token.startswith("*."):
|
|
264
|
-
if normalized.endswith(token[1:]):
|
|
265
|
-
return True
|
|
266
|
-
elif normalized.startswith(token):
|
|
267
|
-
return True
|
|
268
|
-
return False
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def collect_spec_declarations(repo_root: Path) -> list[SpecDeclarations]:
|
|
272
|
-
spec_dir = repo_root / ".cowork-flow" / "spec"
|
|
273
|
-
if not spec_dir.is_dir():
|
|
274
|
-
return []
|
|
275
|
-
collected: list[SpecDeclarations] = []
|
|
276
|
-
for path in sorted(spec_dir.rglob("*.md")):
|
|
277
|
-
relative = path.relative_to(spec_dir).as_posix()
|
|
278
|
-
parts = Path(relative).parts
|
|
279
|
-
if parts and parts[0] in EXCLUDED_SPEC_SUBDIRS:
|
|
280
|
-
continue
|
|
281
|
-
try:
|
|
282
|
-
text = path.read_text(encoding="utf-8")
|
|
283
|
-
except OSError:
|
|
284
|
-
collected.append(
|
|
285
|
-
SpecDeclarations(relative, (), (f"unreadable spec file: {relative}",))
|
|
286
|
-
)
|
|
287
|
-
continue
|
|
288
|
-
decls, errors = parse_frontmatter_checks(text)
|
|
289
|
-
collected.append(SpecDeclarations(relative, tuple(decls), tuple(errors)))
|
|
290
|
-
return collected
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def _decl_for_platform(decl: CheckDecl) -> str:
|
|
294
|
-
if IS_WINDOWS and decl.cmd_win:
|
|
295
|
-
return decl.cmd_win
|
|
296
|
-
return decl.cmd
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def _first_violation_line(output: Iterable[str]) -> str:
|
|
300
|
-
for line in output:
|
|
301
|
-
line = line.strip()
|
|
302
|
-
if line:
|
|
303
|
-
return line[:MAX_FIRST_LINE_CHARS]
|
|
304
|
-
return ""
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
def _run_command(decl: CheckDecl, repo_root: Path) -> tuple[str, int | None, str, list[str]]:
|
|
308
|
-
"""Execute one declaration. Returns (status, exit_code, reason, output)."""
|
|
309
|
-
command = _decl_for_platform(decl)
|
|
310
|
-
tokens: list[str] = []
|
|
311
|
-
if IS_WINDOWS:
|
|
312
|
-
# Windows: .cmd shims need shell=True (a direct spawn fails
|
|
313
|
-
# with EINVAL); an argv list double-escapes the quotes.
|
|
314
|
-
# A missing command stays "unchecked" instead of a
|
|
315
|
-
# cmd.exe rc=1 masquerading as a violation.
|
|
316
|
-
try:
|
|
317
|
-
tokens = shlex.split(command, posix=False)
|
|
318
|
-
except ValueError:
|
|
319
|
-
tokens = []
|
|
320
|
-
first_token = tokens[0].strip('"') if tokens else ""
|
|
321
|
-
if not first_token or shutil.which(first_token) is None:
|
|
322
|
-
return "unchecked", None, f"command not found: {first_token}", []
|
|
323
|
-
argv: str | list[str] = command
|
|
324
|
-
popen_kwargs: dict = {"shell": True}
|
|
325
|
-
else:
|
|
326
|
-
try:
|
|
327
|
-
argv = shlex.split(command)
|
|
328
|
-
except ValueError as error:
|
|
329
|
-
return "unchecked", None, f"unparsable command: {error}", []
|
|
330
|
-
if not argv:
|
|
331
|
-
return "unchecked", None, "empty command", []
|
|
332
|
-
resolved = shutil.which(argv[0])
|
|
333
|
-
if resolved is None:
|
|
334
|
-
return "unchecked", None, f"command not found: {argv[0]}", []
|
|
335
|
-
popen_kwargs = {}
|
|
336
|
-
try:
|
|
337
|
-
completed = subprocess.run(
|
|
338
|
-
argv,
|
|
339
|
-
cwd=str(repo_root),
|
|
340
|
-
capture_output=True,
|
|
341
|
-
# UTF-8 with replacement: command output is noise and must
|
|
342
|
-
# never crash the reader or read as a check outcome.
|
|
343
|
-
encoding="utf-8",
|
|
344
|
-
errors="replace",
|
|
345
|
-
timeout=decl.timeout,
|
|
346
|
-
check=False,
|
|
347
|
-
**popen_kwargs,
|
|
348
|
-
)
|
|
349
|
-
except subprocess.TimeoutExpired:
|
|
350
|
-
return "unchecked", None, f"timed out after {decl.timeout:g}s", []
|
|
351
|
-
except OSError as error:
|
|
352
|
-
# Degraded environments (PATH stripped) are unchecked,
|
|
353
|
-
# never a passing check.
|
|
354
|
-
return "unchecked", None, f"failed to launch: {error}", []
|
|
355
|
-
|
|
356
|
-
merged = list(completed.stdout.splitlines()) + list(completed.stderr.splitlines())
|
|
357
|
-
if completed.returncode == 0:
|
|
358
|
-
return "pass", 0, "", []
|
|
359
|
-
return "violation", completed.returncode, "", merged
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
def run_checks(
|
|
363
|
-
repo_root: Path,
|
|
364
|
-
*,
|
|
365
|
-
phase: str,
|
|
366
|
-
changed_files: Iterable[str] | None = None,
|
|
367
|
-
file_filter: str | None = None,
|
|
368
|
-
) -> dict:
|
|
369
|
-
"""Run declared checks for one phase and return a JSON-ready report.
|
|
370
|
-
|
|
371
|
-
phase: "edit" runs only edit-eligible declarations whose ``files`` match
|
|
372
|
-
``changed_files`` (2.5s clamp); "lifecycle" runs every declaration honoring
|
|
373
|
-
each ``files`` scope. Parse errors are reported but never block as
|
|
374
|
-
violations — they surface through doctor.
|
|
375
|
-
"""
|
|
376
|
-
if phase not in {"edit", "lifecycle"}:
|
|
377
|
-
raise SpecCheckError(f"unknown phase: {phase!r}")
|
|
378
|
-
changed = [c.replace("\\", "/") for c in (changed_files or [])]
|
|
379
|
-
if file_filter:
|
|
380
|
-
changed = [file_filter.replace("\\", "/")]
|
|
381
|
-
|
|
382
|
-
collected = collect_spec_declarations(repo_root)
|
|
383
|
-
# Repository-wide declaration count, phase-independent: "no checks
|
|
384
|
-
# declared" is a property of the repo, not of this phase's result list
|
|
385
|
-
# (a lifecycle run skips every edit-only declaration and would otherwise
|
|
386
|
-
# read as "nothing declared").
|
|
387
|
-
declarations_total = sum(len(spec_decls.decls) for spec_decls in collected)
|
|
388
|
-
|
|
389
|
-
results: list[CheckResult] = []
|
|
390
|
-
parse_errors: list[dict[str, str]] = []
|
|
391
|
-
for spec_decls in collected:
|
|
392
|
-
for message in spec_decls.errors:
|
|
393
|
-
parse_errors.append({"spec": spec_decls.spec, "error": message})
|
|
394
|
-
for decl in spec_decls.decls:
|
|
395
|
-
if phase == "edit":
|
|
396
|
-
if decl.when == "lifecycle":
|
|
397
|
-
continue
|
|
398
|
-
if not changed:
|
|
399
|
-
continue
|
|
400
|
-
if not any(
|
|
401
|
-
path_matches_decl_files(path, decl) for path in changed
|
|
402
|
-
):
|
|
403
|
-
continue
|
|
404
|
-
effective = CheckDecl(
|
|
405
|
-
cmd=decl.cmd,
|
|
406
|
-
files=decl.files,
|
|
407
|
-
timeout=min(decl.timeout, EDIT_PHASE_TIMEOUT),
|
|
408
|
-
when=decl.when,
|
|
409
|
-
cmd_win=decl.cmd_win,
|
|
410
|
-
)
|
|
411
|
-
else:
|
|
412
|
-
if decl.when == "edit":
|
|
413
|
-
continue
|
|
414
|
-
effective = decl
|
|
415
|
-
status, exit_code, reason, output = _run_command(effective, repo_root)
|
|
416
|
-
result = CheckResult(
|
|
417
|
-
spec=spec_decls.spec,
|
|
418
|
-
cmd=_decl_for_platform(effective),
|
|
419
|
-
status=status,
|
|
420
|
-
exit_code=exit_code,
|
|
421
|
-
first_violation_line=_first_violation_line(output) if output else "",
|
|
422
|
-
reason=reason,
|
|
423
|
-
)
|
|
424
|
-
if output:
|
|
425
|
-
result.output = output[:20]
|
|
426
|
-
results.append(result)
|
|
427
|
-
|
|
428
|
-
summary = {
|
|
429
|
-
"pass": sum(1 for r in results if r.status == "pass"),
|
|
430
|
-
"violation": sum(1 for r in results if r.status == "violation"),
|
|
431
|
-
"unchecked": sum(1 for r in results if r.status == "unchecked"),
|
|
432
|
-
"declarations": declarations_total,
|
|
433
|
-
}
|
|
434
|
-
summary["classification"] = classify_spec_check_result(summary, parse_errors)
|
|
435
|
-
return {
|
|
436
|
-
"schemaVersion": SCHEMA_VERSION,
|
|
437
|
-
"phase": phase,
|
|
438
|
-
"changedFiles": changed,
|
|
439
|
-
"declarations": declarations_total,
|
|
440
|
-
"specFiles": len(collected),
|
|
441
|
-
"results": [r.to_json() for r in results],
|
|
442
|
-
"parseErrors": parse_errors,
|
|
443
|
-
"summary": summary,
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
def classify_spec_check_result(summary: dict, parse_errors: list) -> str:
|
|
448
|
-
"""The report's one-word outcome, ordered by severity.
|
|
449
|
-
|
|
450
|
-
Parse failures and a failed executor outrank the counters: both mean the
|
|
451
|
-
checks did not produce a usable verdict, so neither may be read as a
|
|
452
|
-
benign "nothing to check" state.
|
|
453
|
-
"""
|
|
454
|
-
if parse_errors:
|
|
455
|
-
return "parse-error"
|
|
456
|
-
summary = summary or {}
|
|
457
|
-
if int(summary.get("executorError") or 0):
|
|
458
|
-
return "executor-failure"
|
|
459
|
-
if int(summary.get("declarations") or 0) == 0:
|
|
460
|
-
return "zero-declaration"
|
|
461
|
-
if int(summary.get("violation") or 0):
|
|
462
|
-
return "violation"
|
|
463
|
-
if int(summary.get("unchecked") or 0):
|
|
464
|
-
return "unchecked"
|
|
465
|
-
if int(summary.get("pass") or 0):
|
|
466
|
-
return "pass"
|
|
467
|
-
return "empty"
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
def report_classification(report: dict | None) -> str:
|
|
471
|
-
"""Read a report's classification.
|
|
472
|
-
|
|
473
|
-
A report written before the field existed (or an unrecognized value) reads
|
|
474
|
-
back as ``legacy``: absence is its own fact, and re-deriving a verdict
|
|
475
|
-
from the counters would silently upgrade an old report into a guess.
|
|
476
|
-
"""
|
|
477
|
-
summary = (report or {}).get("summary") or {}
|
|
478
|
-
value = summary.get("classification")
|
|
479
|
-
return value if value in CLASSIFICATIONS else LEGACY_CLASSIFICATION
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
def normalized_one_line(report: dict) -> str:
|
|
483
|
-
"""Single-line summary for hook injection; never multi-line output."""
|
|
484
|
-
results = report.get("results") or []
|
|
485
|
-
for item in results:
|
|
486
|
-
if item.get("status") == "violation":
|
|
487
|
-
spec = item.get("spec", "?")
|
|
488
|
-
line = item.get("firstViolationLine") or item.get("reason") or ""
|
|
489
|
-
return f"spec-check[{spec}] violation: {line}"
|
|
490
|
-
for item in results:
|
|
491
|
-
if item.get("status") == "unchecked":
|
|
492
|
-
return f"spec-check[{item.get('spec', '?')}] unchecked: {item.get('reason', '')}"
|
|
493
|
-
return ""
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
# Per-file throttle for editor-phase runs: an edit storm
|
|
497
|
-
# must not multiply command cost. State is a file (each
|
|
498
|
-
# hook call is a new process). The slot is written after a
|
|
499
|
-
# completed run, never on a crash.
|
|
500
|
-
EDIT_THROTTLE_INTERVAL_SECONDS = 10.0
|
|
501
|
-
EDIT_THROTTLE_FILE = ".cowork-flow" / Path(".runtime") / "spec-edit-throttle.json"
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
def _read_throttle(root: Path) -> dict:
|
|
505
|
-
try:
|
|
506
|
-
data = json.loads((root / EDIT_THROTTLE_FILE).read_text(encoding="utf-8"))
|
|
507
|
-
except (OSError, json.JSONDecodeError, ValueError):
|
|
508
|
-
return {}
|
|
509
|
-
return data if isinstance(data, dict) else {}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
def _write_throttle(root: Path, file_path: str, timestamp: float) -> None:
|
|
513
|
-
path = root / EDIT_THROTTLE_FILE
|
|
514
|
-
try:
|
|
515
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
516
|
-
path.write_text(
|
|
517
|
-
json.dumps({"file": file_path, "ts": timestamp}, ensure_ascii=False)
|
|
518
|
-
+ "\n",
|
|
519
|
-
encoding="utf-8",
|
|
520
|
-
)
|
|
521
|
-
except OSError:
|
|
522
|
-
# Best-effort: a failed write means the next edit runs.
|
|
523
|
-
pass
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
def run_edit_checks(
|
|
527
|
-
repo_root: Path,
|
|
528
|
-
file_path: str,
|
|
529
|
-
*,
|
|
530
|
-
throttled: bool = True,
|
|
531
|
-
now: float | None = None,
|
|
532
|
-
) -> str:
|
|
533
|
-
"""Editor-phase single-line result for one changed file.
|
|
534
|
-
|
|
535
|
-
Empty string means "nothing to report": no declaration matched, the file
|
|
536
|
-
was throttled, or checks passed. Violations and unchecked degradations
|
|
537
|
-
return the normalized one-line summary. Never raises for expected
|
|
538
|
-
operating conditions — the editor path must not break edits.
|
|
539
|
-
"""
|
|
540
|
-
import time
|
|
541
|
-
|
|
542
|
-
normalized = file_path.replace("\\", "/").strip()
|
|
543
|
-
if not normalized:
|
|
544
|
-
return ""
|
|
545
|
-
# Hosts pass absolute paths; filters are repo-relative.
|
|
546
|
-
try:
|
|
547
|
-
normalized = (
|
|
548
|
-
Path(normalized).resolve().relative_to(repo_root.resolve()).as_posix()
|
|
549
|
-
)
|
|
550
|
-
except (ValueError, OSError):
|
|
551
|
-
pass
|
|
552
|
-
if throttled:
|
|
553
|
-
state = _read_throttle(repo_root)
|
|
554
|
-
current = time.time() if now is None else now
|
|
555
|
-
if (
|
|
556
|
-
state.get("file") == normalized
|
|
557
|
-
and isinstance(state.get("ts"), (int, float))
|
|
558
|
-
and current - state["ts"] < EDIT_THROTTLE_INTERVAL_SECONDS
|
|
559
|
-
):
|
|
560
|
-
return ""
|
|
561
|
-
try:
|
|
562
|
-
report = run_checks(repo_root, phase="edit", changed_files=[normalized])
|
|
563
|
-
except Exception:
|
|
564
|
-
# Executor crash, not a check failure: leave the throttle
|
|
565
|
-
# untouched so a retry inside the interval still runs.
|
|
566
|
-
return ""
|
|
567
|
-
if throttled:
|
|
568
|
-
_write_throttle(repo_root, normalized, time.time() if now is None else now)
|
|
569
|
-
return normalized_one_line(report)
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
def summary_has(report: dict, key: str) -> bool:
|
|
573
|
-
return bool((report.get("summary") or {}).get(key))
|