cowork-flow 1.1.4 → 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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0 - 2026-09-08
4
+
5
+ ### 规范挂命令(spec 约束前移)
6
+
7
+ 用户在 `.cowork-flow/spec/` 定义的行为规范,从"review 才发现"前移为"编码时即约束":
8
+
9
+ - **spec frontmatter `checks:` 声明**:每条规范可声明检查命令(`cmd`/`files`/`timeout`/`when`),由唯一执行器 `./.cowork-flow/run spec-check` 按相位执行;机制只消费声明、不解析规范正文,规则变化随 spec 同文件天然同步。`files` 只支持目录前缀与扩展名两种形式(完整 glob 解析为错误且声明不执行,doctor 报告)。契约文档见 `spec/contracts/spec-checks.md`。
10
+ - **三态门禁语义**:`pass` / `violation`(complete 阻断、状态不推进)/ `unchecked`(命令缺失、解释器缺失、超时——阻断并要求显式 `--allow-unchecked`,豁免留痕进 `task.json` `meta.specCheckExempt` 供 review 可见)。unchecked 永不冒充 pass。
11
+ - **收口遥测**:complete 时全量结果写 `meta.specCheckSummary`,为"返工前移"效果提供可对比基线。
12
+ - **三线 digest 注入**:进入 in_progress 后,stage-contract 的 Specs 行升级为 h2 标题树索引(每 spec 最多 6 条、每条截断 24 字符),规范条目名随契约常驻注意力;spec 正文改动不影响 digest,超预算整行让位(Scope/Gates 永不丢)。python/zcode/opencode 输出由 matrix 用例锁定逐字相等,claude 线经 Python 单源自动获得。
13
+ - **编辑期快跑**:zcode(PostToolUse 短路径,spawn 单源 CLI、3.8s 上限)与 claude(PostToolUse 新增 hook 资产,exit 2 单行 stderr 反馈)在编辑命中 `files` 的声明时就地执行,违规单行警告、同文件 10 秒节流(状态在 `.cowork-flow/.runtime/`)、执行器缺失静默不阻断编辑流;opencode 降级为仅 digest + 收口。
14
+ - **doctor 健康项**:`specChecks` 报告声明解析错误与命令入口不存在——"跑不了的声明"在收口前暴露。
15
+
16
+ 配套:spec-check CLI(`--phase/--file/--json/--verbose/--throttled`)、`--allow-unchecked` 收口旗标、架构拓扑保持 services 层无 CLI 关注点。
17
+
3
18
  ## 1.1.4 - 2026-08-31
4
19
 
5
20
  ### 修复
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,13 +61,26 @@ def main() -> int:
61
61
  return 0
62
62
  scripts_dir = root / ".cowork-flow" / "scripts"
63
63
  sys.path.insert(0, str(scripts_dir))
64
- from adapters.host.workflow_state_hook import build_hook_context
65
64
 
66
65
  event_name = (
67
66
  hook_input.get("hook_event_name")
68
67
  or hook_input.get("hookEventName")
69
68
  or "UserPromptSubmit"
70
69
  )
70
+ if event_name == "PostToolUse":
71
+ # Editor-phase spec-check short path: at most one advisory line on
72
+ # stderr (exit 2 surfaces it to the model; the edit itself has
73
+ # already happened). Nothing to report exits 0 silently.
74
+ from adapters.host.workflow_state_hook import spec_edit_warning
75
+
76
+ warning = spec_edit_warning(root, hook_input)
77
+ if warning:
78
+ print(warning, file=sys.stderr)
79
+ return 2
80
+ return 0
81
+
82
+ from adapters.host.workflow_state_hook import build_hook_context
83
+
71
84
  context = build_hook_context(
72
85
  root,
73
86
  hook_input,
@@ -76,7 +89,7 @@ def main() -> int:
76
89
  preamble=(
77
90
  (
78
91
  "<claude-code-runtime>\n"
79
- "hooks: UserPromptSubmit, SessionStart\n"
92
+ "hooks: UserPromptSubmit, SessionStart, PostToolUse\n"
80
93
  "</claude-code-runtime>"
81
94
  ),
82
95
  ),
@@ -19,6 +19,18 @@
19
19
  }
