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.
@@ -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, task_data, completed_at, executor=resolved_executor
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
  - 调用方能稳定感知错误
@@ -11,3 +11,4 @@
11
11
  - `capabilities.md`: 宿主适配器能力模型。
12
12
  - `party-mode-v2-board.md`: Party Mode V2 board 协议。
13
13
  - `plan-binding.md`: 开发计划轻量绑定、启动前缺失阻断和恢复可见性合同。
14
+ - `spec-checks.md`: 规范挂命令契约——用户规范 frontmatter `checks:` 声明的语法、三态门禁语义、时机与宿主能力矩阵。
@@ -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)。