cowork-flow 1.1.3 → 1.2.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 +21 -0
- package/package.json +1 -1
- package/src/commands/mcp-state.js +3 -0
- package/template/.claude/hooks/inject-workflow-state.py +15 -2
- package/template/.claude/settings.json +12 -0
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +121 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +1 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +7 -0
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +35 -0
- package/template/.cowork-flow/scripts/run.py +1 -0
- package/template/.cowork-flow/scripts/services/fact_view.py +55 -2
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +75 -2
- package/template/.cowork-flow/scripts/services/lifecycle_policy.py +9 -2
- package/template/.cowork-flow/scripts/services/spec_check.py +493 -0
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +33 -1
- package/template/.cowork-flow/spec/backend/error-handling.md +14 -0
- package/template/.cowork-flow/spec/contracts/index.md +1 -0
- package/template/.cowork-flow/spec/contracts/spec-checks.md +74 -0
- package/template/.opencode/plugins/cowork-flow.js +43 -1
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/inject-context.js +124 -6
- package/template/skills/runtime-health/scripts/doctor.py +49 -0
|
@@ -0,0 +1,493 @@
|
|
|
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
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import shlex
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Iterable
|
|
31
|
+
|
|
32
|
+
# os.name == "nt" covers Windows without importing sys: the services layer
|
|
33
|
+
# is kept free of CLI/process plumbing concerns by architecture tests.
|
|
34
|
+
IS_WINDOWS = os.name == "nt"
|
|
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 3s and
|
|
38
|
+
# nothing more.
|
|
39
|
+
EDIT_PHASE_TIMEOUT = 3.0
|
|
40
|
+
DEFAULT_TIMEOUT = 30.0
|
|
41
|
+
MAX_TIMEOUT = 120.0
|
|
42
|
+
MAX_FIRST_LINE_CHARS = 200
|
|
43
|
+
|
|
44
|
+
# Machine-owned spec subtrees; user-declared checks never scan these.
|
|
45
|
+
EXCLUDED_SPEC_SUBDIRS = ("contracts", "runtime", "schemas")
|
|
46
|
+
|
|
47
|
+
SCHEMA_VERSION = 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SpecCheckError(Exception):
|
|
51
|
+
"""Raised for structural failures that callers must surface."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class CheckDecl:
|
|
56
|
+
"""One user-declared check from a spec frontmatter block."""
|
|
57
|
+
|
|
58
|
+
cmd: str
|
|
59
|
+
files: tuple[str, ...] = ()
|
|
60
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
61
|
+
when: str = "both"
|
|
62
|
+
cmd_win: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class SpecDeclarations:
|
|
67
|
+
spec: str
|
|
68
|
+
decls: tuple[CheckDecl, ...] = ()
|
|
69
|
+
errors: tuple[str, ...] = ()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class CheckResult:
|
|
74
|
+
spec: str
|
|
75
|
+
cmd: str
|
|
76
|
+
status: str # pass | violation | unchecked
|
|
77
|
+
exit_code: int | None = None
|
|
78
|
+
first_violation_line: str = ""
|
|
79
|
+
reason: str = ""
|
|
80
|
+
output: list[str] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
def to_json(self) -> dict:
|
|
83
|
+
payload = {
|
|
84
|
+
"spec": self.spec,
|
|
85
|
+
"cmd": self.cmd,
|
|
86
|
+
"status": self.status,
|
|
87
|
+
}
|
|
88
|
+
if self.exit_code is not None:
|
|
89
|
+
payload["exitCode"] = self.exit_code
|
|
90
|
+
if self.first_violation_line:
|
|
91
|
+
payload["firstViolationLine"] = self.first_violation_line
|
|
92
|
+
if self.reason:
|
|
93
|
+
payload["reason"] = self.reason
|
|
94
|
+
return payload
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _strip_quotes(value: str) -> str:
|
|
98
|
+
value = value.strip()
|
|
99
|
+
# Only unwrap a true single-pair quoting ("src/"); a command value like
|
|
100
|
+
# "python" -c "..." quotes twice and must pass through intact.
|
|
101
|
+
for quote in ("\"", "'"):
|
|
102
|
+
if (
|
|
103
|
+
len(value) >= 2
|
|
104
|
+
and value[0] == quote
|
|
105
|
+
and value[-1] == quote
|
|
106
|
+
and value.count(quote) == 2
|
|
107
|
+
):
|
|
108
|
+
return value[1:-1].strip()
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_frontmatter_checks(text: str) -> tuple[list[CheckDecl], list[str]]:
|
|
113
|
+
"""Parse the minimal frontmatter subset this contract supports.
|
|
114
|
+
|
|
115
|
+
Returns (declarations, errors). A missing frontmatter block or a block
|
|
116
|
+
without ``checks:`` is not an error; malformed content inside a declared
|
|
117
|
+
block is, so doctor can point at the offending spec.
|
|
118
|
+
"""
|
|
119
|
+
lines = text.splitlines()
|
|
120
|
+
if not lines or lines[0].strip() != "---":
|
|
121
|
+
return [], []
|
|
122
|
+
end = None
|
|
123
|
+
for index in range(1, len(lines)):
|
|
124
|
+
if lines[index].strip() == "---":
|
|
125
|
+
end = index
|
|
126
|
+
break
|
|
127
|
+
if end is None:
|
|
128
|
+
return [], ["frontmatter block not closed"]
|
|
129
|
+
|
|
130
|
+
decls: list[CheckDecl] = []
|
|
131
|
+
errors: list[str] = []
|
|
132
|
+
in_checks = False
|
|
133
|
+
current: dict[str, str] | None = None
|
|
134
|
+
|
|
135
|
+
for raw in lines[1:end]:
|
|
136
|
+
stripped = raw.strip()
|
|
137
|
+
if not stripped:
|
|
138
|
+
continue
|
|
139
|
+
if not in_checks:
|
|
140
|
+
if stripped == "checks:":
|
|
141
|
+
in_checks = True
|
|
142
|
+
elif stripped.endswith(":") and not stripped.startswith("#"):
|
|
143
|
+
# Another frontmatter key; ignore its body silently.
|
|
144
|
+
in_checks = False
|
|
145
|
+
continue
|
|
146
|
+
if stripped.startswith("- "):
|
|
147
|
+
if current is not None:
|
|
148
|
+
parsed = _decl_from_pairs(current, errors)
|
|
149
|
+
if parsed is not None:
|
|
150
|
+
decls.append(parsed)
|
|
151
|
+
current = {}
|
|
152
|
+
stripped = stripped[2:].strip()
|
|
153
|
+
if not stripped:
|
|
154
|
+
continue
|
|
155
|
+
if current is None:
|
|
156
|
+
errors.append(f"unexpected line outside a check item: {stripped!r}")
|
|
157
|
+
in_checks = False
|
|
158
|
+
continue
|
|
159
|
+
if ":" not in stripped:
|
|
160
|
+
errors.append(f"malformed check line: {stripped!r}")
|
|
161
|
+
current = None
|
|
162
|
+
in_checks = False
|
|
163
|
+
continue
|
|
164
|
+
key, _, value = stripped.partition(":")
|
|
165
|
+
key = key.strip()
|
|
166
|
+
if not key:
|
|
167
|
+
errors.append(f"malformed check line: {stripped!r}")
|
|
168
|
+
continue
|
|
169
|
+
current[key] = _strip_quotes(value)
|
|
170
|
+
parsed = _decl_from_pairs(current, errors)
|
|
171
|
+
if parsed is not None:
|
|
172
|
+
decls.append(parsed)
|
|
173
|
+
return decls, errors
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _decl_from_pairs(pairs: dict[str, str], errors: list[str]) -> CheckDecl | None:
|
|
177
|
+
cmd = pairs.pop("cmd", "")
|
|
178
|
+
if not cmd:
|
|
179
|
+
errors.append("check item without cmd")
|
|
180
|
+
return None
|
|
181
|
+
when = pairs.pop("when", "both") or "both"
|
|
182
|
+
if when not in {"edit", "lifecycle", "both"}:
|
|
183
|
+
errors.append(f"unknown when value: {when!r}")
|
|
184
|
+
when = "both"
|
|
185
|
+
timeout_raw = pairs.pop("timeout", "")
|
|
186
|
+
try:
|
|
187
|
+
timeout = float(timeout_raw) if timeout_raw else DEFAULT_TIMEOUT
|
|
188
|
+
except ValueError:
|
|
189
|
+
errors.append(f"non-numeric timeout: {timeout_raw!r}")
|
|
190
|
+
timeout = DEFAULT_TIMEOUT
|
|
191
|
+
timeout = min(max(timeout, 0.5), MAX_TIMEOUT)
|
|
192
|
+
files_raw = pairs.pop("files", "")
|
|
193
|
+
files, file_errors = _parse_files(files_raw)
|
|
194
|
+
errors.extend(file_errors)
|
|
195
|
+
if file_errors:
|
|
196
|
+
# A declaration whose files filter cannot be honored must not fall
|
|
197
|
+
# back to "matches everything"; drop it and let doctor surface it.
|
|
198
|
+
return None
|
|
199
|
+
cmd_win = pairs.pop("cmd.win", None)
|
|
200
|
+
unknown = sorted(pairs)
|
|
201
|
+
if unknown:
|
|
202
|
+
errors.append(f"unknown check keys ignored: {', '.join(unknown)}")
|
|
203
|
+
return CheckDecl(
|
|
204
|
+
cmd=cmd,
|
|
205
|
+
files=files,
|
|
206
|
+
timeout=timeout,
|
|
207
|
+
when=when,
|
|
208
|
+
cmd_win=cmd_win,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _parse_files(raw: str) -> tuple[tuple[str, ...], list[str]]:
|
|
213
|
+
if not raw:
|
|
214
|
+
return (), []
|
|
215
|
+
errors: list[str] = []
|
|
216
|
+
matched: list[str] = []
|
|
217
|
+
for part in raw.split(","):
|
|
218
|
+
token = part.strip().replace("\\", "/")
|
|
219
|
+
while token.startswith("./"):
|
|
220
|
+
token = token[2:]
|
|
221
|
+
if not token:
|
|
222
|
+
continue
|
|
223
|
+
if token.endswith("/**"):
|
|
224
|
+
token = token[:-2]
|
|
225
|
+
if token.startswith("*."):
|
|
226
|
+
if "*" in token[2:] or "?" in token:
|
|
227
|
+
errors.append(f"unsupported files form (use dir/ or *.ext): {token!r}")
|
|
228
|
+
continue
|
|
229
|
+
matched.append(token)
|
|
230
|
+
continue
|
|
231
|
+
if "*" in token or "?" in token or "[" in token:
|
|
232
|
+
errors.append(f"unsupported files form (use dir/ or *.ext): {token!r}")
|
|
233
|
+
continue
|
|
234
|
+
matched.append(token if token.endswith("/") else f"{token}/")
|
|
235
|
+
return tuple(matched), errors
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def path_matches_decl_files(relative_path: str, decl: CheckDecl) -> bool:
|
|
239
|
+
if not decl.files:
|
|
240
|
+
return True
|
|
241
|
+
normalized = relative_path.replace("\\", "/")
|
|
242
|
+
for token in decl.files:
|
|
243
|
+
if token.startswith("*."):
|
|
244
|
+
if normalized.endswith(token[1:]):
|
|
245
|
+
return True
|
|
246
|
+
elif normalized.startswith(token):
|
|
247
|
+
return True
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def collect_spec_declarations(repo_root: Path) -> list[SpecDeclarations]:
|
|
252
|
+
spec_dir = repo_root / ".cowork-flow" / "spec"
|
|
253
|
+
if not spec_dir.is_dir():
|
|
254
|
+
return []
|
|
255
|
+
collected: list[SpecDeclarations] = []
|
|
256
|
+
for path in sorted(spec_dir.rglob("*.md")):
|
|
257
|
+
relative = path.relative_to(spec_dir).as_posix()
|
|
258
|
+
parts = Path(relative).parts
|
|
259
|
+
if parts and parts[0] in EXCLUDED_SPEC_SUBDIRS:
|
|
260
|
+
continue
|
|
261
|
+
try:
|
|
262
|
+
text = path.read_text(encoding="utf-8")
|
|
263
|
+
except OSError:
|
|
264
|
+
collected.append(
|
|
265
|
+
SpecDeclarations(relative, (), (f"unreadable spec file: {relative}",))
|
|
266
|
+
)
|
|
267
|
+
continue
|
|
268
|
+
decls, errors = parse_frontmatter_checks(text)
|
|
269
|
+
collected.append(SpecDeclarations(relative, tuple(decls), tuple(errors)))
|
|
270
|
+
return collected
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _decl_for_platform(decl: CheckDecl) -> str:
|
|
274
|
+
if IS_WINDOWS and decl.cmd_win:
|
|
275
|
+
return decl.cmd_win
|
|
276
|
+
return decl.cmd
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _first_violation_line(output: Iterable[str]) -> str:
|
|
280
|
+
for line in output:
|
|
281
|
+
line = line.strip()
|
|
282
|
+
if line:
|
|
283
|
+
return line[:MAX_FIRST_LINE_CHARS]
|
|
284
|
+
return ""
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _run_command(decl: CheckDecl, repo_root: Path) -> tuple[str, int | None, str, list[str]]:
|
|
288
|
+
"""Execute one declaration. Returns (status, exit_code, reason, output)."""
|
|
289
|
+
command = _decl_for_platform(decl)
|
|
290
|
+
if IS_WINDOWS:
|
|
291
|
+
# Windows: newer runtimes refuse to spawn .cmd shims directly
|
|
292
|
+
# (EINVAL); route through cmd.exe the way npm shims launch. Failure
|
|
293
|
+
# to find cmd.exe or the entry degrades to unchecked, never pass.
|
|
294
|
+
argv = ["cmd.exe", "/c", command]
|
|
295
|
+
popen_kwargs: dict = {}
|
|
296
|
+
else:
|
|
297
|
+
try:
|
|
298
|
+
argv = shlex.split(command)
|
|
299
|
+
except ValueError as error:
|
|
300
|
+
return "unchecked", None, f"unparsable command: {error}", []
|
|
301
|
+
if not argv:
|
|
302
|
+
return "unchecked", None, "empty command", []
|
|
303
|
+
resolved = shutil.which(argv[0])
|
|
304
|
+
if resolved is None:
|
|
305
|
+
return "unchecked", None, f"command not found: {argv[0]}", []
|
|
306
|
+
popen_kwargs = {}
|
|
307
|
+
try:
|
|
308
|
+
completed = subprocess.run(
|
|
309
|
+
argv,
|
|
310
|
+
cwd=str(repo_root),
|
|
311
|
+
capture_output=True,
|
|
312
|
+
text=True,
|
|
313
|
+
timeout=decl.timeout,
|
|
314
|
+
check=False,
|
|
315
|
+
**popen_kwargs,
|
|
316
|
+
)
|
|
317
|
+
except subprocess.TimeoutExpired:
|
|
318
|
+
return "unchecked", None, f"timed out after {decl.timeout:g}s", []
|
|
319
|
+
except OSError as error:
|
|
320
|
+
# PATH-stripped or otherwise degraded environments degrade to
|
|
321
|
+
# unchecked; they must never read as a passing check.
|
|
322
|
+
return "unchecked", None, f"failed to launch: {error}", []
|
|
323
|
+
|
|
324
|
+
merged = list(completed.stdout.splitlines()) + list(completed.stderr.splitlines())
|
|
325
|
+
if completed.returncode == 0:
|
|
326
|
+
return "pass", 0, "", []
|
|
327
|
+
return "violation", completed.returncode, "", merged
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def run_checks(
|
|
331
|
+
repo_root: Path,
|
|
332
|
+
*,
|
|
333
|
+
phase: str,
|
|
334
|
+
changed_files: Iterable[str] | None = None,
|
|
335
|
+
file_filter: str | None = None,
|
|
336
|
+
) -> dict:
|
|
337
|
+
"""Run declared checks for one phase and return a JSON-ready report.
|
|
338
|
+
|
|
339
|
+
phase: "edit" runs only edit-eligible declarations whose ``files`` match
|
|
340
|
+
``changed_files`` (3s clamp); "lifecycle" runs every declaration honoring
|
|
341
|
+
each ``files`` scope. Parse errors are reported but never block as
|
|
342
|
+
violations — they surface through doctor.
|
|
343
|
+
"""
|
|
344
|
+
if phase not in {"edit", "lifecycle"}:
|
|
345
|
+
raise SpecCheckError(f"unknown phase: {phase!r}")
|
|
346
|
+
changed = [c.replace("\\", "/") for c in (changed_files or [])]
|
|
347
|
+
if file_filter:
|
|
348
|
+
changed = [file_filter.replace("\\", "/")]
|
|
349
|
+
|
|
350
|
+
results: list[CheckResult] = []
|
|
351
|
+
parse_errors: list[dict[str, str]] = []
|
|
352
|
+
for spec_decls in collect_spec_declarations(repo_root):
|
|
353
|
+
for message in spec_decls.errors:
|
|
354
|
+
parse_errors.append({"spec": spec_decls.spec, "error": message})
|
|
355
|
+
for decl in spec_decls.decls:
|
|
356
|
+
if phase == "edit":
|
|
357
|
+
if decl.when == "lifecycle":
|
|
358
|
+
continue
|
|
359
|
+
if not changed:
|
|
360
|
+
continue
|
|
361
|
+
if not any(
|
|
362
|
+
path_matches_decl_files(path, decl) for path in changed
|
|
363
|
+
):
|
|
364
|
+
continue
|
|
365
|
+
effective = CheckDecl(
|
|
366
|
+
cmd=decl.cmd,
|
|
367
|
+
files=decl.files,
|
|
368
|
+
timeout=min(decl.timeout, EDIT_PHASE_TIMEOUT),
|
|
369
|
+
when=decl.when,
|
|
370
|
+
cmd_win=decl.cmd_win,
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
if decl.when == "edit":
|
|
374
|
+
continue
|
|
375
|
+
effective = decl
|
|
376
|
+
status, exit_code, reason, output = _run_command(effective, repo_root)
|
|
377
|
+
result = CheckResult(
|
|
378
|
+
spec=spec_decls.spec,
|
|
379
|
+
cmd=_decl_for_platform(effective),
|
|
380
|
+
status=status,
|
|
381
|
+
exit_code=exit_code,
|
|
382
|
+
first_violation_line=_first_violation_line(output) if output else "",
|
|
383
|
+
reason=reason,
|
|
384
|
+
)
|
|
385
|
+
if output:
|
|
386
|
+
result.output = output[:20]
|
|
387
|
+
results.append(result)
|
|
388
|
+
|
|
389
|
+
summary = {
|
|
390
|
+
"pass": sum(1 for r in results if r.status == "pass"),
|
|
391
|
+
"violation": sum(1 for r in results if r.status == "violation"),
|
|
392
|
+
"unchecked": sum(1 for r in results if r.status == "unchecked"),
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
396
|
+
"phase": phase,
|
|
397
|
+
"changedFiles": changed,
|
|
398
|
+
"results": [r.to_json() for r in results],
|
|
399
|
+
"parseErrors": parse_errors,
|
|
400
|
+
"summary": summary,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def normalized_one_line(report: dict) -> str:
|
|
405
|
+
"""Single-line summary for hook injection; never multi-line output."""
|
|
406
|
+
results = report.get("results") or []
|
|
407
|
+
for item in results:
|
|
408
|
+
if item.get("status") == "violation":
|
|
409
|
+
spec = item.get("spec", "?")
|
|
410
|
+
line = item.get("firstViolationLine") or item.get("reason") or ""
|
|
411
|
+
return f"spec-check[{spec}] violation: {line}"
|
|
412
|
+
for item in results:
|
|
413
|
+
if item.get("status") == "unchecked":
|
|
414
|
+
return f"spec-check[{item.get('spec', '?')}] unchecked: {item.get('reason', '')}"
|
|
415
|
+
return ""
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# Per-file throttle for editor-phase runs: an edit storm must not multiply
|
|
419
|
+
# the command cost. State lives in the workflow runtime directory because
|
|
420
|
+
# every hook invocation is a fresh process — in-memory maps cannot throttle
|
|
421
|
+
# across invocations.
|
|
422
|
+
EDIT_THROTTLE_INTERVAL_SECONDS = 10.0
|
|
423
|
+
EDIT_THROTTLE_FILE = ".cowork-flow" / Path(".runtime") / "spec-edit-throttle.json"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _read_throttle(root: Path) -> dict:
|
|
427
|
+
try:
|
|
428
|
+
data = json.loads((root / EDIT_THROTTLE_FILE).read_text(encoding="utf-8"))
|
|
429
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
430
|
+
return {}
|
|
431
|
+
return data if isinstance(data, dict) else {}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _write_throttle(root: Path, file_path: str, timestamp: float) -> None:
|
|
435
|
+
path = root / EDIT_THROTTLE_FILE
|
|
436
|
+
try:
|
|
437
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
438
|
+
path.write_text(
|
|
439
|
+
json.dumps({"file": file_path, "ts": timestamp}, ensure_ascii=False)
|
|
440
|
+
+ "\n",
|
|
441
|
+
encoding="utf-8",
|
|
442
|
+
)
|
|
443
|
+
except OSError:
|
|
444
|
+
# Throttle state is best-effort; a failed write just means the next
|
|
445
|
+
# edit runs its checks again.
|
|
446
|
+
pass
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def run_edit_checks(
|
|
450
|
+
repo_root: Path,
|
|
451
|
+
file_path: str,
|
|
452
|
+
*,
|
|
453
|
+
throttled: bool = True,
|
|
454
|
+
now: float | None = None,
|
|
455
|
+
) -> str:
|
|
456
|
+
"""Editor-phase single-line result for one changed file.
|
|
457
|
+
|
|
458
|
+
Empty string means "nothing to report": no declaration matched, the file
|
|
459
|
+
was throttled, or checks passed. Violations and unchecked degradations
|
|
460
|
+
return the normalized one-line summary. Never raises for expected
|
|
461
|
+
operating conditions — the editor path must not break edits.
|
|
462
|
+
"""
|
|
463
|
+
import time
|
|
464
|
+
|
|
465
|
+
normalized = file_path.replace("\\", "/").strip()
|
|
466
|
+
if not normalized:
|
|
467
|
+
return ""
|
|
468
|
+
# Hosts pass absolute edit paths; declaration filters are repo-relative.
|
|
469
|
+
try:
|
|
470
|
+
normalized = (
|
|
471
|
+
Path(normalized).resolve().relative_to(repo_root.resolve()).as_posix()
|
|
472
|
+
)
|
|
473
|
+
except (ValueError, OSError):
|
|
474
|
+
pass
|
|
475
|
+
if throttled:
|
|
476
|
+
state = _read_throttle(repo_root)
|
|
477
|
+
current = time.time() if now is None else now
|
|
478
|
+
if (
|
|
479
|
+
state.get("file") == normalized
|
|
480
|
+
and isinstance(state.get("ts"), (int, float))
|
|
481
|
+
and current - state["ts"] < EDIT_THROTTLE_INTERVAL_SECONDS
|
|
482
|
+
):
|
|
483
|
+
return ""
|
|
484
|
+
_write_throttle(repo_root, normalized, current)
|
|
485
|
+
try:
|
|
486
|
+
report = run_checks(repo_root, phase="edit", changed_files=[normalized])
|
|
487
|
+
except Exception:
|
|
488
|
+
return ""
|
|
489
|
+
return normalized_one_line(report)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def summary_has(report: dict, key: str) -> bool:
|
|
493
|
+
return bool((report.get("summary") or {}).get(key))
|
|
@@ -151,6 +151,7 @@ class TaskLifecycleService:
|
|
|
151
151
|
*,
|
|
152
152
|
completed_at: str | None = None,
|
|
153
153
|
allow_spec_file_modifications: bool | None = None,
|
|
154
|
+
allow_unchecked_specs: bool = False,
|
|
154
155
|
execution_context: object | None = None,
|
|
155
156
|
) -> LifecycleResult:
|
|
156
157
|
return self.execute(
|
|
@@ -158,6 +159,7 @@ class TaskLifecycleService:
|
|
|
158
159
|
task,
|
|
159
160
|
completed_at=completed_at,
|
|
160
161
|
allow_spec_file_modifications=allow_spec_file_modifications,
|
|
162
|
+
allow_unchecked_specs=allow_unchecked_specs,
|
|
161
163
|
execution_context=execution_context,
|
|
162
164
|
)
|
|
163
165
|
|
|
@@ -168,6 +170,7 @@ class TaskLifecycleService:
|
|
|
168
170
|
*,
|
|
169
171
|
preflight: Preflight | None = None,
|
|
170
172
|
allow_spec_file_modifications: bool | None = None,
|
|
173
|
+
allow_unchecked_specs: bool = False,
|
|
171
174
|
completed_at: str | None = None,
|
|
172
175
|
execution_context: object | None = None,
|
|
173
176
|
executor: str | None = None,
|
|
@@ -179,6 +182,7 @@ class TaskLifecycleService:
|
|
|
179
182
|
self.repo_root,
|
|
180
183
|
execution_context,
|
|
181
184
|
allow_spec_file_modifications=allow_spec_file_modifications,
|
|
185
|
+
allow_unchecked_specs=allow_unchecked_specs,
|
|
182
186
|
)
|
|
183
187
|
prepared = self._prepare_transition(
|
|
184
188
|
stage, task_dir, preflight, executor=executor, takeover=takeover
|
|
@@ -334,7 +338,12 @@ class TaskLifecycleService:
|
|
|
334
338
|
task_dir,
|
|
335
339
|
task_data,
|
|
336
340
|
self._persisted_task_data(
|
|
337
|
-
stage,
|
|
341
|
+
stage,
|
|
342
|
+
task_data,
|
|
343
|
+
completed_at,
|
|
344
|
+
executor=resolved_executor,
|
|
345
|
+
check_result=check_result,
|
|
346
|
+
execution_policy=execution_policy,
|
|
338
347
|
),
|
|
339
348
|
session_state,
|
|
340
349
|
check_result,
|
|
@@ -559,6 +568,8 @@ class TaskLifecycleService:
|
|
|
559
568
|
task_data: dict,
|
|
560
569
|
completed_at: str | None,
|
|
561
570
|
executor: str | None = None,
|
|
571
|
+
check_result: object | None = None,
|
|
572
|
+
execution_policy: LifecycleExecutionPolicy | None = None,
|
|
562
573
|
) -> dict:
|
|
563
574
|
persisted = dict(task_data)
|
|
564
575
|
persisted["status"] = stage.target_status
|
|
@@ -579,6 +590,27 @@ class TaskLifecycleService:
|
|
|
579
590
|
if head:
|
|
580
591
|
meta["baselineCommit"] = head
|
|
581
592
|
persisted["meta"] = meta
|
|
593
|
+
if stage.name == COMPLETE_STAGE.name:
|
|
594
|
+
meta = dict(persisted.get("meta") or {})
|
|
595
|
+
spec_report = getattr(check_result, "spec_check", None)
|
|
596
|
+
if isinstance(spec_report, dict):
|
|
597
|
+
# Overwrite-on-each-completion telemetry: retry loops must
|
|
598
|
+
# not accumulate, the latest gate result wins.
|
|
599
|
+
meta["specCheckSummary"] = dict(
|
|
600
|
+
spec_report.get("summary") or {}
|
|
601
|
+
)
|
|
602
|
+
if (
|
|
603
|
+
execution_policy is not None
|
|
604
|
+
and execution_policy.allow_unchecked_specs
|
|
605
|
+
and int((spec_report.get("summary") or {}).get("unchecked") or 0)
|
|
606
|
+
):
|
|
607
|
+
meta["specCheckExempt"] = {
|
|
608
|
+
"at": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
|
|
609
|
+
"unchecked": int(
|
|
610
|
+
(spec_report.get("summary") or {}).get("unchecked") or 0
|
|
611
|
+
),
|
|
612
|
+
}
|
|
613
|
+
persisted["meta"] = meta
|
|
582
614
|
return persisted
|
|
583
615
|
|
|
584
616
|
def _resolve_executor(self, executor: str | None) -> str | None:
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# 后端异常处理规范
|
|
2
2
|
|
|
3
|
+
<!--
|
|
4
|
+
本 spec 支持 frontmatter `checks:` 声明检查命令(规范挂命令契约见
|
|
5
|
+
spec/contracts/spec-checks.md)。模板自带的声明由上游维护、sync 会覆盖
|
|
6
|
+
本地修改;请把实际声明写进自建 spec 文件。示例:
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
checks:
|
|
10
|
+
- cmd: python scripts/check_error_codes.py
|
|
11
|
+
files: "src/"
|
|
12
|
+
when: lifecycle
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
-->
|
|
16
|
+
|
|
3
17
|
## 目标
|
|
4
18
|
|
|
5
19
|
- 调用方能稳定感知错误
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Spec Check 契约(规范挂命令)
|
|
2
|
+
|
|
3
|
+
用户规范(`.cowork-flow/spec/` 下 `backend/`、`frontend/`、自建目录)可通过
|
|
4
|
+
frontmatter 的 `checks:` 声明检查命令。机制只执行声明、不解析规范正文——
|
|
5
|
+
规则变化 = 用户改同一个文件里的命令,天然同步。
|
|
6
|
+
|
|
7
|
+
## 语法
|
|
8
|
+
|
|
9
|
+
```markdown
|
|
10
|
+
---
|
|
11
|
+
checks:
|
|
12
|
+
- cmd: npm run lint --silent
|
|
13
|
+
files: "src/" # 可选:适用范围,目录前缀或扩展名("*.ts"),逗号分隔
|
|
14
|
+
timeout: 60 # 可选:秒,默认 30,硬顶 120
|
|
15
|
+
when: both # 可选:edit | lifecycle | both(默认 both)
|
|
16
|
+
cmd.win: npm run lint # 可选:Windows 覆盖命令
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# 规范正文(机制不读)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- `files` 只支持两种形式:目录前缀(`src/`,以 `/` 结尾或自动补齐)与
|
|
23
|
+
扩展名(`*.ts`)。完整 glob 形态(`**`、`[]`)解析为错误,声明不执行,
|
|
24
|
+
doctor 报告——绝不回退为"匹配一切"。
|
|
25
|
+
- `when: edit` 的声明在编辑期(PostToolUse)对命中 `files` 的改动文件
|
|
26
|
+
就地执行,超时被钳制到 3 秒;慢命令请声明 `when: lifecycle`。
|
|
27
|
+
- 引号规则:仅值整体为单对引号时去引号;`"python" -c "..."`
|
|
28
|
+
这类含多对引号的命令值原样保留,由执行器按 shell 规则拆分。
|
|
29
|
+
|
|
30
|
+
## 三态结果(完成门禁语义)
|
|
31
|
+
|
|
32
|
+
| 状态 | 含义 | complete 门禁 |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `pass` | 退出码 0 | 通过 |
|
|
35
|
+
| `violation` | 退出码非 0 | **阻断**(状态不推进) |
|
|
36
|
+
| `unchecked` | 命令不存在/解释器缺失/超时 | **阻断**;显式 `--allow-unchecked` 可放行,豁免留痕进 task.json `meta.specCheckExempt` |
|
|
37
|
+
|
|
38
|
+
unchecked 永不冒充 pass("没跑成"不是"通过")。frontmatter 畸形视该
|
|
39
|
+
spec 为无检查并进 doctor 报告,不炸流程。
|
|
40
|
+
|
|
41
|
+
## 时机与单行输出
|
|
42
|
+
|
|
43
|
+
- **编辑期**(zcode/claude):违规输出单行
|
|
44
|
+
`spec-check[<spec>] violation: <首个违规行>`;同一文件 10 秒内只报一次
|
|
45
|
+
(节流状态在 `.cowork-flow/.runtime/spec-edit-throttle.json`);执行器
|
|
46
|
+
缺失或超时静默,绝不阻断编辑流。
|
|
47
|
+
- **收口期**(task complete):全量执行,结果进 `meta.specCheckSummary`
|
|
48
|
+
遥测;阻断消息只列未过项摘要。
|
|
49
|
+
- 手动查询:`./.cowork-flow/run spec-check [--file <path>] [--json]
|
|
50
|
+
[--verbose]`。
|
|
51
|
+
|
|
52
|
+
## 归属与 sync 策略
|
|
53
|
+
|
|
54
|
+
- **模板自带 spec**(`spec/backend/` 等随包分发)的 check 声明由
|
|
55
|
+
cowork-flow 上游维护,属 sync safe 资产——本地修改会被 sync 覆盖。
|
|
56
|
+
- **用户自定义规范**请放自建 spec 文件(如 `spec/team-xxx.md` 或自建
|
|
57
|
+
子目录),sync 不触碰;check 声明随规范正文一起由用户维护。
|
|
58
|
+
|
|
59
|
+
## 宿主能力矩阵
|
|
60
|
+
|
|
61
|
+
| 宿主 | digest 注入 | 编辑期快跑 | 收口强制 |
|
|
62
|
+
| --- | --- | --- | --- |
|
|
63
|
+
| zcode | 有(Specs 行 h2 digest) | 有(PostToolUse 短路径) | 有 |
|
|
64
|
+
| claude-code | 有(Python 单源) | 有(PostToolUse,exit 2 反馈) | 有 |
|
|
65
|
+
| opencode | 有 | **降级:无**(插件无 PostToolUse 短路径) | 有 |
|
|
66
|
+
|
|
67
|
+
## digest 注入
|
|
68
|
+
|
|
69
|
+
进入 in_progress 后,stage-contract 的 Specs 行升级为
|
|
70
|
+
`Specs: <spec>(<h2 标题树>); …`:最多 6 个 h2 条目、每条截断 24 字符、
|
|
71
|
+
剔除 `();` 字符;文件缺失不加注解。digest 是条目名索引不是正文,受
|
|
72
|
+
scope-rules.json `stageContract.budget` 硬顶,超预算整行让位
|
|
73
|
+
(Scope/Gates 行永不丢)。格式由三线逐字相等测试锁定
|
|
74
|
+
(`test/stage-contract.test.js` matrix)。
|