20
20
  ]
21
21
  }
22
+ ],
23
+ "PostToolUse": [
24
+ {
25
+ "matcher": "Edit|Write|MultiEdit",
26
+ "hooks": [
27
+ {
28
+ "type": "command",
29
+ "command": "\"${CLAUDE_PROJECT_DIR:-.}/.cowork-flow/run\" python \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/inject-workflow-state.py\"",
30
+ "timeout": 30
31
+ }
32
+ ]
33
+ }
22
34
  ]
23
35
  }
24
36
  }
@@ -1 +1 @@
1
- 1.1.4
1
+ 1.2.0
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """`run spec-check [--phase edit|lifecycle] [--file <path>...] [--json]`.
4
+
5
+ Executes user-declared spec checks (frontmatter `checks:`) and prints a
6
+ normalized one-line summary unless `--json`/`--verbose` asks for more. See
7
+ services/spec_check.py for the three-state contract (pass/violation/unchecked).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+
16
+ if __package__:
17
+ from . import _bootstrap as _bootstrap # noqa: F401
18
+ else:
19
+ import _bootstrap # noqa: F401
20
+
21
+ from infra.paths import get_repo_root
22
+ from services.spec_check import SpecCheckError, run_checks, summary_has
23
+
24
+
25
+ def main() -> int:
26
+ parser = argparse.ArgumentParser(
27
+ prog="spec-check",
28
+ description=(
29
+ "Run user-declared spec checks (frontmatter checks:) for one phase."
30
+ ),
31
+ )
32
+ parser.add_argument(
33
+ "--phase",
34
+ choices=("edit", "lifecycle"),
35
+ default="lifecycle",
36
+ help="edit: only declarations matching the given files (3s clamp); "
37
+ "lifecycle: every declaration honoring its files scope.",
38
+ )
39
+ parser.add_argument(
40
+ "--file",
41
+ action="append",
42
+ default=None,
43
+ help="Changed file path to match against declaration files filters; "
44
+ "repeatable. Drives the edit-phase filter and single-file queries.",
45
+ )
46
+ parser.add_argument(
47
+ "--verbose",
48
+ action="store_true",
49
+ help="Print full command output per result (query mode only).",
50
+ )
51
+ parser.add_argument(
52
+ "--throttled",
53
+ action="store_true",
54
+ help="Editor-phase mode: single-line report for one file, throttled "
55
+ "per file (empty output when nothing to report).",
56
+ )
57
+ parser.add_argument(
58
+ "--json",
59
+ action="store_true",
60
+ dest="as_json",
61
+ help="Print the machine-readable report.",
62
+ )
63
+ args = parser.parse_args()
64
+
65
+ repo_root = get_repo_root()
66
+
67
+ if args.throttled:
68
+ from services.spec_check import run_edit_checks
69
+
70
+ files = args.file or []
71
+ if len(files) != 1:
72
+ print("Error: --throttled requires exactly one --file", file=sys.stderr)
73
+ return 2
74
+ print(run_edit_checks(repo_root, files[0]))
75
+ return 0
76
+
77
+ try:
78
+ report = run_checks(
79
+ repo_root,
80
+ phase=args.phase,
81
+ changed_files=args.file or [],
82
+ )
83
+ except SpecCheckError as error:
84
+ print(f"Error: {error}", file=sys.stderr)
85
+ return 2
86
+
87
+ if args.as_json:
88
+ print(json.dumps(report, ensure_ascii=False, indent=2))
89
+ return 0
90
+
91
+ if args.verbose:
92
+ for result in report.get("results", []):
93
+ print(f"--- {result.get('spec')} :: {result.get('cmd')}")
94
+ for line in result.get("output", []):
95
+ print(line)
96
+ if result.get("reason"):
97
+ print(f"reason: {result['reason']}")
98
+
99
+ from services.spec_check import normalized_one_line
100
+
101
+ line = normalized_one_line(report)
102
+ if line:
103
+ print(line)
104
+ else:
105
+ summary = report.get("summary", {})
106
+ print(
107
+ "spec-check: {pass} passed, {violation} violations, "
108
+ "{unchecked} unchecked".format(
109
+ **{**{"pass": 0, "violation": 0, "unchecked": 0}, **summary}
110
+ )
111
+ )
112
+
113
+ if summary_has(report, "violation"):
114
+ return 1
115
+ if summary_has(report, "unchecked"):
116
+ return 2
117
+ return 0
118
+
119
+
120
+ if __name__ == "__main__":
121
+ sys.exit(main())
@@ -329,6 +329,7 @@ def cmd_complete(args: argparse.Namespace) -> int:
329
329
  result = service.complete(
330
330
  task_dir,
331
331
  execution_context=execution_context,
332
+ allow_unchecked_specs=bool(getattr(args, "allow_unchecked", False)),
332
333
  )
333
334
  if not result.ok:
334
335
  if result.code == "LIFECYCLE-TRANSITION-001":
@@ -84,6 +84,13 @@ def _add_next_output_options(parser: argparse.ArgumentParser) -> None:
84
84
  action="store_true",
85
85
  help="Validate task context JSONL without changing task state",
86
86
  )
87
+ parser.add_argument(
88
+ "--allow-unchecked",
89
+ dest="allow_unchecked",
90
+ action="store_true",
91
+ help="With task completion: allow unchecked spec-check commands "
92
+ "(missing/timeout) and record the exemption in task.json meta",
93
+ )
87
94
  parser.add_argument(
88
95
  "--list",
89
96
  dest="list_tasks",
@@ -252,12 +252,14 @@ def _stage_contract_block(
252
252
  build_stage_contract,
253
253
  file_scope_whitelist,
254
254
  parse_decision_anchor,
255
+ spec_digest_items,
255
256
  spec_pointer_files,
256
257
  )
257
258
 
258
259
  task_dir = root / task_path
259
260
  whitelist = file_scope_whitelist(root, task_dir)
260
261
  spec_files = spec_pointer_files(task_dir)
262
+ digests = spec_digest_items(root, spec_files) if spec_files else {}
261
263
  rules = load_scope_rules(root)
262
264
  anchor_path = task_dir / "decision-anchor.md"
263
265
  try:
@@ -280,12 +282,45 @@ def _stage_contract_block(
280
282
  parsed,
281
283
  mutable=status != "delegated_subtask",
282
284
  rules=rules,
285
+ digests=digests,
283
286
  )
284
287
  except Exception as error:
285
288
  sys.stderr.write(f"stage-contract degraded: {error}\n")
286
289
  return None
287
290
 
288
291
 
292
+ def spec_edit_warning(root: Path, hook_input: dict[str, Any]) -> str:
293
+ """Editor-phase spec-check single-line warning (PostToolUse short path).
294
+
295
+ Silence rules match the zcode JS mirror (editScopeWarning family): only
296
+ an active in_progress/review main-session task gets advisories —
297
+ no_task, planning, completed, and delegated sessions stay silent. Empty
298
+ string means silent. Never raises — the editor path must not break
299
+ edits.
300
+ """
301
+ try:
302
+ task_path, status, _source = _get_active_task(root, hook_input)
303
+ except Exception:
304
+ return ""
305
+ if not task_path or status not in STAGE_CONTRACT_STATES:
306
+ return ""
307
+ tool_name = hook_input.get("tool_name")
308
+ tool_input = hook_input.get("tool_input")
309
+ if tool_name not in {"Edit", "Write", "MultiEdit"}:
310
+ return ""
311
+ file_path = (
312
+ tool_input.get("file_path") if isinstance(tool_input, dict) else None
313
+ )
314
+ if not isinstance(file_path, str) or not file_path.strip():
315
+ return ""
316
+ try:
317
+ from services.spec_check import run_edit_checks
318
+
319
+ return run_edit_checks(root, file_path)
320
+ except Exception:
321
+ return ""
322
+
323
+
289
324
  def _load_breadcrumbs(root: Path) -> dict[str, str]:
290
325
  path = (
291
326
  root
@@ -31,6 +31,7 @@ COMMAND_SCRIPTS = {
31
31
  "state": "adapters/cli/state.py",
32
32
  "mcp-state": "adapters/mcp/state_server.py",
33
33
  "subagent": "adapters/cli/subagent.py",
34
+ "spec-check": "adapters/cli/spec_check.py",
34
35
  }
35
36
  RESERVED_COMMAND_NAMES = (*COMMAND_SCRIPTS, "python", "help", "-h", "--help")
36
37
 
@@ -277,6 +277,7 @@ def _stage_contract_lines(
277
277
  scope_limit: int = STAGE_CONTRACT_SCOPE_LIMIT,
278
278
  spec_limit: int = STAGE_CONTRACT_SPECS_LIMIT,
279
279
  verify_limit: int = STAGE_CONTRACT_VERIFY_LIMIT,
280
+ digests: dict[str, str] | None = None,
280
281
  ) -> list[str]:
281
282
  """Assemble the stage-contract body lines. Byte-for-byte mirrored by the
