@michaelxwb/code-flow 0.5.1 → 0.6.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/docs/migrations/spec-workflow-0.6.0.md +42 -0
- package/package.json +4 -2
- package/src/adapters/claude/CLAUDE.md +11 -17
- package/src/adapters/claude/commands/cf-init.md +5 -4
- package/src/adapters/claude/commands/cf-spec.md +50 -0
- package/src/adapters/claude/commands/cf-task/align.md +29 -3
- package/src/adapters/claude/commands/cf-task/archive.md +32 -4
- package/src/adapters/claude/commands/cf-task/plan.md +54 -1
- package/src/adapters/claude/commands/cf-task/prd.md +24 -0
- package/src/adapters/claude/commands/cf-task/start.md +56 -14
- package/src/adapters/claude/settings.local.json +1 -11
- package/src/adapters/codex/AGENTS.md +11 -17
- package/src/adapters/codex/hooks.json +6 -4
- package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +55 -0
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +29 -3
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +32 -4
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +54 -1
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +56 -14
- package/src/adapters/costrict/CLAUDE.md +11 -17
- package/src/adapters/costrict/commands/cf-init.md +5 -4
- package/src/adapters/costrict/commands/cf-spec.md +50 -0
- package/src/adapters/costrict/commands/cf-task/align.md +29 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +32 -4
- package/src/adapters/costrict/commands/cf-task/plan.md +54 -1
- package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
- package/src/adapters/costrict/commands/cf-task/start.md +56 -14
- package/src/adapters/costrict/settings.local.json +1 -11
- package/src/adapters/opencode/AGENTS.md +11 -17
- package/src/adapters/opencode/commands/cf-init.md +4 -3
- package/src/adapters/opencode/commands/cf-spec.md +54 -0
- package/src/adapters/opencode/commands/cf-task/align.md +30 -3
- package/src/adapters/opencode/commands/cf-task/archive.md +33 -4
- package/src/adapters/opencode/commands/cf-task/plan.md +55 -1
- package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
- package/src/adapters/opencode/commands/cf-task/start.md +57 -14
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -1
- package/src/cli.js +51 -2
- package/src/core/code-flow/config.yml +13 -16
- package/src/core/code-flow/scripts/cf_checks.py +30 -0
- package/src/core/code-flow/scripts/cf_core.py +12 -255
- package/src/core/code-flow/scripts/cf_log.py +8 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +33 -9
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +65 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +1233 -0
- package/src/core/code-flow/scripts/cf_spec_gate.py +224 -0
- package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
- package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
- package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
- package/src/core/code-flow/scripts/cf_spec_router.py +145 -0
- package/src/core/code-flow/scripts/cf_spec_session.py +122 -0
- package/src/core/code-flow/scripts/cf_spec_verify.py +293 -0
- package/src/core/code-flow/scripts/cf_stats.py +40 -5
- package/src/core/code-flow/scripts/cf_stop_hook.py +107 -7
- package/src/core/code-flow/scripts/cf_task_runtime.py +142 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +34 -360
- package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
- package/src/core/code-flow/specs/backend/database.md +13 -1
- package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
- package/src/core/code-flow/specs/backend/logging.md +13 -1
- package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
- package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
- package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
- package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
- package/src/core/code-flow/specs/shared/design/design-frontend.md +20 -10
- package/src/core/code-flow/specs/shared/design/design-full.md +36 -24
- package/src/core/code-flow/specs/shared/design/design-lite.md +21 -9
- package/src/core/code-flow/specs/shared/prd-template.md +14 -3
- package/src/migrate/spec-workflow.js +238 -0
- package/src/adapters/claude/commands/cf-inject.md +0 -55
- package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
- package/src/adapters/costrict/commands/cf-inject.md +0 -55
- package/src/adapters/opencode/commands/cf-inject.md +0 -59
- package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
- package/src/core/code-flow/scripts/cf_session_hook.py +0 -42
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Rule verifier registry and hash-bound Verification Evidence."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
import fnmatch
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import subprocess
|
|
14
|
+
from typing import Mapping, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
from cf_checks import run_regex_verifier
|
|
17
|
+
from cf_spec_metadata import SpecMetadata, SpecRule, SpecVerifier
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class VerificationScope:
|
|
22
|
+
root: str
|
|
23
|
+
files: tuple[str, ...]
|
|
24
|
+
diff_sha256: Optional[str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class VerificationEvidence:
|
|
29
|
+
verifier_ref: str
|
|
30
|
+
executed_at: str
|
|
31
|
+
status: str
|
|
32
|
+
rule_text_sha256: str
|
|
33
|
+
artifact_sha256: Optional[str]
|
|
34
|
+
diff_sha256: Optional[str]
|
|
35
|
+
result_sha256: str
|
|
36
|
+
error_code: Optional[str]
|
|
37
|
+
details: Mapping[str, object]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class VerificationResult:
|
|
42
|
+
evidence: tuple[VerificationEvidence, ...]
|
|
43
|
+
passed: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class _Outcome:
|
|
48
|
+
passed: bool
|
|
49
|
+
error_code: Optional[str]
|
|
50
|
+
artifact_sha256: Optional[str]
|
|
51
|
+
details: Mapping[str, object]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _hash_bytes(data: bytes) -> str:
|
|
55
|
+
return hashlib.sha256(data).hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _result_hash(status: str, error: Optional[str], details: Mapping[str, object]) -> str:
|
|
59
|
+
payload = {"status": status, "error_code": error, "details": details}
|
|
60
|
+
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
|
61
|
+
return _hash_bytes(encoded)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _config_string(config: Mapping[str, object], key: str) -> Optional[str]:
|
|
65
|
+
value = config.get(key)
|
|
66
|
+
return value if isinstance(value, str) and value else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_scope_files(scope: VerificationScope) -> tuple[dict[str, str], Optional[_Outcome]]:
|
|
70
|
+
contents: dict[str, str] = {}
|
|
71
|
+
for relative in scope.files:
|
|
72
|
+
path = Path(scope.root) / relative
|
|
73
|
+
try:
|
|
74
|
+
contents[relative] = path.read_text(encoding="utf-8")
|
|
75
|
+
except (OSError, UnicodeError) as exc:
|
|
76
|
+
outcome = _Outcome(False, "file_read_error", None, {"file": relative, "error": str(exc)})
|
|
77
|
+
return {}, outcome
|
|
78
|
+
return contents, None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _document(config: Mapping[str, object], scope: VerificationScope) -> _Outcome:
|
|
82
|
+
relative = _config_string(config, "artifact")
|
|
83
|
+
section = _config_string(config, "section_id")
|
|
84
|
+
item = _config_string(config, "item_id")
|
|
85
|
+
if not relative or not section or not item:
|
|
86
|
+
return _Outcome(False, "invalid_verifier_config", None, {"required": ["artifact", "section_id", "item_id"]})
|
|
87
|
+
path = Path(scope.root) / relative
|
|
88
|
+
try:
|
|
89
|
+
content = path.read_text(encoding="utf-8")
|
|
90
|
+
artifact_hash = _hash_bytes(path.read_bytes())
|
|
91
|
+
except (OSError, UnicodeError) as exc:
|
|
92
|
+
return _Outcome(False, "artifact_read_error", None, {"artifact": relative, "error": str(exc)})
|
|
93
|
+
expected = _config_string(config, "artifact_sha256")
|
|
94
|
+
present = section in content and item in content
|
|
95
|
+
hash_matches = expected is None or expected == artifact_hash
|
|
96
|
+
details = {"artifact": relative, "section_present": section in content, "item_present": item in content, "hash_matches": hash_matches}
|
|
97
|
+
return _Outcome(present and hash_matches, None if present and hash_matches else "document_mismatch", artifact_hash, details)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _regex(config: Mapping[str, object], metadata: SpecMetadata, scope: VerificationScope) -> _Outcome:
|
|
101
|
+
pattern = _config_string(config, "pattern")
|
|
102
|
+
files = _config_string(config, "files") or "*"
|
|
103
|
+
check_id = _config_string(config, "check_id")
|
|
104
|
+
if check_id:
|
|
105
|
+
check = next((item for item in metadata.checks if item.get("id") == check_id), None)
|
|
106
|
+
if check is None:
|
|
107
|
+
return _Outcome(False, "check_not_found", None, {"check_id": check_id})
|
|
108
|
+
pattern = check.get("pattern") if isinstance(check.get("pattern"), str) else None
|
|
109
|
+
files = check.get("files") if isinstance(check.get("files"), str) else "*"
|
|
110
|
+
if pattern is None:
|
|
111
|
+
return _Outcome(False, "invalid_verifier_config", None, {"required": ["pattern"]})
|
|
112
|
+
selected = tuple(
|
|
113
|
+
relative
|
|
114
|
+
for relative in scope.files
|
|
115
|
+
if fnmatch.fnmatch(relative, files) and (Path(scope.root) / relative).is_file()
|
|
116
|
+
)
|
|
117
|
+
contents, error = _read_scope_files(VerificationScope(scope.root, selected, scope.diff_sha256))
|
|
118
|
+
if error is not None:
|
|
119
|
+
return error
|
|
120
|
+
violations, skipped = run_regex_verifier(pattern, files, contents)
|
|
121
|
+
if skipped:
|
|
122
|
+
return _Outcome(False, "regex_unavailable", None, {"skipped": skipped})
|
|
123
|
+
details = {"checked_files": len(contents), "violations": violations}
|
|
124
|
+
return _Outcome(not violations, None if not violations else "regex_violation", None, details)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _call_name(node: ast.Call) -> str:
|
|
128
|
+
if isinstance(node.func, ast.Name):
|
|
129
|
+
return node.func.id
|
|
130
|
+
if isinstance(node.func, ast.Attribute):
|
|
131
|
+
return node.func.attr
|
|
132
|
+
return ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _ast(config: Mapping[str, object], scope: VerificationScope) -> _Outcome:
|
|
136
|
+
if _config_string(config, "language") != "python":
|
|
137
|
+
return _Outcome(False, "unsupported_ast_language", None, {"language": config.get("language")})
|
|
138
|
+
assertion = _config_string(config, "assertion") or "parse"
|
|
139
|
+
contents, error = _read_scope_files(scope)
|
|
140
|
+
if error is not None:
|
|
141
|
+
return error
|
|
142
|
+
parsed: list[ast.AST] = []
|
|
143
|
+
try:
|
|
144
|
+
parsed = [ast.parse(content, filename=relative) for relative, content in contents.items()]
|
|
145
|
+
except SyntaxError as exc:
|
|
146
|
+
return _Outcome(False, "ast_parse_error", None, {"error": str(exc)})
|
|
147
|
+
if assertion == "parse":
|
|
148
|
+
return _Outcome(True, None, None, {"parsed_files": len(parsed)})
|
|
149
|
+
if assertion == "forbid_call":
|
|
150
|
+
call = _config_string(config, "call")
|
|
151
|
+
hits = sum(_call_name(node) == call for tree in parsed for node in ast.walk(tree) if isinstance(node, ast.Call))
|
|
152
|
+
return _Outcome(hits == 0, None if hits == 0 else "ast_assertion_failed", None, {"call": call, "hits": hits})
|
|
153
|
+
return _Outcome(False, "unsupported_ast_assertion", None, {"assertion": assertion})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _argv(config: Mapping[str, object]) -> Optional[list[str]]:
|
|
157
|
+
value = config.get("argv")
|
|
158
|
+
if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value):
|
|
159
|
+
return None
|
|
160
|
+
return list(value)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _command(config: Mapping[str, object], scope: VerificationScope) -> _Outcome:
|
|
164
|
+
argv = _argv(config)
|
|
165
|
+
if argv is None:
|
|
166
|
+
return _Outcome(False, "invalid_verifier_config", None, {"required": ["argv"]})
|
|
167
|
+
cwd = _config_string(config, "cwd") or "."
|
|
168
|
+
timeout_value = config.get("timeout", 30)
|
|
169
|
+
timeout = float(timeout_value) if isinstance(timeout_value, (int, float)) else 30.0
|
|
170
|
+
allowed_value = config.get("allowed_exit_codes", [0])
|
|
171
|
+
allowed = tuple(item for item in allowed_value if isinstance(item, int)) if isinstance(allowed_value, list) else (0,)
|
|
172
|
+
try:
|
|
173
|
+
completed = subprocess.run(
|
|
174
|
+
argv,
|
|
175
|
+
cwd=str(Path(scope.root) / cwd),
|
|
176
|
+
capture_output=True,
|
|
177
|
+
text=True,
|
|
178
|
+
timeout=timeout,
|
|
179
|
+
check=False,
|
|
180
|
+
)
|
|
181
|
+
except subprocess.TimeoutExpired:
|
|
182
|
+
return _Outcome(False, "verifier_timeout", None, {"timeout": timeout, "argv": argv})
|
|
183
|
+
except OSError as exc:
|
|
184
|
+
return _Outcome(False, "command_unavailable", None, {"argv": argv, "error": str(exc)})
|
|
185
|
+
details = {
|
|
186
|
+
"argv": argv,
|
|
187
|
+
"exit_code": completed.returncode,
|
|
188
|
+
"stdout_sha256": _hash_bytes(completed.stdout.encode()),
|
|
189
|
+
"stderr_sha256": _hash_bytes(completed.stderr.encode()),
|
|
190
|
+
}
|
|
191
|
+
passed = completed.returncode in allowed
|
|
192
|
+
return _Outcome(passed, None if passed else "command_failed", None, details)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _manual(config: Mapping[str, object], confirmation: Optional[Mapping[str, object]]) -> _Outcome:
|
|
196
|
+
if confirmation is None:
|
|
197
|
+
return _Outcome(False, "manual_confirmation_missing", None, {"checklist": config.get("checklist")})
|
|
198
|
+
required = ("reason", "confirmed_by", "confirmed_at", "source")
|
|
199
|
+
if not all(isinstance(confirmation.get(key), str) and confirmation.get(key) for key in required):
|
|
200
|
+
return _Outcome(False, "manual_confirmation_invalid", None, {"required": list(required)})
|
|
201
|
+
identity = str(confirmation["confirmed_by"]).lower().split(":", 1)[0]
|
|
202
|
+
if identity in ("agent", "assistant", "codex", "claude", "opencode", "costrict"):
|
|
203
|
+
return _Outcome(False, "manual_agent_forbidden", None, {"confirmed_by": confirmation["confirmed_by"]})
|
|
204
|
+
return _Outcome(True, None, None, {"source": confirmation["source"], "owner": config.get("owner")})
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _run(
|
|
208
|
+
verifier: SpecVerifier,
|
|
209
|
+
metadata: SpecMetadata,
|
|
210
|
+
scope: VerificationScope,
|
|
211
|
+
confirmation: Optional[Mapping[str, object]],
|
|
212
|
+
) -> _Outcome:
|
|
213
|
+
if verifier.type == "document":
|
|
214
|
+
return _document(verifier.config, scope)
|
|
215
|
+
if verifier.type == "regex":
|
|
216
|
+
return _regex(verifier.config, metadata, scope)
|
|
217
|
+
if verifier.type == "ast":
|
|
218
|
+
return _ast(verifier.config, scope)
|
|
219
|
+
if verifier.type in ("command", "test"):
|
|
220
|
+
return _command(verifier.config, scope)
|
|
221
|
+
if verifier.type == "manual":
|
|
222
|
+
return _manual(verifier.config, confirmation)
|
|
223
|
+
return _Outcome(False, "verifier_type_unimplemented", None, {"type": verifier.type})
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _evidence(
|
|
227
|
+
metadata: SpecMetadata,
|
|
228
|
+
rule: SpecRule,
|
|
229
|
+
verifier: SpecVerifier,
|
|
230
|
+
scope: VerificationScope,
|
|
231
|
+
confirmation: Optional[Mapping[str, object]],
|
|
232
|
+
) -> VerificationEvidence:
|
|
233
|
+
outcome = _run(verifier, metadata, scope, confirmation)
|
|
234
|
+
status = "verified" if outcome.passed else "unverified"
|
|
235
|
+
diff_hash = None if verifier.type == "document" else scope.diff_sha256
|
|
236
|
+
return VerificationEvidence(
|
|
237
|
+
f"{metadata.id}#{rule.ref}",
|
|
238
|
+
datetime.now(timezone.utc).isoformat(),
|
|
239
|
+
status,
|
|
240
|
+
rule.text_sha256,
|
|
241
|
+
outcome.artifact_sha256,
|
|
242
|
+
diff_hash,
|
|
243
|
+
_result_hash(status, outcome.error_code, outcome.details),
|
|
244
|
+
outcome.error_code,
|
|
245
|
+
outcome.details,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def run_all_verifiers(
|
|
250
|
+
metadata: SpecMetadata,
|
|
251
|
+
scope: VerificationScope,
|
|
252
|
+
confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
|
|
253
|
+
) -> VerificationResult:
|
|
254
|
+
verifier_by_rule = {item.rule: item for item in metadata.verifiers}
|
|
255
|
+
confirmation_by_rule = confirmations or {}
|
|
256
|
+
evidence: list[VerificationEvidence] = []
|
|
257
|
+
for rule in metadata.rules:
|
|
258
|
+
if rule.enforcement != "required":
|
|
259
|
+
continue
|
|
260
|
+
verifier = verifier_by_rule.get(rule.ref)
|
|
261
|
+
if verifier is None:
|
|
262
|
+
details = {"rule": rule.ref}
|
|
263
|
+
evidence.append(
|
|
264
|
+
VerificationEvidence(
|
|
265
|
+
f"{metadata.id}#{rule.ref}",
|
|
266
|
+
datetime.now(timezone.utc).isoformat(),
|
|
267
|
+
"unverified",
|
|
268
|
+
rule.text_sha256,
|
|
269
|
+
None,
|
|
270
|
+
scope.diff_sha256,
|
|
271
|
+
_result_hash("unverified", "verifier_missing", details),
|
|
272
|
+
"verifier_missing",
|
|
273
|
+
details,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
continue
|
|
277
|
+
evidence.append(_evidence(metadata, rule, verifier, scope, confirmation_by_rule.get(rule.ref)))
|
|
278
|
+
result = tuple(evidence)
|
|
279
|
+
return VerificationResult(result, bool(result) and all(item.status == "verified" for item in result))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def evidence_is_fresh(
|
|
283
|
+
evidence: VerificationEvidence,
|
|
284
|
+
rule_text_sha256: str,
|
|
285
|
+
artifact_sha256: Optional[str],
|
|
286
|
+
diff_sha256: Optional[str],
|
|
287
|
+
) -> bool:
|
|
288
|
+
return (
|
|
289
|
+
evidence.status == "verified"
|
|
290
|
+
and evidence.rule_text_sha256 == rule_text_sha256
|
|
291
|
+
and evidence.artifact_sha256 == artifact_sha256
|
|
292
|
+
and evidence.diff_sha256 == diff_sha256
|
|
293
|
+
)
|
|
@@ -13,7 +13,6 @@ from cf_core import (
|
|
|
13
13
|
estimate_tokens,
|
|
14
14
|
load_config,
|
|
15
15
|
non_injectable_specs,
|
|
16
|
-
resolve_inject_mode,
|
|
17
16
|
resolve_quality_loop,
|
|
18
17
|
)
|
|
19
18
|
|
|
@@ -96,6 +95,41 @@ def quality_loop_summary(project_root: str, config: dict) -> dict:
|
|
|
96
95
|
return summary
|
|
97
96
|
|
|
98
97
|
|
|
98
|
+
def _percent(numerator: int, denominator: int) -> str:
|
|
99
|
+
return f"{round(numerator * 100 / denominator)}%" if denominator else "n/a"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def spec_workflow_summary(project_root: str) -> dict:
|
|
103
|
+
events = cf_log.read_events(project_root, days=30)
|
|
104
|
+
candidates = {
|
|
105
|
+
(str((event.get("data") or {}).get("task")), str((event.get("data") or {}).get("rule")))
|
|
106
|
+
for event in events if event.get("event") == "spec_candidate"
|
|
107
|
+
}
|
|
108
|
+
bound = {
|
|
109
|
+
(str((event.get("data") or {}).get("task")), str((event.get("data") or {}).get("rule")))
|
|
110
|
+
for event in events if event.get("event") == "spec_bound"
|
|
111
|
+
}
|
|
112
|
+
gates = [event for event in events if event.get("event") == "spec_gate"]
|
|
113
|
+
first_pass = sum((event.get("data") or {}).get("first_pass") is True for event in gates)
|
|
114
|
+
late = sum(event.get("event") == "late_violation" for event in events)
|
|
115
|
+
statuses = {"stale": 0, "conflict": 0, "not_applicable": 0}
|
|
116
|
+
for event in events:
|
|
117
|
+
status = (event.get("data") or {}).get("status")
|
|
118
|
+
if status in statuses:
|
|
119
|
+
statuses[status] += 1
|
|
120
|
+
degraded = sum(event.get("event") == "metrics_degraded" for event in events)
|
|
121
|
+
return {
|
|
122
|
+
"window_days": 30,
|
|
123
|
+
"coverage": _percent(len(bound & candidates), len(candidates)),
|
|
124
|
+
"first_compliance_rate": _percent(first_pass, len(gates)),
|
|
125
|
+
"late_violation_rate": _percent(late, len(bound)),
|
|
126
|
+
"statuses": statuses,
|
|
127
|
+
"data_complete": degraded == 0,
|
|
128
|
+
"degraded_events": degraded,
|
|
129
|
+
"read_note": "read 仅表示读取,不代表理解",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
99
133
|
def read_text(path: str) -> str:
|
|
100
134
|
try:
|
|
101
135
|
with open(path, "r", encoding="utf-8") as file:
|
|
@@ -324,20 +358,20 @@ def main() -> None:
|
|
|
324
358
|
"total_saved_pct": total_saved_pct,
|
|
325
359
|
}
|
|
326
360
|
|
|
327
|
-
inject_mode = resolve_inject_mode(config.get("inject") or {})
|
|
328
361
|
try:
|
|
329
362
|
catalog_max = int(budget_cfg.get("catalog_max", 200))
|
|
330
|
-
except
|
|
363
|
+
except (TypeError, ValueError):
|
|
331
364
|
catalog_max = 200
|
|
332
365
|
catalog_text = build_spec_catalog(project_root, effective_mapping, catalog_max)
|
|
333
366
|
catalog_summary = {
|
|
334
|
-
"mode":
|
|
367
|
+
"mode": "context_first",
|
|
335
368
|
"tokens": estimate_tokens(catalog_text),
|
|
336
369
|
"budget": catalog_max,
|
|
337
370
|
"entries": catalog_text.count("\n- `"),
|
|
338
371
|
}
|
|
339
372
|
|
|
340
373
|
ql_summary = quality_loop_summary(project_root, config)
|
|
374
|
+
workflow_summary = spec_workflow_summary(project_root)
|
|
341
375
|
|
|
342
376
|
output = {
|
|
343
377
|
"l0": {"file": "CLAUDE.md", "tokens": l0_tokens, "budget": l0_budget},
|
|
@@ -351,6 +385,7 @@ def main() -> None:
|
|
|
351
385
|
"compression_summary": compression_summary,
|
|
352
386
|
"catalog": catalog_summary,
|
|
353
387
|
"quality_loop": ql_summary,
|
|
388
|
+
"spec_workflow": workflow_summary,
|
|
354
389
|
"templates": {
|
|
355
390
|
"tokens": templates_tokens,
|
|
356
391
|
"files": sorted(excluded_specs),
|
|
@@ -394,7 +429,7 @@ def main() -> None:
|
|
|
394
429
|
)
|
|
395
430
|
print(
|
|
396
431
|
"CATALOG:",
|
|
397
|
-
|
|
432
|
+
"mode=context_first,",
|
|
398
433
|
f"{catalog_summary['tokens']} / {catalog_max} tokens,",
|
|
399
434
|
f"{catalog_summary['entries']} entries",
|
|
400
435
|
)
|
|
@@ -18,6 +18,8 @@ import subprocess
|
|
|
18
18
|
import sys
|
|
19
19
|
|
|
20
20
|
import cf_log
|
|
21
|
+
from cf_spec_context import load_active_task
|
|
22
|
+
from cf_task_runtime import run_done_gate
|
|
21
23
|
from cf_core import (
|
|
22
24
|
_log,
|
|
23
25
|
ensure_utf8_io,
|
|
@@ -29,6 +31,11 @@ from cf_core import (
|
|
|
29
31
|
|
|
30
32
|
TOTAL_BUDGET_SECONDS = 30.0
|
|
31
33
|
_BRACE_RE = re.compile(r"\{([^{}]+)\}")
|
|
34
|
+
_TASK_SECTION_RE = re.compile(
|
|
35
|
+
r"(?ms)^## (TASK-\d+):.*?(?=^## TASK-\d+:|\Z)"
|
|
36
|
+
)
|
|
37
|
+
_SCENARIO_RE = re.compile(r"\b[SEB]-\d+\b")
|
|
38
|
+
_UNVERIFIED_RE = re.compile(r"\b(?:planned|pending|tbd)\b", re.IGNORECASE)
|
|
32
39
|
|
|
33
40
|
|
|
34
41
|
def expand_braces(pattern: str) -> list:
|
|
@@ -83,6 +90,79 @@ def session_edited_files(project_root: str, sid: str) -> list:
|
|
|
83
90
|
return files
|
|
84
91
|
|
|
85
92
|
|
|
93
|
+
def _is_active_task_file(rel_path: str) -> bool:
|
|
94
|
+
path = normalize_path(rel_path)
|
|
95
|
+
if not path.startswith(".code-flow/tasks/") or "/archived/" in path:
|
|
96
|
+
return False
|
|
97
|
+
return path.endswith(".md") and not path.endswith((".design.md", ".prd.md"))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _subsection(section: str, heading: str) -> str:
|
|
101
|
+
match = re.search(
|
|
102
|
+
rf"(?ms)^### {re.escape(heading)}\s*$\n(.*?)(?=^### |^## |\Z)",
|
|
103
|
+
section,
|
|
104
|
+
)
|
|
105
|
+
return match.group(1) if match else ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _acceptance_gap(task_id: str, section: str, coverage: str) -> str:
|
|
109
|
+
status = re.search(r"(?m)^- \*\*Status\*\*: ([^\n]+)", section)
|
|
110
|
+
if not status or status.group(1).strip() != "done":
|
|
111
|
+
return ""
|
|
112
|
+
refs_match = re.search(r"(?m)^- \*\*Acceptance-Refs\*\*: ([^\n]+)", section)
|
|
113
|
+
if not refs_match:
|
|
114
|
+
return f"{task_id} 缺少 Acceptance-Refs"
|
|
115
|
+
refs = refs_match.group(1).strip()
|
|
116
|
+
if refs.upper().startswith("N/A"):
|
|
117
|
+
return ""
|
|
118
|
+
scenarios = sorted(set(_SCENARIO_RE.findall(refs)))
|
|
119
|
+
if not scenarios:
|
|
120
|
+
return f"{task_id} Acceptance-Refs 未引用 S/E/B 场景"
|
|
121
|
+
contract = _subsection(section, "Acceptance Contract")
|
|
122
|
+
evidence = _subsection(section, "Acceptance Evidence")
|
|
123
|
+
if not contract or not evidence:
|
|
124
|
+
return f"{task_id} 缺少 Acceptance Contract 或 Acceptance Evidence"
|
|
125
|
+
if _UNVERIFIED_RE.search(contract) or _UNVERIFIED_RE.search(evidence):
|
|
126
|
+
return f"{task_id} 验收契约仍有 planned/pending/TBD"
|
|
127
|
+
for scenario in scenarios:
|
|
128
|
+
contract_ok = any(scenario in line and "verified" in line.lower()
|
|
129
|
+
for line in contract.splitlines())
|
|
130
|
+
evidence_ok = any(scenario in line and "verified" in line.lower()
|
|
131
|
+
for line in evidence.splitlines())
|
|
132
|
+
coverage_ok = any(scenario in line and "verified" in line.lower()
|
|
133
|
+
for line in coverage.splitlines())
|
|
134
|
+
if not contract_ok or not evidence_ok or not coverage_ok:
|
|
135
|
+
return f"{task_id} 的 {scenario} 未在覆盖表、契约和证据中全部 verified"
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def task_acceptance_failures(project_root: str, files: list) -> list:
|
|
140
|
+
"""Check new-format task files; legacy tasks without coverage are ignored."""
|
|
141
|
+
failures = []
|
|
142
|
+
for rel_path in files:
|
|
143
|
+
if not _is_active_task_file(rel_path):
|
|
144
|
+
continue
|
|
145
|
+
path = os.path.join(project_root, normalize_path(rel_path))
|
|
146
|
+
try:
|
|
147
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
148
|
+
text = f.read()
|
|
149
|
+
except OSError as exc:
|
|
150
|
+
_log(f"cf_stop_hook task read failed: {exc}")
|
|
151
|
+
continue
|
|
152
|
+
if "## Acceptance Coverage" not in text:
|
|
153
|
+
continue
|
|
154
|
+
coverage = text.split("## Acceptance Coverage", 1)[1].split("## TASK-", 1)[0]
|
|
155
|
+
for match in _TASK_SECTION_RE.finditer(text):
|
|
156
|
+
detail = _acceptance_gap(match.group(1), match.group(0), coverage)
|
|
157
|
+
if detail:
|
|
158
|
+
failures.append({
|
|
159
|
+
"name": "任务验收契约",
|
|
160
|
+
"on_fail": "补齐设计场景的测试映射与执行证据后再完成任务",
|
|
161
|
+
"detail": detail,
|
|
162
|
+
})
|
|
163
|
+
return failures
|
|
164
|
+
|
|
165
|
+
|
|
86
166
|
def run_validators(
|
|
87
167
|
project_root: str, validators: list, files: list, sid: str,
|
|
88
168
|
total_budget: float = TOTAL_BUDGET_SECONDS,
|
|
@@ -144,7 +224,7 @@ def run_validators(
|
|
|
144
224
|
|
|
145
225
|
|
|
146
226
|
def _reason_text(failures: list, truncated: bool) -> str:
|
|
147
|
-
lines = ["收尾校验未通过(cf-stop
|
|
227
|
+
lines = ["收尾校验未通过(cf-stop):"]
|
|
148
228
|
for item in failures:
|
|
149
229
|
lines.append(f"✗ {item['name']}:{item['on_fail']}")
|
|
150
230
|
if item["detail"]:
|
|
@@ -165,21 +245,41 @@ def main() -> None:
|
|
|
165
245
|
if data.get("stop_hook_active"):
|
|
166
246
|
return # 已因本 hook 续跑过一轮,避免循环
|
|
167
247
|
project_root = os.getcwd()
|
|
248
|
+
sid = resolve_session_id(data)
|
|
249
|
+
marker = os.path.join(project_root, ".code-flow", ".active-task.json")
|
|
250
|
+
has_active = os.path.exists(marker)
|
|
251
|
+
files = []
|
|
252
|
+
if has_active:
|
|
253
|
+
try:
|
|
254
|
+
active = load_active_task(project_root)
|
|
255
|
+
task_dir = os.path.join(project_root, active.task_dir)
|
|
256
|
+
done = run_done_gate(project_root, task_dir)
|
|
257
|
+
except (OSError, ValueError) as exc:
|
|
258
|
+
payload = {"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: active task is invalid: {exc}"}
|
|
259
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
260
|
+
return
|
|
261
|
+
if done.decision != "pass":
|
|
262
|
+
payload = {"decision": "block", "reason": "当前 TASK required Spec verifier/Evidence 未通过;修复或重新对齐后再 Done。"}
|
|
263
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
264
|
+
return
|
|
265
|
+
files = list(done.files)
|
|
168
266
|
config = load_config(project_root)
|
|
169
267
|
if not config:
|
|
170
268
|
return
|
|
171
269
|
if not resolve_quality_loop(config)["stop_check"]:
|
|
172
270
|
return
|
|
173
|
-
|
|
174
|
-
|
|
271
|
+
if not has_active:
|
|
272
|
+
files = session_edited_files(project_root, sid)
|
|
175
273
|
if not files:
|
|
176
274
|
return
|
|
275
|
+
acceptance_failures = task_acceptance_failures(project_root, files)
|
|
177
276
|
validators = load_validators(project_root)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
277
|
+
failures, truncated = run_validators(
|
|
278
|
+
project_root, validators, files, sid
|
|
279
|
+
) if validators else ([], False)
|
|
280
|
+
failures = acceptance_failures + failures
|
|
181
281
|
if not failures:
|
|
182
|
-
return #
|
|
282
|
+
return # 全过或无 validation.yml 且无验收缺口时静默
|
|
183
283
|
payload = {"decision": "block", "reason": _reason_text(failures, truncated)}
|
|
184
284
|
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
185
285
|
except Exception as exc:
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Active-task scope expansion and hash-bound Done verification."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
import hashlib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Mapping
|
|
10
|
+
|
|
11
|
+
from cf_spec_context import (
|
|
12
|
+
BindingInput,
|
|
13
|
+
RuleBinding,
|
|
14
|
+
RuleStageStatus,
|
|
15
|
+
SpecBinding,
|
|
16
|
+
SpecContext,
|
|
17
|
+
bind_specs,
|
|
18
|
+
current_owned_paths,
|
|
19
|
+
load_active_task,
|
|
20
|
+
load_context,
|
|
21
|
+
pause_active_task,
|
|
22
|
+
save_context,
|
|
23
|
+
)
|
|
24
|
+
from cf_spec_gate import validate_stage
|
|
25
|
+
from cf_spec_metadata import load_spec_metadata
|
|
26
|
+
from cf_spec_resolver import resolve_candidates, SpecCandidate
|
|
27
|
+
from cf_spec_verify import VerificationEvidence, VerificationScope, run_all_verifiers
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ScopeResult:
|
|
32
|
+
decision: str
|
|
33
|
+
files: tuple[str, ...]
|
|
34
|
+
new_specs: tuple[str, ...]
|
|
35
|
+
message: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class DoneResult:
|
|
40
|
+
decision: str
|
|
41
|
+
files: tuple[str, ...]
|
|
42
|
+
evidence: tuple[Mapping[str, object], ...]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _context_path(task_dir: str) -> str:
|
|
46
|
+
return str(Path(task_dir) / "spec-context.yml")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _required_candidate(candidate: SpecCandidate) -> bool:
|
|
50
|
+
metadata = candidate.metadata
|
|
51
|
+
return metadata.enforcement == "required" and any(
|
|
52
|
+
rule.enforcement == "required" for rule in metadata.rules
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def evaluate_scope(root: str, task_dir: str) -> ScopeResult:
|
|
57
|
+
active = load_active_task(root)
|
|
58
|
+
files = current_owned_paths(root, active)
|
|
59
|
+
context = load_context(_context_path(task_dir))
|
|
60
|
+
candidates = resolve_candidates(root, "code", files)
|
|
61
|
+
existing = {binding.spec_id for binding in context.bindings}
|
|
62
|
+
new = tuple(
|
|
63
|
+
candidate
|
|
64
|
+
for candidate in candidates
|
|
65
|
+
if candidate.scope == "path" and candidate.spec_id not in existing
|
|
66
|
+
)
|
|
67
|
+
if not new:
|
|
68
|
+
return ScopeResult("continue", files, (), "scope unchanged")
|
|
69
|
+
selections = tuple(
|
|
70
|
+
BindingInput(candidate, "scope:path", f"Git diff matched {','.join(candidate.matched_paths)}")
|
|
71
|
+
for candidate in new
|
|
72
|
+
)
|
|
73
|
+
save_context(_context_path(task_dir), bind_specs(context, selections))
|
|
74
|
+
required = tuple(candidate.spec_id for candidate in new if _required_candidate(candidate))
|
|
75
|
+
if required:
|
|
76
|
+
pause_active_task(root)
|
|
77
|
+
message = "新增 required Spec 已暂停 TASK;请选择局部 Plan 或回 Align 更新设计"
|
|
78
|
+
return ScopeResult("pause", files, required, message)
|
|
79
|
+
return ScopeResult("continue", files, tuple(candidate.spec_id for candidate in new), "advisory scope expanded")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _diff_hash(root: str, files: tuple[str, ...]) -> str:
|
|
83
|
+
digest = hashlib.sha256()
|
|
84
|
+
for relative in files:
|
|
85
|
+
path = Path(root) / relative
|
|
86
|
+
digest.update(relative.encode())
|
|
87
|
+
digest.update(path.read_bytes() if path.is_file() else b"<deleted>")
|
|
88
|
+
return digest.hexdigest()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _evidence_data(evidence: VerificationEvidence) -> Mapping[str, object]:
|
|
92
|
+
return {
|
|
93
|
+
"verifier_ref": evidence.verifier_ref,
|
|
94
|
+
"executed_at": evidence.executed_at,
|
|
95
|
+
"status": evidence.status,
|
|
96
|
+
"rule_text_sha256": evidence.rule_text_sha256,
|
|
97
|
+
"artifact_sha256": evidence.artifact_sha256,
|
|
98
|
+
"diff_sha256": evidence.diff_sha256,
|
|
99
|
+
"result_sha256": evidence.result_sha256,
|
|
100
|
+
"error_code": evidence.error_code,
|
|
101
|
+
"details": evidence.details,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _update_rule(rule: RuleBinding, evidence: Mapping[str, object]) -> RuleBinding:
|
|
106
|
+
if "code" not in rule.stage_status:
|
|
107
|
+
return rule
|
|
108
|
+
statuses = dict(rule.stage_status)
|
|
109
|
+
current = statuses["code"]
|
|
110
|
+
status = "verified" if evidence.get("status") == "verified" else "unverified"
|
|
111
|
+
statuses["code"] = replace(current, status=status, evidence=(*current.evidence, evidence))
|
|
112
|
+
return replace(rule, stage_status=statuses)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _apply_evidence(context: SpecContext, evidence: tuple[Mapping[str, object], ...]) -> SpecContext:
|
|
116
|
+
by_ref = {str(item["verifier_ref"]): item for item in evidence}
|
|
117
|
+
bindings: list[SpecBinding] = []
|
|
118
|
+
for binding in context.bindings:
|
|
119
|
+
rules = tuple(
|
|
120
|
+
_update_rule(rule, by_ref[f"{binding.spec_id}#{rule.ref}"])
|
|
121
|
+
if f"{binding.spec_id}#{rule.ref}" in by_ref else rule
|
|
122
|
+
for rule in binding.rules
|
|
123
|
+
)
|
|
124
|
+
bindings.append(replace(binding, rules=rules))
|
|
125
|
+
return replace(context, bindings=tuple(bindings))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def run_done_gate(root: str, task_dir: str) -> DoneResult:
|
|
129
|
+
scope_result = evaluate_scope(root, task_dir)
|
|
130
|
+
if scope_result.decision == "pause":
|
|
131
|
+
return DoneResult("block", scope_result.files, ())
|
|
132
|
+
context = load_context(_context_path(task_dir))
|
|
133
|
+
diff_hash = _diff_hash(root, scope_result.files)
|
|
134
|
+
all_evidence: list[Mapping[str, object]] = []
|
|
135
|
+
for binding in context.bindings:
|
|
136
|
+
metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / binding.path))
|
|
137
|
+
result = run_all_verifiers(metadata, VerificationScope(root, scope_result.files, diff_hash))
|
|
138
|
+
all_evidence.extend(_evidence_data(item) for item in result.evidence)
|
|
139
|
+
updated = _apply_evidence(context, tuple(all_evidence))
|
|
140
|
+
save_context(_context_path(task_dir), updated)
|
|
141
|
+
gate = validate_stage(updated, "code")
|
|
142
|
+
return DoneResult(gate.decision, scope_result.files, tuple(all_evidence))
|