@michaelxwb/code-flow 0.6.0 → 0.6.2
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/package.json +1 -1
- package/src/adapters/claude/commands/cf-spec.md +13 -0
- package/src/adapters/claude/commands/cf-sync.md +25 -0
- package/src/adapters/claude/commands/cf-task/plan.md +40 -0
- package/src/adapters/claude/commands/cf-task/start.md +6 -7
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +111 -0
- package/src/adapters/codex/skills/cf-spec/SKILL.md +1 -0
- package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +6 -7
- package/src/adapters/costrict/commands/cf-spec.md +13 -0
- package/src/adapters/costrict/commands/cf-sync.md +25 -0
- package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
- package/src/adapters/costrict/commands/cf-task/start.md +6 -7
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +111 -0
- package/src/adapters/opencode/commands/cf-spec.md +1 -0
- package/src/adapters/opencode/commands/cf-sync.md +29 -0
- package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
- package/src/adapters/opencode/commands/cf-task/start.md +6 -7
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +111 -0
- package/src/adapters/opencode/plugins/code-flow/index.js +3 -3
- package/src/core/code-flow/config.yml +2 -1
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +199 -0
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +107 -0
- package/src/core/code-flow/scripts/cf_core.py +44 -1
- package/src/core/code-flow/scripts/cf_post_hook.py +5 -30
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +41 -17
- package/src/core/code-flow/scripts/cf_session_state.py +37 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +381 -29
- package/src/core/code-flow/scripts/cf_spec_gate.py +22 -6
- package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
- package/src/core/code-flow/scripts/cf_spec_resolver.py +78 -7
- package/src/core/code-flow/scripts/cf_spec_router.py +107 -10
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -9
- package/src/core/code-flow/scripts/cf_spec_verify.py +166 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +35 -12
- package/src/core/code-flow/scripts/cf_sync.py +140 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +95 -7
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +106 -8
|
@@ -11,12 +11,23 @@ import hashlib
|
|
|
11
11
|
import json
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
import subprocess
|
|
14
|
+
from threading import Lock
|
|
15
|
+
import time
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
17
|
from typing import Mapping, Optional, Sequence
|
|
15
18
|
|
|
16
19
|
from cf_checks import run_regex_verifier
|
|
17
20
|
from cf_spec_metadata import SpecMetadata, SpecRule, SpecVerifier
|
|
18
21
|
|
|
19
22
|
|
|
23
|
+
_RESULT_CACHE: dict[str, dict[str, object]] = {}
|
|
24
|
+
_RESULT_CACHE_ROOT: Optional[str] = None
|
|
25
|
+
_RESULT_CACHE_DIRTY = False
|
|
26
|
+
_RESULT_CACHE_LOCK = Lock()
|
|
27
|
+
_MAX_RESULT_CACHE_ENTRIES = 2048
|
|
28
|
+
_PURE_VERIFIER_TYPES = frozenset(("document", "regex", "ast"))
|
|
29
|
+
|
|
30
|
+
|
|
20
31
|
@dataclass(frozen=True)
|
|
21
32
|
class VerificationScope:
|
|
22
33
|
root: str
|
|
@@ -43,6 +54,94 @@ class VerificationResult:
|
|
|
43
54
|
passed: bool
|
|
44
55
|
|
|
45
56
|
|
|
57
|
+
def _cache_file(root: str) -> Path:
|
|
58
|
+
return Path(root) / ".code-flow" / ".verifier-cache.json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]]) -> str:
|
|
62
|
+
payload = {
|
|
63
|
+
"spec": metadata.hashes.file_sha256,
|
|
64
|
+
"rule": rule.text_sha256,
|
|
65
|
+
"verifier": {"type": verifier.type, "config": verifier.config},
|
|
66
|
+
"files": scope.files,
|
|
67
|
+
"diff": scope.diff_sha256,
|
|
68
|
+
"confirmation": confirmation or {},
|
|
69
|
+
}
|
|
70
|
+
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_result_cache(root: str) -> None:
|
|
74
|
+
global _RESULT_CACHE_ROOT, _RESULT_CACHE, _RESULT_CACHE_DIRTY
|
|
75
|
+
if _RESULT_CACHE_ROOT == root:
|
|
76
|
+
return
|
|
77
|
+
_RESULT_CACHE_ROOT = root
|
|
78
|
+
_RESULT_CACHE = {}
|
|
79
|
+
_RESULT_CACHE_DIRTY = False
|
|
80
|
+
path = _cache_file(root)
|
|
81
|
+
try:
|
|
82
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
83
|
+
if isinstance(loaded, dict):
|
|
84
|
+
_RESULT_CACHE.update({str(key): value for key, value in loaded.items() if isinstance(value, dict)})
|
|
85
|
+
except (OSError, ValueError):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cached_evidence(key: str) -> Optional[VerificationEvidence]:
|
|
90
|
+
item = _RESULT_CACHE.get(key)
|
|
91
|
+
if not isinstance(item, dict) or item.get("status") != "verified":
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
return VerificationEvidence(**item)
|
|
95
|
+
except (TypeError, ValueError):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _save_result_cache(key: str, evidence: VerificationEvidence) -> None:
|
|
100
|
+
global _RESULT_CACHE_DIRTY
|
|
101
|
+
with _RESULT_CACHE_LOCK:
|
|
102
|
+
_RESULT_CACHE[key] = evidence.__dict__
|
|
103
|
+
_RESULT_CACHE_DIRTY = True
|
|
104
|
+
while len(_RESULT_CACHE) > _MAX_RESULT_CACHE_ENTRIES:
|
|
105
|
+
_RESULT_CACHE.pop(next(iter(_RESULT_CACHE)))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _flush_result_cache(root: str) -> None:
|
|
109
|
+
global _RESULT_CACHE_DIRTY
|
|
110
|
+
with _RESULT_CACHE_LOCK:
|
|
111
|
+
if not _RESULT_CACHE_DIRTY:
|
|
112
|
+
return
|
|
113
|
+
path = _cache_file(root)
|
|
114
|
+
try:
|
|
115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
temporary = path.with_suffix(".json.tmp")
|
|
117
|
+
temporary.write_text(
|
|
118
|
+
json.dumps(_RESULT_CACHE, ensure_ascii=False, sort_keys=True, default=str), encoding="utf-8"
|
|
119
|
+
)
|
|
120
|
+
temporary.replace(path)
|
|
121
|
+
_RESULT_CACHE_DIRTY = False
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cached_or_run(
|
|
127
|
+
metadata: SpecMetadata,
|
|
128
|
+
rule: SpecRule,
|
|
129
|
+
verifier: SpecVerifier,
|
|
130
|
+
scope: VerificationScope,
|
|
131
|
+
confirmation: Optional[Mapping[str, object]],
|
|
132
|
+
) -> VerificationEvidence:
|
|
133
|
+
cacheable = verifier.type in _PURE_VERIFIER_TYPES
|
|
134
|
+
key = _cache_key(metadata, rule, verifier, scope, confirmation) if cacheable else ""
|
|
135
|
+
if cacheable:
|
|
136
|
+
cached = _cached_evidence(key)
|
|
137
|
+
if cached is not None:
|
|
138
|
+
return cached
|
|
139
|
+
current = _evidence(metadata, rule, verifier, scope, confirmation)
|
|
140
|
+
if cacheable and current.status == "verified":
|
|
141
|
+
_save_result_cache(key, current)
|
|
142
|
+
return current
|
|
143
|
+
|
|
144
|
+
|
|
46
145
|
@dataclass(frozen=True)
|
|
47
146
|
class _Outcome:
|
|
48
147
|
passed: bool
|
|
@@ -160,13 +259,15 @@ def _argv(config: Mapping[str, object]) -> Optional[list[str]]:
|
|
|
160
259
|
return list(value)
|
|
161
260
|
|
|
162
261
|
|
|
163
|
-
def _command(config: Mapping[str, object], scope: VerificationScope) -> _Outcome:
|
|
262
|
+
def _command(config: Mapping[str, object], scope: VerificationScope, timeout_cap: Optional[float] = None) -> _Outcome:
|
|
164
263
|
argv = _argv(config)
|
|
165
264
|
if argv is None:
|
|
166
265
|
return _Outcome(False, "invalid_verifier_config", None, {"required": ["argv"]})
|
|
167
266
|
cwd = _config_string(config, "cwd") or "."
|
|
168
267
|
timeout_value = config.get("timeout", 30)
|
|
169
268
|
timeout = float(timeout_value) if isinstance(timeout_value, (int, float)) else 30.0
|
|
269
|
+
if timeout_cap is not None:
|
|
270
|
+
timeout = min(timeout, timeout_cap)
|
|
170
271
|
allowed_value = config.get("allowed_exit_codes", [0])
|
|
171
272
|
allowed = tuple(item for item in allowed_value if isinstance(item, int)) if isinstance(allowed_value, list) else (0,)
|
|
172
273
|
try:
|
|
@@ -209,6 +310,7 @@ def _run(
|
|
|
209
310
|
metadata: SpecMetadata,
|
|
210
311
|
scope: VerificationScope,
|
|
211
312
|
confirmation: Optional[Mapping[str, object]],
|
|
313
|
+
timeout_cap: Optional[float] = None,
|
|
212
314
|
) -> _Outcome:
|
|
213
315
|
if verifier.type == "document":
|
|
214
316
|
return _document(verifier.config, scope)
|
|
@@ -217,7 +319,7 @@ def _run(
|
|
|
217
319
|
if verifier.type == "ast":
|
|
218
320
|
return _ast(verifier.config, scope)
|
|
219
321
|
if verifier.type in ("command", "test"):
|
|
220
|
-
return _command(verifier.config, scope)
|
|
322
|
+
return _command(verifier.config, scope, timeout_cap)
|
|
221
323
|
if verifier.type == "manual":
|
|
222
324
|
return _manual(verifier.config, confirmation)
|
|
223
325
|
return _Outcome(False, "verifier_type_unimplemented", None, {"type": verifier.type})
|
|
@@ -229,8 +331,9 @@ def _evidence(
|
|
|
229
331
|
verifier: SpecVerifier,
|
|
230
332
|
scope: VerificationScope,
|
|
231
333
|
confirmation: Optional[Mapping[str, object]],
|
|
334
|
+
timeout_cap: Optional[float] = None,
|
|
232
335
|
) -> VerificationEvidence:
|
|
233
|
-
outcome = _run(verifier, metadata, scope, confirmation)
|
|
336
|
+
outcome = _run(verifier, metadata, scope, confirmation, timeout_cap)
|
|
234
337
|
status = "verified" if outcome.passed else "unverified"
|
|
235
338
|
diff_hash = None if verifier.type == "document" else scope.diff_sha256
|
|
236
339
|
return VerificationEvidence(
|
|
@@ -246,22 +349,61 @@ def _evidence(
|
|
|
246
349
|
)
|
|
247
350
|
|
|
248
351
|
|
|
352
|
+
def _skipped_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope) -> VerificationEvidence:
|
|
353
|
+
details = {"skipped": True}
|
|
354
|
+
return VerificationEvidence(
|
|
355
|
+
f"{metadata.id}#{rule.ref}",
|
|
356
|
+
datetime.now(timezone.utc).isoformat(),
|
|
357
|
+
"unverified",
|
|
358
|
+
rule.text_sha256,
|
|
359
|
+
None,
|
|
360
|
+
scope.diff_sha256,
|
|
361
|
+
_result_hash("unverified", "skipped_in_cheap_gate", details),
|
|
362
|
+
"skipped_in_cheap_gate",
|
|
363
|
+
details,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _budget_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope, budget: float) -> VerificationEvidence:
|
|
368
|
+
details = {"budget": budget}
|
|
369
|
+
return VerificationEvidence(
|
|
370
|
+
f"{metadata.id}#{rule.ref}",
|
|
371
|
+
datetime.now(timezone.utc).isoformat(),
|
|
372
|
+
"unverified",
|
|
373
|
+
rule.text_sha256,
|
|
374
|
+
None,
|
|
375
|
+
scope.diff_sha256,
|
|
376
|
+
_result_hash("unverified", "verifier_budget_exhausted", details),
|
|
377
|
+
"verifier_budget_exhausted",
|
|
378
|
+
details,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
249
382
|
def run_all_verifiers(
|
|
250
383
|
metadata: SpecMetadata,
|
|
251
384
|
scope: VerificationScope,
|
|
252
385
|
confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
|
|
386
|
+
skip_command: bool = False,
|
|
387
|
+
timeout_budget: Optional[float] = None,
|
|
253
388
|
) -> VerificationResult:
|
|
254
389
|
verifier_by_rule = {item.rule: item for item in metadata.verifiers}
|
|
255
390
|
confirmation_by_rule = confirmations or {}
|
|
256
|
-
|
|
391
|
+
_load_result_cache(scope.root)
|
|
392
|
+
evidence_by_ref: dict[str, VerificationEvidence] = {}
|
|
393
|
+
parallel: list[tuple[SpecRule, SpecVerifier, Optional[Mapping[str, object]]]] = []
|
|
394
|
+
started = time.monotonic()
|
|
257
395
|
for rule in metadata.rules:
|
|
258
396
|
if rule.enforcement != "required":
|
|
259
397
|
continue
|
|
398
|
+
if timeout_budget is not None:
|
|
399
|
+
remaining = timeout_budget - (time.monotonic() - started)
|
|
400
|
+
if remaining <= 0:
|
|
401
|
+
evidence_by_ref[rule.ref] = _budget_evidence(metadata, rule, scope, timeout_budget)
|
|
402
|
+
continue
|
|
260
403
|
verifier = verifier_by_rule.get(rule.ref)
|
|
261
404
|
if verifier is None:
|
|
262
405
|
details = {"rule": rule.ref}
|
|
263
|
-
|
|
264
|
-
VerificationEvidence(
|
|
406
|
+
evidence_by_ref[rule.ref] = VerificationEvidence(
|
|
265
407
|
f"{metadata.id}#{rule.ref}",
|
|
266
408
|
datetime.now(timezone.utc).isoformat(),
|
|
267
409
|
"unverified",
|
|
@@ -272,10 +414,25 @@ def run_all_verifiers(
|
|
|
272
414
|
"verifier_missing",
|
|
273
415
|
details,
|
|
274
416
|
)
|
|
275
|
-
)
|
|
276
417
|
continue
|
|
277
|
-
|
|
278
|
-
|
|
418
|
+
if skip_command and verifier.type in ("command", "test"):
|
|
419
|
+
evidence_by_ref[rule.ref] = _skipped_evidence(metadata, rule, scope)
|
|
420
|
+
continue
|
|
421
|
+
confirmation = confirmation_by_rule.get(rule.ref)
|
|
422
|
+
if timeout_budget is None and verifier.type in ("document", "regex", "ast"):
|
|
423
|
+
parallel.append((rule, verifier, confirmation))
|
|
424
|
+
continue
|
|
425
|
+
cap = None if timeout_budget is None else max(0.1, timeout_budget - (time.monotonic() - started))
|
|
426
|
+
evidence_by_ref[rule.ref] = _cached_or_run(metadata, rule, verifier, scope, confirmation) if cap is None else _evidence(metadata, rule, verifier, scope, confirmation, cap)
|
|
427
|
+
if parallel:
|
|
428
|
+
with ThreadPoolExecutor(max_workers=min(8, len(parallel))) as pool:
|
|
429
|
+
futures = {
|
|
430
|
+
rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation)
|
|
431
|
+
for rule, verifier, confirmation in parallel
|
|
432
|
+
}
|
|
433
|
+
evidence_by_ref.update({ref: future.result() for ref, future in futures.items()})
|
|
434
|
+
result = tuple(evidence_by_ref[rule.ref] for rule in metadata.rules if rule.ref in evidence_by_ref)
|
|
435
|
+
_flush_result_cache(scope.root)
|
|
279
436
|
return VerificationResult(result, bool(result) and all(item.status == "verified" for item in result))
|
|
280
437
|
|
|
281
438
|
|
|
@@ -25,11 +25,14 @@ from cf_core import (
|
|
|
25
25
|
ensure_utf8_io,
|
|
26
26
|
load_config,
|
|
27
27
|
normalize_path,
|
|
28
|
+
resolve_enforcement,
|
|
28
29
|
resolve_quality_loop,
|
|
29
30
|
resolve_session_id,
|
|
31
|
+
timing_log,
|
|
30
32
|
)
|
|
31
33
|
|
|
32
34
|
TOTAL_BUDGET_SECONDS = 30.0
|
|
35
|
+
GATE_BUDGET_SECONDS = 25.0
|
|
33
36
|
_BRACE_RE = re.compile(r"\{([^{}]+)\}")
|
|
34
37
|
_TASK_SECTION_RE = re.compile(
|
|
35
38
|
r"(?ms)^## (TASK-\d+):.*?(?=^## TASK-\d+:|\Z)"
|
|
@@ -246,6 +249,12 @@ def main() -> None:
|
|
|
246
249
|
return # 已因本 hook 续跑过一轮,避免循环
|
|
247
250
|
project_root = os.getcwd()
|
|
248
251
|
sid = resolve_session_id(data)
|
|
252
|
+
config = load_config(project_root)
|
|
253
|
+
if not config:
|
|
254
|
+
return
|
|
255
|
+
enforcement = resolve_enforcement(config)
|
|
256
|
+
if enforcement == "inject":
|
|
257
|
+
return # 轻量模式:只注入不门禁,停止会话不受限
|
|
249
258
|
marker = os.path.join(project_root, ".code-flow", ".active-task.json")
|
|
250
259
|
has_active = os.path.exists(marker)
|
|
251
260
|
files = []
|
|
@@ -253,19 +262,25 @@ def main() -> None:
|
|
|
253
262
|
try:
|
|
254
263
|
active = load_active_task(project_root)
|
|
255
264
|
task_dir = os.path.join(project_root, active.task_dir)
|
|
256
|
-
done = run_done_gate(project_root, task_dir)
|
|
265
|
+
done = run_done_gate(project_root, task_dir, budget=GATE_BUDGET_SECONDS)
|
|
257
266
|
except (OSError, ValueError) as exc:
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
267
|
+
if enforcement == "required":
|
|
268
|
+
payload = {"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: active task is invalid: {exc}"}
|
|
269
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
270
|
+
return
|
|
271
|
+
cf_log.append_event(project_root, "stop_check", {"gate": "invalid_active_nonfatal", "error": str(exc)}, sid)
|
|
272
|
+
files = []
|
|
273
|
+
else:
|
|
274
|
+
if done.decision != "pass":
|
|
275
|
+
reason = done.message or "当前 TASK required Spec verifier/Evidence 未通过;修复或重新对齐后再 Done。"
|
|
276
|
+
if any(item.get("error_code") == "verifier_budget_exhausted" for item in done.evidence):
|
|
277
|
+
reason += "(验证预算不足,部分 verifier 未运行,请拆分 TASK 或减少验证命令)"
|
|
278
|
+
if enforcement == "required":
|
|
279
|
+
payload = {"decision": "block", "reason": reason}
|
|
280
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
281
|
+
return
|
|
282
|
+
cf_log.append_event(project_root, "stop_check", {"gate": "blocked_nonfatal", "reason": reason}, sid)
|
|
283
|
+
files = list(done.files)
|
|
269
284
|
if not resolve_quality_loop(config)["stop_check"]:
|
|
270
285
|
return
|
|
271
286
|
if not has_active:
|
|
@@ -280,6 +295,13 @@ def main() -> None:
|
|
|
280
295
|
failures = acceptance_failures + failures
|
|
281
296
|
if not failures:
|
|
282
297
|
return # 全过或无 validation.yml 且无验收缺口时静默
|
|
298
|
+
if enforcement == "warn":
|
|
299
|
+
cf_log.append_event(
|
|
300
|
+
project_root, "stop_check",
|
|
301
|
+
{"failures": [item["name"] for item in failures], "nonfatal": True},
|
|
302
|
+
sid,
|
|
303
|
+
)
|
|
304
|
+
return
|
|
283
305
|
payload = {"decision": "block", "reason": _reason_text(failures, truncated)}
|
|
284
306
|
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
285
307
|
except Exception as exc:
|
|
@@ -289,3 +311,4 @@ def main() -> None:
|
|
|
289
311
|
|
|
290
312
|
if __name__ == "__main__":
|
|
291
313
|
main()
|
|
314
|
+
timing_log("cf_stop_hook")
|
|
@@ -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())
|
|
@@ -6,7 +6,8 @@ from __future__ import annotations
|
|
|
6
6
|
from dataclasses import dataclass, replace
|
|
7
7
|
import hashlib
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
|
|
9
|
+
import time
|
|
10
|
+
from typing import Mapping, Optional
|
|
10
11
|
|
|
11
12
|
from cf_spec_context import (
|
|
12
13
|
BindingInput,
|
|
@@ -19,12 +20,15 @@ from cf_spec_context import (
|
|
|
19
20
|
load_active_task,
|
|
20
21
|
load_context,
|
|
21
22
|
pause_active_task,
|
|
23
|
+
resync_active_hash,
|
|
22
24
|
save_context,
|
|
23
25
|
)
|
|
24
26
|
from cf_spec_gate import validate_stage
|
|
25
27
|
from cf_spec_metadata import load_spec_metadata
|
|
26
28
|
from cf_spec_resolver import resolve_candidates, SpecCandidate
|
|
29
|
+
from cf_spec_session import context_sha256
|
|
27
30
|
from cf_spec_verify import VerificationEvidence, VerificationScope, run_all_verifiers
|
|
31
|
+
from cf_core import phase_timing
|
|
28
32
|
|
|
29
33
|
|
|
30
34
|
@dataclass(frozen=True)
|
|
@@ -40,12 +44,31 @@ class DoneResult:
|
|
|
40
44
|
decision: str
|
|
41
45
|
files: tuple[str, ...]
|
|
42
46
|
evidence: tuple[Mapping[str, object], ...]
|
|
47
|
+
message: str = ""
|
|
43
48
|
|
|
44
49
|
|
|
45
50
|
def _context_path(task_dir: str) -> str:
|
|
46
51
|
return str(Path(task_dir) / "spec-context.yml")
|
|
47
52
|
|
|
48
53
|
|
|
54
|
+
def _manual_manifest_issue(task_dir: str) -> str:
|
|
55
|
+
path = Path(task_dir) / ".acceptance-manifest.json"
|
|
56
|
+
if not path.is_file():
|
|
57
|
+
return ""
|
|
58
|
+
try:
|
|
59
|
+
import json
|
|
60
|
+
|
|
61
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
pending = [
|
|
63
|
+
item.get("id", "unknown")
|
|
64
|
+
for item in data.get("scenarios", [])
|
|
65
|
+
if isinstance(item, dict) and item.get("kind") == "manual" and item.get("status") != "verified"
|
|
66
|
+
]
|
|
67
|
+
return f"manual 场景未完成: {', '.join(pending)}" if pending else ""
|
|
68
|
+
except (OSError, ValueError, TypeError):
|
|
69
|
+
return "acceptance manifest invalid"
|
|
70
|
+
|
|
71
|
+
|
|
49
72
|
def _required_candidate(candidate: SpecCandidate) -> bool:
|
|
50
73
|
metadata = candidate.metadata
|
|
51
74
|
return metadata.enforcement == "required" and any(
|
|
@@ -71,6 +94,9 @@ def evaluate_scope(root: str, task_dir: str) -> ScopeResult:
|
|
|
71
94
|
for candidate in new
|
|
72
95
|
)
|
|
73
96
|
save_context(_context_path(task_dir), bind_specs(context, selections))
|
|
97
|
+
# The toolchain expanded the context itself; re-sync the marker so this
|
|
98
|
+
# automatic change does not surface as active_context_drift with no recovery.
|
|
99
|
+
resync_active_hash(root, task_dir, context_sha256(load_context(_context_path(task_dir))))
|
|
74
100
|
required = tuple(candidate.spec_id for candidate in new if _required_candidate(candidate))
|
|
75
101
|
if required:
|
|
76
102
|
pause_active_task(root)
|
|
@@ -107,8 +133,20 @@ def _update_rule(rule: RuleBinding, evidence: Mapping[str, object]) -> RuleBindi
|
|
|
107
133
|
return rule
|
|
108
134
|
statuses = dict(rule.stage_status)
|
|
109
135
|
current = statuses["code"]
|
|
136
|
+
if current.status in ("not_applicable", "waived"):
|
|
137
|
+
return rule
|
|
110
138
|
status = "verified" if evidence.get("status") == "verified" else "unverified"
|
|
111
|
-
|
|
139
|
+
if evidence.get("error_code") == "skipped_in_cheap_gate":
|
|
140
|
+
signature = (evidence.get("verifier_ref"), evidence.get("result_sha256"), None)
|
|
141
|
+
else:
|
|
142
|
+
signature = (evidence.get("verifier_ref"), evidence.get("result_sha256"), evidence.get("diff_sha256"))
|
|
143
|
+
if any(
|
|
144
|
+
(item.get("verifier_ref"), item.get("result_sha256"), item.get("diff_sha256")) == signature
|
|
145
|
+
for item in current.evidence
|
|
146
|
+
):
|
|
147
|
+
statuses["code"] = replace(current, status=status)
|
|
148
|
+
else:
|
|
149
|
+
statuses["code"] = replace(current, status=status, evidence=(*current.evidence, evidence))
|
|
112
150
|
return replace(rule, stage_status=statuses)
|
|
113
151
|
|
|
114
152
|
|
|
@@ -125,18 +163,68 @@ def _apply_evidence(context: SpecContext, evidence: tuple[Mapping[str, object],
|
|
|
125
163
|
return replace(context, bindings=tuple(bindings))
|
|
126
164
|
|
|
127
165
|
|
|
128
|
-
def
|
|
166
|
+
def _rule_manual_confirmation(rule: RuleBinding) -> Optional[Mapping[str, object]]:
|
|
167
|
+
status = rule.stage_status.get("code")
|
|
168
|
+
if status is None or status.decision is None or status.decision.kind != "manual_verification":
|
|
169
|
+
return None
|
|
170
|
+
decision = status.decision
|
|
171
|
+
return {
|
|
172
|
+
"reason": decision.reason,
|
|
173
|
+
"confirmed_by": decision.confirmed_by,
|
|
174
|
+
"confirmed_at": decision.confirmed_at,
|
|
175
|
+
"source": decision.source,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False) -> DoneResult:
|
|
180
|
+
started = time.monotonic()
|
|
181
|
+
phase_started = time.monotonic()
|
|
129
182
|
scope_result = evaluate_scope(root, task_dir)
|
|
183
|
+
phase_timing("done.evaluate_scope", phase_started)
|
|
130
184
|
if scope_result.decision == "pause":
|
|
131
|
-
return DoneResult("block", scope_result.files, ())
|
|
185
|
+
return DoneResult("block", scope_result.files, (), scope_result.message)
|
|
186
|
+
manual_issue = _manual_manifest_issue(task_dir)
|
|
187
|
+
if manual_issue:
|
|
188
|
+
return DoneResult("block", scope_result.files, (), manual_issue)
|
|
189
|
+
manifest_path = Path(task_dir) / ".acceptance-manifest.json"
|
|
190
|
+
if manifest_path.is_file():
|
|
191
|
+
try:
|
|
192
|
+
import json
|
|
193
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
194
|
+
has_commands = any(
|
|
195
|
+
isinstance(item, dict) and isinstance(item.get("command"), list)
|
|
196
|
+
for item in manifest.get("scenarios", [])
|
|
197
|
+
)
|
|
198
|
+
except (OSError, ValueError, TypeError):
|
|
199
|
+
return DoneResult("block", scope_result.files, (), "acceptance manifest invalid")
|
|
200
|
+
if has_commands:
|
|
201
|
+
from cf_acceptance_runner import run_manifest
|
|
202
|
+
|
|
203
|
+
scenario_result = run_manifest(str(manifest_path), root, write_evidence=True, include_e2e=include_e2e)
|
|
204
|
+
if scenario_result["decision"] == "block":
|
|
205
|
+
return DoneResult("block", scope_result.files, (), "acceptance scenario failed")
|
|
206
|
+
phase_started = time.monotonic()
|
|
132
207
|
context = load_context(_context_path(task_dir))
|
|
133
208
|
diff_hash = _diff_hash(root, scope_result.files)
|
|
209
|
+
phase_timing("done.load_context_and_diff", phase_started)
|
|
134
210
|
all_evidence: list[Mapping[str, object]] = []
|
|
135
211
|
for binding in context.bindings:
|
|
212
|
+
phase_started = time.monotonic()
|
|
136
213
|
metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / binding.path))
|
|
137
|
-
|
|
214
|
+
confirmations: dict[str, Mapping[str, object]] = {}
|
|
215
|
+
for rule in binding.rules:
|
|
216
|
+
confirmation = _rule_manual_confirmation(rule)
|
|
217
|
+
if confirmation is not None:
|
|
218
|
+
confirmations[rule.ref] = confirmation
|
|
219
|
+
remaining = None if budget is None else budget - (time.monotonic() - started)
|
|
220
|
+
result = run_all_verifiers(
|
|
221
|
+
metadata, VerificationScope(root, scope_result.files, diff_hash), confirmations, cheap, remaining
|
|
222
|
+
)
|
|
223
|
+
phase_timing(f"done.verify.{binding.spec_id}", phase_started)
|
|
138
224
|
all_evidence.extend(_evidence_data(item) for item in result.evidence)
|
|
139
225
|
updated = _apply_evidence(context, tuple(all_evidence))
|
|
140
|
-
|
|
141
|
-
|
|
226
|
+
if updated != context:
|
|
227
|
+
save_context(_context_path(task_dir), updated)
|
|
228
|
+
gate = validate_stage(updated, "code", diff_sha256=diff_hash)
|
|
229
|
+
phase_timing("done.total", started)
|
|
142
230
|
return DoneResult(gate.decision, scope_result.files, tuple(all_evidence))
|