282
283
  zcode and opencode JS implementations — keep the formatting identical."""
@@ -285,8 +286,13 @@ def _stage_contract_lines(
285
286
  scope_items = [entry["file"] for entry in whitelist[:scope_limit]]
286
287
  lines.append(_scope_row(scope_items, len(whitelist), scope_suffix))
287
288
  if spec_files:
289
+ digest_map = digests or {}
288
290
  spec_items = spec_files[:spec_limit]
289
- specs_text = "; ".join(spec_items)
291
+ parts = []
292
+ for item in spec_items:
293
+ digest = digest_map.get(item)
294
+ parts.append(f"{item}({digest})" if digest else item)
295
+ specs_text = "; ".join(parts)
290
296
  spec_more = len(spec_files) - len(spec_items)
291
297
  if spec_more > 0:
292
298
  specs_text += f" (+{spec_more} more)"
@@ -315,6 +321,7 @@ def build_stage_contract(
315
321
  anchor: dict[str, Any],
316
322
  mutable: bool = True,
317
323
  rules: dict | None = None,
324
+ digests: dict[str, str] | None = None,
318
325
  ) -> str:
319
326
  """Assemble the stage-contract block. Byte-for-byte mirrored by the zcode
320
327
  and opencode JS implementations — keep the formatting identical. Over-
