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 +15 -0
- package/package.json +1 -1
- 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
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
|
@@ -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
|
+
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
|
-
|
|
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
|
|
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
|
|