@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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/claude/commands/cf-spec.md +13 -0
  3. package/src/adapters/claude/commands/cf-sync.md +25 -0
  4. package/src/adapters/claude/commands/cf-task/plan.md +40 -0
  5. package/src/adapters/claude/commands/cf-task/start.md +6 -7
  6. package/src/adapters/claude/commands/cf-task/verify-e2e.md +111 -0
  7. package/src/adapters/codex/skills/cf-spec/SKILL.md +1 -0
  8. package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
  9. package/src/adapters/codex/skills/cf-task-start/SKILL.md +6 -7
  10. package/src/adapters/costrict/commands/cf-spec.md +13 -0
  11. package/src/adapters/costrict/commands/cf-sync.md +25 -0
  12. package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
  13. package/src/adapters/costrict/commands/cf-task/start.md +6 -7
  14. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +111 -0
  15. package/src/adapters/opencode/commands/cf-spec.md +1 -0
  16. package/src/adapters/opencode/commands/cf-sync.md +29 -0
  17. package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
  18. package/src/adapters/opencode/commands/cf-task/start.md +6 -7
  19. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +111 -0
  20. package/src/adapters/opencode/plugins/code-flow/index.js +3 -3
  21. package/src/core/code-flow/config.yml +2 -1
  22. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +199 -0
  23. package/src/core/code-flow/scripts/cf_acceptance_runner.py +107 -0
  24. package/src/core/code-flow/scripts/cf_core.py +44 -1
  25. package/src/core/code-flow/scripts/cf_post_hook.py +5 -30
  26. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +41 -17
  27. package/src/core/code-flow/scripts/cf_session_state.py +37 -0
  28. package/src/core/code-flow/scripts/cf_spec_context.py +381 -29
  29. package/src/core/code-flow/scripts/cf_spec_gate.py +22 -6
  30. package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
  31. package/src/core/code-flow/scripts/cf_spec_resolver.py +78 -7
  32. package/src/core/code-flow/scripts/cf_spec_router.py +107 -10
  33. package/src/core/code-flow/scripts/cf_spec_session.py +5 -9
  34. package/src/core/code-flow/scripts/cf_spec_verify.py +166 -9
  35. package/src/core/code-flow/scripts/cf_stop_hook.py +35 -12
  36. package/src/core/code-flow/scripts/cf_sync.py +140 -0
  37. package/src/core/code-flow/scripts/cf_task_runtime.py +95 -7
  38. 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
- evidence: list[VerificationEvidence] = []
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
- evidence.append(
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
- evidence.append(_evidence(metadata, rule, verifier, scope, confirmation_by_rule.get(rule.ref)))
278
- result = tuple(evidence)
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
- 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)
266
- config = load_config(project_root)
267
- if not config:
268
- return
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
- from typing import Mapping
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
- statuses["code"] = replace(current, status=status, evidence=(*current.evidence, evidence))
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 run_done_gate(root: str, task_dir: str) -> DoneResult:
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
- result = run_all_verifiers(metadata, VerificationScope(root, scope_result.files, diff_hash))
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
- save_context(_context_path(task_dir), updated)
141
- gate = validate_stage(updated, "code")
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))