@@ -322,7 +329,8 @@ def build_stage_contract(
322
329
  closed block. `mutable=False` renders the parent scope as a read-only
323
330
  reference for delegated subtasks. `rules` comes from
324
331
  scope-rules.json (load_scope_rules) and carries budget and limits; None
325
- falls back to the module constants."""
332
+ falls back to the module constants. `digests` maps spec pointer entries
333
+ to their h2 heading digest (spec_digest_items)."""
326
334
  stage_contract = (rules or {}).get("stageContract") or {}
327
335
  budget = stage_contract.get("budget", STAGE_CONTRACT_BUDGET)
328
336
  scope_limit = stage_contract.get("scopeLimit", STAGE_CONTRACT_SCOPE_LIMIT)
@@ -338,6 +346,7 @@ def build_stage_contract(
338
346
  scope_limit=scope_limit,
339
347
  spec_limit=spec_limit,
340
348
  verify_limit=verify_limit,
349
+ digests=digests,
341
350
  )
342
351
  )
343
352
  lines.append("</stage-contract>")
@@ -348,6 +357,50 @@ def build_stage_contract(
348
357
  return "\n".join(fitted)
349
358
 
350
359
 
360
+ # Spec digest: the h2 heading tree of each bound spec — the "entry-name"
361
+ # index injected with the contract so the rules exist in the agent's
362
+ # attention without reading full spec bodies. Format is pinned byte-for-byte
363
+ # across the Python source and the zcode/opencode JS mirrors (contract
364
+ # fingerprint tests): path(h2a/h2b), at most 6 headings, each truncated to
365
+ # 24 chars after stripping "();" characters.
366
+ SPEC_DIGEST_MAX_HEADINGS = 6
367
+ SPEC_DIGEST_MAX_CHARS = 24
368
+
369
+
370
+ def _clean_digest_heading(raw: str) -> str:
371
+ cleaned = (
372
+ raw.replace("(", "").replace(")", "").replace(";", "").strip()
373
+ )
374
+ return cleaned[:SPEC_DIGEST_MAX_CHARS]
375
+
376
+
377
+ def spec_heading_digest(path: Path) -> str | None:
378
+ """Extract the h2 heading digest of one spec file, or None."""
379
+ try:
380
+ text = path.read_text(encoding="utf-8")
381
+ except OSError:
382
+ return None
383
+ headings: list[str] = []
384
+ for line in text.splitlines():
385
+ if line.startswith("## ") and not line.startswith("###"):
386
+ cleaned = _clean_digest_heading(line[3:])
387
+ if cleaned:
388
+ headings.append(cleaned)
389
+ if len(headings) >= SPEC_DIGEST_MAX_HEADINGS:
390
+ break
391
+ return "/".join(headings) if headings else None
392
+
393
+
394
+ def spec_digest_items(root: Path, spec_files: list[str]) -> dict[str, str]:
395
+ """Digest map for spec pointer entries; missing files stay unannotated."""
396
+ digests: dict[str, str] = {}
397
+ for item in spec_files:
398
+ digest = spec_heading_digest(root / item)
399
+ if digest:
400
+ digests[item] = digest
401
+ return digests
402
+
403
+
351
404
  def _normalize_rel(path_value: str) -> str | None:
352
405
  value = path_value.strip().replace("\\", "/")
353
406
  while value.startswith("./"):
@@ -44,6 +44,7 @@ class LifecycleCheckResult:
44
44
  stage: str
45
45
  blockers: tuple[str, ...] = ()
46
46
  issues: tuple[LifecycleCheckIssue, ...] = ()
47
+ spec_check: dict | None = None
47
48
 
48
49
  def __post_init__(self) -> None:
49
50
  if self.issues and not self.blockers:
@@ -96,18 +97,27 @@ class LifecycleCheckRunner:
96
97
  allow_spec_file_modifications: bool | None = None,
97
98
  execution_policy: LifecycleExecutionPolicy | None = None,
98
99
  ) -> LifecycleCheckResult:
