@michaelxwb/code-flow 0.5.2 → 0.6.1
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 +63 -0
- package/src/adapters/claude/commands/cf-sync.md +25 -0
- package/src/adapters/claude/commands/cf-task/align.md +23 -1
- package/src/adapters/claude/commands/cf-task/archive.md +7 -0
- package/src/adapters/claude/commands/cf-task/plan.md +16 -0
- package/src/adapters/claude/commands/cf-task/prd.md +24 -0
- package/src/adapters/claude/commands/cf-task/start.md +13 -10
- 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 +56 -0
- package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +23 -1
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +7 -0
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +16 -0
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +13 -10
- 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 +63 -0
- package/src/adapters/costrict/commands/cf-sync.md +25 -0
- package/src/adapters/costrict/commands/cf-task/align.md +23 -1
- package/src/adapters/costrict/commands/cf-task/archive.md +7 -0
- package/src/adapters/costrict/commands/cf-task/plan.md +16 -0
- package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
- package/src/adapters/costrict/commands/cf-task/start.md +13 -10
- 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 +55 -0
- package/src/adapters/opencode/commands/cf-sync.md +29 -0
- package/src/adapters/opencode/commands/cf-task/align.md +24 -1
- package/src/adapters/opencode/commands/cf-task/archive.md +8 -0
- package/src/adapters/opencode/commands/cf-task/plan.md +17 -0
- package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
- package/src/adapters/opencode/commands/cf-task/start.md +14 -10
- package/src/adapters/opencode/plugins/code-flow/index.js +3 -4
- package/src/cli.js +51 -2
- package/src/core/code-flow/config.yml +14 -16
- package/src/core/code-flow/scripts/cf_checks.py +30 -0
- package/src/core/code-flow/scripts/cf_core.py +36 -256
- package/src/core/code-flow/scripts/cf_log.py +8 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +6 -9
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +88 -0
- package/src/core/code-flow/scripts/cf_session_state.py +37 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +1470 -0
- package/src/core/code-flow/scripts/cf_spec_gate.py +230 -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 +149 -0
- package/src/core/code-flow/scripts/cf_spec_session.py +118 -0
- package/src/core/code-flow/scripts/cf_spec_verify.py +340 -0
- package/src/core/code-flow/scripts/cf_stats.py +40 -5
- package/src/core/code-flow/scripts/cf_stop_hook.py +43 -2
- package/src/core/code-flow/scripts/cf_sync.py +140 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +184 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +115 -345
- 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 +10 -0
- package/src/core/code-flow/specs/shared/design/design-full.md +10 -0
- package/src/core/code-flow/specs/shared/design/design-lite.md +10 -0
- 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,340 @@
|
|
|
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
|
+
import time
|
|
15
|
+
from typing import Mapping, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
from cf_checks import run_regex_verifier
|
|
18
|
+
from cf_spec_metadata import SpecMetadata, SpecRule, SpecVerifier
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class VerificationScope:
|
|
23
|
+
root: str
|
|
24
|
+
files: tuple[str, ...]
|
|
25
|
+
diff_sha256: Optional[str]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class VerificationEvidence:
|
|
30
|
+
verifier_ref: str
|
|
31
|
+
executed_at: str
|
|
32
|
+
status: str
|
|
33
|
+
rule_text_sha256: str
|
|
34
|
+
artifact_sha256: Optional[str]
|
|
35
|
+
diff_sha256: Optional[str]
|
|
36
|
+
result_sha256: str
|
|
37
|
+
error_code: Optional[str]
|
|
38
|
+
details: Mapping[str, object]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class VerificationResult:
|
|
43
|
+
evidence: tuple[VerificationEvidence, ...]
|
|
44
|
+
passed: bool
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class _Outcome:
|
|
49
|
+
passed: bool
|
|
50
|
+
error_code: Optional[str]
|
|
51
|
+
artifact_sha256: Optional[str]
|
|
52
|
+
details: Mapping[str, object]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _hash_bytes(data: bytes) -> str:
|
|
56
|
+
return hashlib.sha256(data).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _result_hash(status: str, error: Optional[str], details: Mapping[str, object]) -> str:
|
|
60
|
+
payload = {"status": status, "error_code": error, "details": details}
|
|
61
|
+
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
|
62
|
+
return _hash_bytes(encoded)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _config_string(config: Mapping[str, object], key: str) -> Optional[str]:
|
|
66
|
+
value = config.get(key)
|
|
67
|
+
return value if isinstance(value, str) and value else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_scope_files(scope: VerificationScope) -> tuple[dict[str, str], Optional[_Outcome]]:
|
|
71
|
+
contents: dict[str, str] = {}
|
|
72
|
+
for relative in scope.files:
|
|
73
|
+
path = Path(scope.root) / relative
|
|
74
|
+
try:
|
|
75
|
+
contents[relative] = path.read_text(encoding="utf-8")
|
|
76
|
+
except (OSError, UnicodeError) as exc:
|
|
77
|
+
outcome = _Outcome(False, "file_read_error", None, {"file": relative, "error": str(exc)})
|
|
78
|
+
return {}, outcome
|
|
79
|
+
return contents, None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _document(config: Mapping[str, object], scope: VerificationScope) -> _Outcome:
|
|
83
|
+
relative = _config_string(config, "artifact")
|
|
84
|
+
section = _config_string(config, "section_id")
|
|
85
|
+
item = _config_string(config, "item_id")
|
|
86
|
+
if not relative or not section or not item:
|
|
87
|
+
return _Outcome(False, "invalid_verifier_config", None, {"required": ["artifact", "section_id", "item_id"]})
|
|
88
|
+
path = Path(scope.root) / relative
|
|
89
|
+
try:
|
|
90
|
+
content = path.read_text(encoding="utf-8")
|
|
91
|
+
artifact_hash = _hash_bytes(path.read_bytes())
|
|
92
|
+
except (OSError, UnicodeError) as exc:
|
|
93
|
+
return _Outcome(False, "artifact_read_error", None, {"artifact": relative, "error": str(exc)})
|
|
94
|
+
expected = _config_string(config, "artifact_sha256")
|
|
95
|
+
present = section in content and item in content
|
|
96
|
+
hash_matches = expected is None or expected == artifact_hash
|
|
97
|
+
details = {"artifact": relative, "section_present": section in content, "item_present": item in content, "hash_matches": hash_matches}
|
|
98
|
+
return _Outcome(present and hash_matches, None if present and hash_matches else "document_mismatch", artifact_hash, details)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _regex(config: Mapping[str, object], metadata: SpecMetadata, scope: VerificationScope) -> _Outcome:
|
|
102
|
+
pattern = _config_string(config, "pattern")
|
|
103
|
+
files = _config_string(config, "files") or "*"
|
|
104
|
+
check_id = _config_string(config, "check_id")
|
|
105
|
+
if check_id:
|
|
106
|
+
check = next((item for item in metadata.checks if item.get("id") == check_id), None)
|
|
107
|
+
if check is None:
|
|
108
|
+
return _Outcome(False, "check_not_found", None, {"check_id": check_id})
|
|
109
|
+
pattern = check.get("pattern") if isinstance(check.get("pattern"), str) else None
|
|
110
|
+
files = check.get("files") if isinstance(check.get("files"), str) else "*"
|
|
111
|
+
if pattern is None:
|
|
112
|
+
return _Outcome(False, "invalid_verifier_config", None, {"required": ["pattern"]})
|
|
113
|
+
selected = tuple(
|
|
114
|
+
relative
|
|
115
|
+
for relative in scope.files
|
|
116
|
+
if fnmatch.fnmatch(relative, files) and (Path(scope.root) / relative).is_file()
|
|
117
|
+
)
|
|
118
|
+
contents, error = _read_scope_files(VerificationScope(scope.root, selected, scope.diff_sha256))
|
|
119
|
+
if error is not None:
|
|
120
|
+
return error
|
|
121
|
+
violations, skipped = run_regex_verifier(pattern, files, contents)
|
|
122
|
+
if skipped:
|
|
123
|
+
return _Outcome(False, "regex_unavailable", None, {"skipped": skipped})
|
|
124
|
+
details = {"checked_files": len(contents), "violations": violations}
|
|
125
|
+
return _Outcome(not violations, None if not violations else "regex_violation", None, details)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _call_name(node: ast.Call) -> str:
|
|
129
|
+
if isinstance(node.func, ast.Name):
|
|
130
|
+
return node.func.id
|
|
131
|
+
if isinstance(node.func, ast.Attribute):
|
|
132
|
+
return node.func.attr
|
|
133
|
+
return ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _ast(config: Mapping[str, object], scope: VerificationScope) -> _Outcome:
|
|
137
|
+
if _config_string(config, "language") != "python":
|
|
138
|
+
return _Outcome(False, "unsupported_ast_language", None, {"language": config.get("language")})
|
|
139
|
+
assertion = _config_string(config, "assertion") or "parse"
|
|
140
|
+
contents, error = _read_scope_files(scope)
|
|
141
|
+
if error is not None:
|
|
142
|
+
return error
|
|
143
|
+
parsed: list[ast.AST] = []
|
|
144
|
+
try:
|
|
145
|
+
parsed = [ast.parse(content, filename=relative) for relative, content in contents.items()]
|
|
146
|
+
except SyntaxError as exc:
|
|
147
|
+
return _Outcome(False, "ast_parse_error", None, {"error": str(exc)})
|
|
148
|
+
if assertion == "parse":
|
|
149
|
+
return _Outcome(True, None, None, {"parsed_files": len(parsed)})
|
|
150
|
+
if assertion == "forbid_call":
|
|
151
|
+
call = _config_string(config, "call")
|
|
152
|
+
hits = sum(_call_name(node) == call for tree in parsed for node in ast.walk(tree) if isinstance(node, ast.Call))
|
|
153
|
+
return _Outcome(hits == 0, None if hits == 0 else "ast_assertion_failed", None, {"call": call, "hits": hits})
|
|
154
|
+
return _Outcome(False, "unsupported_ast_assertion", None, {"assertion": assertion})
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _argv(config: Mapping[str, object]) -> Optional[list[str]]:
|
|
158
|
+
value = config.get("argv")
|
|
159
|
+
if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value):
|
|
160
|
+
return None
|
|
161
|
+
return list(value)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _command(config: Mapping[str, object], scope: VerificationScope, timeout_cap: Optional[float] = None) -> _Outcome:
|
|
165
|
+
argv = _argv(config)
|
|
166
|
+
if argv is None:
|
|
167
|
+
return _Outcome(False, "invalid_verifier_config", None, {"required": ["argv"]})
|
|
168
|
+
cwd = _config_string(config, "cwd") or "."
|
|
169
|
+
timeout_value = config.get("timeout", 30)
|
|
170
|
+
timeout = float(timeout_value) if isinstance(timeout_value, (int, float)) else 30.0
|
|
171
|
+
if timeout_cap is not None:
|
|
172
|
+
timeout = min(timeout, timeout_cap)
|
|
173
|
+
allowed_value = config.get("allowed_exit_codes", [0])
|
|
174
|
+
allowed = tuple(item for item in allowed_value if isinstance(item, int)) if isinstance(allowed_value, list) else (0,)
|
|
175
|
+
try:
|
|
176
|
+
completed = subprocess.run(
|
|
177
|
+
argv,
|
|
178
|
+
cwd=str(Path(scope.root) / cwd),
|
|
179
|
+
capture_output=True,
|
|
180
|
+
text=True,
|
|
181
|
+
timeout=timeout,
|
|
182
|
+
check=False,
|
|
183
|
+
)
|
|
184
|
+
except subprocess.TimeoutExpired:
|
|
185
|
+
return _Outcome(False, "verifier_timeout", None, {"timeout": timeout, "argv": argv})
|
|
186
|
+
except OSError as exc:
|
|
187
|
+
return _Outcome(False, "command_unavailable", None, {"argv": argv, "error": str(exc)})
|
|
188
|
+
details = {
|
|
189
|
+
"argv": argv,
|
|
190
|
+
"exit_code": completed.returncode,
|
|
191
|
+
"stdout_sha256": _hash_bytes(completed.stdout.encode()),
|
|
192
|
+
"stderr_sha256": _hash_bytes(completed.stderr.encode()),
|
|
193
|
+
}
|
|
194
|
+
passed = completed.returncode in allowed
|
|
195
|
+
return _Outcome(passed, None if passed else "command_failed", None, details)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _manual(config: Mapping[str, object], confirmation: Optional[Mapping[str, object]]) -> _Outcome:
|
|
199
|
+
if confirmation is None:
|
|
200
|
+
return _Outcome(False, "manual_confirmation_missing", None, {"checklist": config.get("checklist")})
|
|
201
|
+
required = ("reason", "confirmed_by", "confirmed_at", "source")
|
|
202
|
+
if not all(isinstance(confirmation.get(key), str) and confirmation.get(key) for key in required):
|
|
203
|
+
return _Outcome(False, "manual_confirmation_invalid", None, {"required": list(required)})
|
|
204
|
+
identity = str(confirmation["confirmed_by"]).lower().split(":", 1)[0]
|
|
205
|
+
if identity in ("agent", "assistant", "codex", "claude", "opencode", "costrict"):
|
|
206
|
+
return _Outcome(False, "manual_agent_forbidden", None, {"confirmed_by": confirmation["confirmed_by"]})
|
|
207
|
+
return _Outcome(True, None, None, {"source": confirmation["source"], "owner": config.get("owner")})
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _run(
|
|
211
|
+
verifier: SpecVerifier,
|
|
212
|
+
metadata: SpecMetadata,
|
|
213
|
+
scope: VerificationScope,
|
|
214
|
+
confirmation: Optional[Mapping[str, object]],
|
|
215
|
+
timeout_cap: Optional[float] = None,
|
|
216
|
+
) -> _Outcome:
|
|
217
|
+
if verifier.type == "document":
|
|
218
|
+
return _document(verifier.config, scope)
|
|
219
|
+
if verifier.type == "regex":
|
|
220
|
+
return _regex(verifier.config, metadata, scope)
|
|
221
|
+
if verifier.type == "ast":
|
|
222
|
+
return _ast(verifier.config, scope)
|
|
223
|
+
if verifier.type in ("command", "test"):
|
|
224
|
+
return _command(verifier.config, scope, timeout_cap)
|
|
225
|
+
if verifier.type == "manual":
|
|
226
|
+
return _manual(verifier.config, confirmation)
|
|
227
|
+
return _Outcome(False, "verifier_type_unimplemented", None, {"type": verifier.type})
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _evidence(
|
|
231
|
+
metadata: SpecMetadata,
|
|
232
|
+
rule: SpecRule,
|
|
233
|
+
verifier: SpecVerifier,
|
|
234
|
+
scope: VerificationScope,
|
|
235
|
+
confirmation: Optional[Mapping[str, object]],
|
|
236
|
+
timeout_cap: Optional[float] = None,
|
|
237
|
+
) -> VerificationEvidence:
|
|
238
|
+
outcome = _run(verifier, metadata, scope, confirmation, timeout_cap)
|
|
239
|
+
status = "verified" if outcome.passed else "unverified"
|
|
240
|
+
diff_hash = None if verifier.type == "document" else scope.diff_sha256
|
|
241
|
+
return VerificationEvidence(
|
|
242
|
+
f"{metadata.id}#{rule.ref}",
|
|
243
|
+
datetime.now(timezone.utc).isoformat(),
|
|
244
|
+
status,
|
|
245
|
+
rule.text_sha256,
|
|
246
|
+
outcome.artifact_sha256,
|
|
247
|
+
diff_hash,
|
|
248
|
+
_result_hash(status, outcome.error_code, outcome.details),
|
|
249
|
+
outcome.error_code,
|
|
250
|
+
outcome.details,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _skipped_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope) -> VerificationEvidence:
|
|
255
|
+
details = {"skipped": True}
|
|
256
|
+
return VerificationEvidence(
|
|
257
|
+
f"{metadata.id}#{rule.ref}",
|
|
258
|
+
datetime.now(timezone.utc).isoformat(),
|
|
259
|
+
"unverified",
|
|
260
|
+
rule.text_sha256,
|
|
261
|
+
None,
|
|
262
|
+
scope.diff_sha256,
|
|
263
|
+
_result_hash("unverified", "skipped_in_cheap_gate", details),
|
|
264
|
+
"skipped_in_cheap_gate",
|
|
265
|
+
details,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _budget_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope, budget: float) -> VerificationEvidence:
|
|
270
|
+
details = {"budget": budget}
|
|
271
|
+
return VerificationEvidence(
|
|
272
|
+
f"{metadata.id}#{rule.ref}",
|
|
273
|
+
datetime.now(timezone.utc).isoformat(),
|
|
274
|
+
"unverified",
|
|
275
|
+
rule.text_sha256,
|
|
276
|
+
None,
|
|
277
|
+
scope.diff_sha256,
|
|
278
|
+
_result_hash("unverified", "verifier_budget_exhausted", details),
|
|
279
|
+
"verifier_budget_exhausted",
|
|
280
|
+
details,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def run_all_verifiers(
|
|
285
|
+
metadata: SpecMetadata,
|
|
286
|
+
scope: VerificationScope,
|
|
287
|
+
confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
|
|
288
|
+
skip_command: bool = False,
|
|
289
|
+
timeout_budget: Optional[float] = None,
|
|
290
|
+
) -> VerificationResult:
|
|
291
|
+
verifier_by_rule = {item.rule: item for item in metadata.verifiers}
|
|
292
|
+
confirmation_by_rule = confirmations or {}
|
|
293
|
+
evidence: list[VerificationEvidence] = []
|
|
294
|
+
started = time.monotonic()
|
|
295
|
+
for rule in metadata.rules:
|
|
296
|
+
if rule.enforcement != "required":
|
|
297
|
+
continue
|
|
298
|
+
if timeout_budget is not None:
|
|
299
|
+
remaining = timeout_budget - (time.monotonic() - started)
|
|
300
|
+
if remaining <= 0:
|
|
301
|
+
evidence.append(_budget_evidence(metadata, rule, scope, timeout_budget))
|
|
302
|
+
continue
|
|
303
|
+
verifier = verifier_by_rule.get(rule.ref)
|
|
304
|
+
if verifier is None:
|
|
305
|
+
details = {"rule": rule.ref}
|
|
306
|
+
evidence.append(
|
|
307
|
+
VerificationEvidence(
|
|
308
|
+
f"{metadata.id}#{rule.ref}",
|
|
309
|
+
datetime.now(timezone.utc).isoformat(),
|
|
310
|
+
"unverified",
|
|
311
|
+
rule.text_sha256,
|
|
312
|
+
None,
|
|
313
|
+
scope.diff_sha256,
|
|
314
|
+
_result_hash("unverified", "verifier_missing", details),
|
|
315
|
+
"verifier_missing",
|
|
316
|
+
details,
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
continue
|
|
320
|
+
if skip_command and verifier.type in ("command", "test"):
|
|
321
|
+
evidence.append(_skipped_evidence(metadata, rule, scope))
|
|
322
|
+
continue
|
|
323
|
+
cap = None if timeout_budget is None else max(0.1, timeout_budget - (time.monotonic() - started))
|
|
324
|
+
evidence.append(_evidence(metadata, rule, verifier, scope, confirmation_by_rule.get(rule.ref), cap))
|
|
325
|
+
result = tuple(evidence)
|
|
326
|
+
return VerificationResult(result, bool(result) and all(item.status == "verified" for item in result))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def evidence_is_fresh(
|
|
330
|
+
evidence: VerificationEvidence,
|
|
331
|
+
rule_text_sha256: str,
|
|
332
|
+
artifact_sha256: Optional[str],
|
|
333
|
+
diff_sha256: Optional[str],
|
|
334
|
+
) -> bool:
|
|
335
|
+
return (
|
|
336
|
+
evidence.status == "verified"
|
|
337
|
+
and evidence.rule_text_sha256 == rule_text_sha256
|
|
338
|
+
and evidence.artifact_sha256 == artifact_sha256
|
|
339
|
+
and evidence.diff_sha256 == diff_sha256
|
|
340
|
+
)
|
|
@@ -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,16 +18,20 @@ 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,
|
|
24
26
|
load_config,
|
|
25
27
|
normalize_path,
|
|
28
|
+
resolve_enforcement,
|
|
26
29
|
resolve_quality_loop,
|
|
27
30
|
resolve_session_id,
|
|
28
31
|
)
|
|
29
32
|
|
|
30
33
|
TOTAL_BUDGET_SECONDS = 30.0
|
|
34
|
+
GATE_BUDGET_SECONDS = 25.0
|
|
31
35
|
_BRACE_RE = re.compile(r"\{([^{}]+)\}")
|
|
32
36
|
_TASK_SECTION_RE = re.compile(
|
|
33
37
|
r"(?ms)^## (TASK-\d+):.*?(?=^## TASK-\d+:|\Z)"
|
|
@@ -243,13 +247,43 @@ def main() -> None:
|
|
|
243
247
|
if data.get("stop_hook_active"):
|
|
244
248
|
return # 已因本 hook 续跑过一轮,避免循环
|
|
245
249
|
project_root = os.getcwd()
|
|
250
|
+
sid = resolve_session_id(data)
|
|
246
251
|
config = load_config(project_root)
|
|
247
252
|
if not config:
|
|
248
253
|
return
|
|
254
|
+
enforcement = resolve_enforcement(config)
|
|
255
|
+
if enforcement == "inject":
|
|
256
|
+
return # 轻量模式:只注入不门禁,停止会话不受限
|
|
257
|
+
marker = os.path.join(project_root, ".code-flow", ".active-task.json")
|
|
258
|
+
has_active = os.path.exists(marker)
|
|
259
|
+
files = []
|
|
260
|
+
if has_active:
|
|
261
|
+
try:
|
|
262
|
+
active = load_active_task(project_root)
|
|
263
|
+
task_dir = os.path.join(project_root, active.task_dir)
|
|
264
|
+
done = run_done_gate(project_root, task_dir, budget=GATE_BUDGET_SECONDS)
|
|
265
|
+
except (OSError, ValueError) as exc:
|
|
266
|
+
if enforcement == "required":
|
|
267
|
+
payload = {"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: active task is invalid: {exc}"}
|
|
268
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
269
|
+
return
|
|
270
|
+
cf_log.append_event(project_root, "stop_check", {"gate": "invalid_active_nonfatal", "error": str(exc)}, sid)
|
|
271
|
+
files = []
|
|
272
|
+
else:
|
|
273
|
+
if done.decision != "pass":
|
|
274
|
+
reason = done.message or "当前 TASK required Spec verifier/Evidence 未通过;修复或重新对齐后再 Done。"
|
|
275
|
+
if any(item.get("error_code") == "verifier_budget_exhausted" for item in done.evidence):
|
|
276
|
+
reason += "(验证预算不足,部分 verifier 未运行,请拆分 TASK 或减少验证命令)"
|
|
277
|
+
if enforcement == "required":
|
|
278
|
+
payload = {"decision": "block", "reason": reason}
|
|
279
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
280
|
+
return
|
|
281
|
+
cf_log.append_event(project_root, "stop_check", {"gate": "blocked_nonfatal", "reason": reason}, sid)
|
|
282
|
+
files = list(done.files)
|
|
249
283
|
if not resolve_quality_loop(config)["stop_check"]:
|
|
250
284
|
return
|
|
251
|
-
|
|
252
|
-
|
|
285
|
+
if not has_active:
|
|
286
|
+
files = session_edited_files(project_root, sid)
|
|
253
287
|
if not files:
|
|
254
288
|
return
|
|
255
289
|
acceptance_failures = task_acceptance_failures(project_root, files)
|
|
@@ -260,6 +294,13 @@ def main() -> None:
|
|
|
260
294
|
failures = acceptance_failures + failures
|
|
261
295
|
if not failures:
|
|
262
296
|
return # 全过或无 validation.yml 且无验收缺口时静默
|
|
297
|
+
if enforcement == "warn":
|
|
298
|
+
cf_log.append_event(
|
|
299
|
+
project_root, "stop_check",
|
|
300
|
+
{"failures": [item["name"] for item in failures], "nonfatal": True},
|
|
301
|
+
sid,
|
|
302
|
+
)
|
|
303
|
+
return
|
|
263
304
|
payload = {"decision": "block", "reason": _reason_text(failures, truncated)}
|
|
264
305
|
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
265
306
|
except Exception as exc:
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""cf-sync: one-command canonical → deploy sync for dual-copy artifacts.
|
|
3
|
+
|
|
4
|
+
Pairs (canonical source → deployed live copy), each with an explicit include
|
|
5
|
+
prefix list — project-owned trees (specs/, tasks/) and deploy-only files
|
|
6
|
+
(config.toml, CLAUDE.md, opencode.json) are never touched:
|
|
7
|
+
|
|
8
|
+
src/core/code-flow → .code-flow (scripts/, config.yml, .version, validation.yml, .gitignore)
|
|
9
|
+
src/adapters/claude → .claude (commands/, settings.local.json)
|
|
10
|
+
src/adapters/codex → .codex (hooks.json)
|
|
11
|
+
src/adapters/codex/skills → .agents/skills (everything)
|
|
12
|
+
src/adapters/costrict → .costrict (commands/, settings.local.json)
|
|
13
|
+
src/adapters/opencode → .opencode (commands/, plugins/)
|
|
14
|
+
|
|
15
|
+
`check` (default) reports drift; `sync` copies canonical → deploy. Deploy-only
|
|
16
|
+
files are never deleted (use --prune to remove files whose source side no
|
|
17
|
+
longer contains them within the include prefixes).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import filecmp
|
|
22
|
+
import os
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
import shutil
|
|
25
|
+
import sys
|
|
26
|
+
from typing import IO, Optional, Sequence
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
PAIRS = (
|
|
30
|
+
("src/core/code-flow", ".code-flow", ("scripts/", ".version", ".gitignore")),
|
|
31
|
+
("src/adapters/claude", ".claude", ("commands/",)),
|
|
32
|
+
("src/adapters/codex", ".codex", ("hooks.json",)),
|
|
33
|
+
("src/adapters/codex/skills", ".agents/skills", ("*",)),
|
|
34
|
+
("src/adapters/costrict", ".costrict", ("commands/",)),
|
|
35
|
+
("src/adapters/opencode", ".opencode", ("commands/", "plugins/")),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_IGNORED_DIRS = {"__pycache__", ".git", "node_modules", "dist", "build"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _included(relative: str, prefixes: Sequence[str]) -> bool:
|
|
42
|
+
return any(prefix == "*" or relative.startswith(prefix) for prefix in prefixes)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _walk(source: Path) -> list[str]:
|
|
46
|
+
files = []
|
|
47
|
+
for current, dirs, names in os.walk(source):
|
|
48
|
+
dirs[:] = sorted(item for item in dirs if item not in _IGNORED_DIRS)
|
|
49
|
+
base = Path(current)
|
|
50
|
+
for name in names:
|
|
51
|
+
files.append(str((base / name).relative_to(source)))
|
|
52
|
+
return sorted(files)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _relative(relative: str) -> str:
|
|
56
|
+
return relative.replace("\\", "/")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _pair_diffs(root: Path, source_rel: str, deploy_rel: str, prefixes: Sequence[str]) -> tuple[list[str], list[str], list[str]]:
|
|
60
|
+
"""Return (differ, missing_in_deploy, deploy_only_within_prefixes)."""
|
|
61
|
+
source = root / source_rel
|
|
62
|
+
deploy = root / deploy_rel
|
|
63
|
+
differ: list[str] = []
|
|
64
|
+
missing: list[str] = []
|
|
65
|
+
for relative in _walk(source):
|
|
66
|
+
if not _included(_relative(relative), prefixes):
|
|
67
|
+
continue
|
|
68
|
+
source_file = source / relative
|
|
69
|
+
deploy_file = deploy / relative
|
|
70
|
+
if not deploy_file.exists():
|
|
71
|
+
missing.append(relative)
|
|
72
|
+
elif not filecmp.cmp(source_file, deploy_file, shallow=False):
|
|
73
|
+
differ.append(relative)
|
|
74
|
+
deploy_only: list[str] = []
|
|
75
|
+
if deploy.is_dir():
|
|
76
|
+
deploy_relative = set(_walk(deploy))
|
|
77
|
+
source_relative = {_relative(item) for item in _walk(source) if _included(_relative(item), prefixes)}
|
|
78
|
+
deploy_only = sorted(item for item in deploy_relative if item not in source_relative and _included(item, prefixes))
|
|
79
|
+
return differ, missing, deploy_only
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _report(stdout: IO[str], root: Path, verbose: bool) -> int:
|
|
83
|
+
drift = 0
|
|
84
|
+
for source_rel, deploy_rel, prefixes in PAIRS:
|
|
85
|
+
differ, missing, deploy_only = _pair_diffs(root, source_rel, deploy_rel, prefixes)
|
|
86
|
+
status = "OK " if not (differ or missing) else "DRIFT"
|
|
87
|
+
stdout.write(f"{status} {source_rel} -> {deploy_rel}\n")
|
|
88
|
+
for relative in differ:
|
|
89
|
+
stdout.write(f" diff {deploy_rel}/{relative}\n")
|
|
90
|
+
drift += 1
|
|
91
|
+
for relative in missing:
|
|
92
|
+
stdout.write(f" miss {deploy_rel}/{relative}\n")
|
|
93
|
+
drift += 1
|
|
94
|
+
if verbose and deploy_only:
|
|
95
|
+
stdout.write(f" only-in-deploy ({len(deploy_only)}):\n")
|
|
96
|
+
for relative in deploy_only[:10]:
|
|
97
|
+
stdout.write(f" {deploy_rel}/{relative}\n")
|
|
98
|
+
return drift
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _sync(root: Path, stdout: IO[str]) -> int:
|
|
102
|
+
copied = 0
|
|
103
|
+
for source_rel, deploy_rel, prefixes in PAIRS:
|
|
104
|
+
source = root / source_rel
|
|
105
|
+
deploy = root / deploy_rel
|
|
106
|
+
for relative in _walk(source):
|
|
107
|
+
if not _included(_relative(relative), prefixes):
|
|
108
|
+
continue
|
|
109
|
+
source_file = source / relative
|
|
110
|
+
deploy_file = deploy / relative
|
|
111
|
+
if deploy_file.exists() and filecmp.cmp(source_file, deploy_file, shallow=False):
|
|
112
|
+
continue
|
|
113
|
+
deploy_file.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
shutil.copy2(source_file, deploy_file)
|
|
115
|
+
stdout.write(f"sync {deploy_rel}/{relative}\n")
|
|
116
|
+
copied += 1
|
|
117
|
+
return copied
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
121
|
+
parser = argparse.ArgumentParser(prog="cf_sync.py")
|
|
122
|
+
parser.add_argument("action", choices=("check", "sync"), nargs="?", default="check")
|
|
123
|
+
parser.add_argument("--root", default=".")
|
|
124
|
+
parser.add_argument("--verbose", action="store_true")
|
|
125
|
+
args = parser.parse_args(argv)
|
|
126
|
+
root = Path(args.root).resolve()
|
|
127
|
+
if args.action == "check":
|
|
128
|
+
drift = _report(stdout, root, args.verbose)
|
|
129
|
+
if drift:
|
|
130
|
+
stdout.write(f"{drift} file(s) drifted; run `cf_sync.py sync` to deploy.\n")
|
|
131
|
+
return 1
|
|
132
|
+
stdout.write("all pairs in sync.\n")
|
|
133
|
+
return 0
|
|
134
|
+
copied = _sync(root, stdout)
|
|
135
|
+
stdout.write(f"{copied} file(s) deployed.\n")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
raise SystemExit(main())
|