100
+ spec_report = _spec_check_report(self.repo_root)
101
+ spec_issues = _spec_check_completion_issues(
102
+ spec_report,
103
+ allow_unchecked_specs=_policy_allows_unchecked(
104
+ execution_policy,
105
+ ),
106
+ )
99
107
  return LifecycleCheckResult(
100
108
  stage="complete",
101
109
  issues=tuple(
102
- _review_completion_issues(
110
+ list(_review_completion_issues(
103
111
  self.repo_root,
104
112
  task_dir,
105
113
  allow_spec_file_modifications=_policy_allows_spec_changes(
106
114
  execution_policy,
107
115
  allow_spec_file_modifications,
108
116
  ),
109
- ),
117
+ ))
118
+ + list(spec_issues)
110
119
  ),
120
+ spec_check=spec_report,
111
121
  )
112
122
 
113
123
 
@@ -120,6 +130,69 @@ def _policy_allows_spec_changes(
120
130
  return bool(allow_spec_file_modifications)
121
131
 
122
132
 
133
+ def _policy_allows_unchecked(
134
+ execution_policy: LifecycleExecutionPolicy | None,
135
+ ) -> bool:
136
+ return bool(execution_policy is not None and execution_policy.allow_unchecked_specs)
137
+
138
+
139
+ def _spec_check_report(repo_root: Path) -> dict | None:
140
+ """Run the lifecycle-phase spec checks. A structural failure degrades to
141
+ an unchecked report — never to a silently passing gate."""
142
+ try:
143
+ from services.spec_check import run_checks
144
+ except Exception: # pragma: no cover - import surface must stay lazy
145
+ return {"schemaVersion": 1, "phase": "lifecycle", "results": [],
146
+ "parseErrors": [], "summary": {"pass": 0, "violation": 0,
147
+ "unchecked": 1, "executorError": 1}}
148
+ try:
149
+ return run_checks(repo_root, phase="lifecycle")
150
+ except Exception as error: # fail closed: unchecked, not pass
151
+ return {"schemaVersion": 1, "phase": "lifecycle", "results": [],
152
+ "parseErrors": [], "summary": {"pass": 0, "violation": 0,
153
+ "unchecked": 1, "executorError": 1, "error": str(error)}}
154
+
155
+
156
+ def _spec_check_completion_issues(
157
+ spec_report: dict | None,
158
+ *,
159
+ allow_unchecked_specs: bool,
160
+ ) -> list[LifecycleCheckIssue]:
161
+ if not spec_report:
162
+ return []
163
+ summary = spec_report.get("summary") or {}
164
+ issues: list[LifecycleCheckIssue] = []
165
+ violation = int(summary.get("violation") or 0)
166
+ unchecked = int(summary.get("unchecked") or 0)
167
+ if violation:
168
+ first_lines = []
169
+ for item in spec_report.get("results") or []:
170
+ if item.get("status") == "violation":
171
+ detail = item.get("firstViolationLine") or item.get("reason") or ""
172
+ first_lines.append(f"{item.get('spec')}: {detail}")
173
+ issues.append(
174
+ LifecycleCheckIssue(
175
+ code="SPEC-CHECK-VIOLATION",
176
+ message=(
177
+ "spec checks report "
178
+ f"{violation} violation(s): {'; '.join(first_lines[:3])}"
179
+ ),
180
+ )
181
+ )
182
+ if unchecked and not allow_unchecked_specs:
183
+ issues.append(
184
+ LifecycleCheckIssue(
185
+ code="SPEC-CHECK-UNCHECKED",
186
+ message=(
187
+ f"spec checks report {unchecked} unchecked command(s) "
188
+ "(missing/timeout); rerun spec-check, or pass "
189
+ "--allow-unchecked to complete with the exemption recorded"
190
+ ),
191
+ )
192
+ )
193
+ return issues
194
+
195
+
123
196
 
124
197
  def _baseline_changed_paths(
125
198
  repo_root: Path, task_dir: Path
@@ -34,6 +34,7 @@ class LifecycleExecutionPolicy:
34
34
 
35
35
  execution_scope: str
36
36
  allow_spec_file_modifications: bool
37
+ allow_unchecked_specs: bool = False
37
38
 
38
39
 
39
40
  def start_readiness_failure(
@@ -91,13 +92,18 @@ def resolve_execution_policy(
91
92
  execution_context: ExecutionContext | None = None,
92
93
  *,
93
94
  allow_spec_file_modifications: bool | None = None,
95
+ allow_unchecked_specs: bool | None = None,
94
96
  ) -> LifecycleExecutionPolicy:
95
97
  """Resolve lifecycle scope capability from execution facts."""
96
98
  if execution_context is None:
97
- if allow_spec_file_modifications is not None:
99
+ if (
100
+ allow_spec_file_modifications is not None
101
+ or allow_unchecked_specs is not None
102
+ ):
98
103
  return LifecycleExecutionPolicy(
99
104
  execution_scope="explicit",
100
- allow_spec_file_modifications=allow_spec_file_modifications,
105
+ allow_spec_file_modifications=bool(allow_spec_file_modifications),
106
+ allow_unchecked_specs=bool(allow_unchecked_specs),
101
107
  )
102
108
  execution_context = ExecutionContext()
103
109
 
@@ -110,6 +116,7 @@ def resolve_execution_policy(
110
116
  return LifecycleExecutionPolicy(
111
117
  execution_scope=execution_context.mode,
112
118
  allow_spec_file_modifications=allowed,
119
+ allow_unchecked_specs=bool(allow_unchecked_specs),
113
120
  )
114
121
 
115
122