@michaelxwb/code-flow 0.6.0 → 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.
@@ -25,11 +25,13 @@ 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,
30
31
  )
31
32
 
32
33
  TOTAL_BUDGET_SECONDS = 30.0
34
+ GATE_BUDGET_SECONDS = 25.0
33
35
  _BRACE_RE = re.compile(r"\{([^{}]+)\}")
34
36
  _TASK_SECTION_RE = re.compile(
35
37
  r"(?ms)^## (TASK-\d+):.*?(?=^## TASK-\d+:|\Z)"
@@ -246,6 +248,12 @@ def main() -> None:
246
248
  return # 已因本 hook 续跑过一轮,避免循环
247
249
  project_root = os.getcwd()
248
250
  sid = resolve_session_id(data)
251
+ config = load_config(project_root)
252
+ if not config:
253
+ return
254
+ enforcement = resolve_enforcement(config)
255
+ if enforcement == "inject":
256
+ return # 轻量模式:只注入不门禁,停止会话不受限
249
257
  marker = os.path.join(project_root, ".code-flow", ".active-task.json")
250
258
  has_active = os.path.exists(marker)
251
259
  files = []
@@ -253,19 +261,25 @@ def main() -> None:
253
261
  try:
254
262
  active = load_active_task(project_root)
255
263
  task_dir = os.path.join(project_root, active.task_dir)
256
- done = run_done_gate(project_root, task_dir)
264
+ done = run_done_gate(project_root, task_dir, budget=GATE_BUDGET_SECONDS)
257
265
  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
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)
269
283
  if not resolve_quality_loop(config)["stop_check"]:
270
284
  return
271
285
  if not has_active:
@@ -280,6 +294,13 @@ def main() -> None:
280
294
  failures = acceptance_failures + failures
281
295
  if not failures:
282
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
283
304
  payload = {"decision": "block", "reason": _reason_text(failures, truncated)}
284
305
  sys.stdout.write(json.dumps(payload, ensure_ascii=False))
285
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())
@@ -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,11 +20,13 @@ 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
28
31
 
29
32
 
@@ -40,6 +43,7 @@ class DoneResult:
40
43
  decision: str
41
44
  files: tuple[str, ...]
42
45
  evidence: tuple[Mapping[str, object], ...]
46
+ message: str = ""
43
47
 
44
48
 
45
49
  def _context_path(task_dir: str) -> str:
@@ -71,6 +75,9 @@ def evaluate_scope(root: str, task_dir: str) -> ScopeResult:
71
75
  for candidate in new
72
76
  )
73
77
  save_context(_context_path(task_dir), bind_specs(context, selections))
78
+ # The toolchain expanded the context itself; re-sync the marker so this
79
+ # automatic change does not surface as active_context_drift with no recovery.
80
+ resync_active_hash(root, task_dir, context_sha256(load_context(_context_path(task_dir))))
74
81
  required = tuple(candidate.spec_id for candidate in new if _required_candidate(candidate))
75
82
  if required:
76
83
  pause_active_task(root)
@@ -107,8 +114,20 @@ def _update_rule(rule: RuleBinding, evidence: Mapping[str, object]) -> RuleBindi
107
114
  return rule
108
115
  statuses = dict(rule.stage_status)
109
116
  current = statuses["code"]
117
+ if current.status in ("not_applicable", "waived"):
118
+ return rule
110
119
  status = "verified" if evidence.get("status") == "verified" else "unverified"
111
- statuses["code"] = replace(current, status=status, evidence=(*current.evidence, evidence))
120
+ if evidence.get("error_code") == "skipped_in_cheap_gate":
121
+ signature = (evidence.get("verifier_ref"), evidence.get("result_sha256"), None)
122
+ else:
123
+ signature = (evidence.get("verifier_ref"), evidence.get("result_sha256"), evidence.get("diff_sha256"))
124
+ if any(
125
+ (item.get("verifier_ref"), item.get("result_sha256"), item.get("diff_sha256")) == signature
126
+ for item in current.evidence
127
+ ):
128
+ statuses["code"] = replace(current, status=status)
129
+ else:
130
+ statuses["code"] = replace(current, status=status, evidence=(*current.evidence, evidence))
112
131
  return replace(rule, stage_status=statuses)
113
132
 
114
133
 
@@ -125,18 +144,41 @@ def _apply_evidence(context: SpecContext, evidence: tuple[Mapping[str, object],
125
144
  return replace(context, bindings=tuple(bindings))
126
145
 
127
146
 
128
- def run_done_gate(root: str, task_dir: str) -> DoneResult:
147
+ def _rule_manual_confirmation(rule: RuleBinding) -> Optional[Mapping[str, object]]:
148
+ status = rule.stage_status.get("code")
149
+ if status is None or status.decision is None or status.decision.kind != "manual_verification":
150
+ return None
151
+ decision = status.decision
152
+ return {
153
+ "reason": decision.reason,
154
+ "confirmed_by": decision.confirmed_by,
155
+ "confirmed_at": decision.confirmed_at,
156
+ "source": decision.source,
157
+ }
158
+
159
+
160
+ def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None) -> DoneResult:
129
161
  scope_result = evaluate_scope(root, task_dir)
130
162
  if scope_result.decision == "pause":
131
- return DoneResult("block", scope_result.files, ())
163
+ return DoneResult("block", scope_result.files, (), scope_result.message)
132
164
  context = load_context(_context_path(task_dir))
133
165
  diff_hash = _diff_hash(root, scope_result.files)
134
166
  all_evidence: list[Mapping[str, object]] = []
167
+ started = time.monotonic()
135
168
  for binding in context.bindings:
136
169
  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))
170
+ confirmations: dict[str, Mapping[str, object]] = {}
171
+ for rule in binding.rules:
172
+ confirmation = _rule_manual_confirmation(rule)
173
+ if confirmation is not None:
174
+ confirmations[rule.ref] = confirmation
175
+ remaining = None if budget is None else budget - (time.monotonic() - started)
176
+ result = run_all_verifiers(
177
+ metadata, VerificationScope(root, scope_result.files, diff_hash), confirmations, cheap, remaining
178
+ )
138
179
  all_evidence.extend(_evidence_data(item) for item in result.evidence)
139
180
  updated = _apply_evidence(context, tuple(all_evidence))
140
- save_context(_context_path(task_dir), updated)
141
- gate = validate_stage(updated, "code")
181
+ if updated != context:
182
+ save_context(_context_path(task_dir), updated)
183
+ gate = validate_stage(updated, "code", diff_sha256=diff_hash)
142
184
  return DoneResult(gate.decision, scope_result.files, tuple(all_evidence))
@@ -8,13 +8,29 @@ import sys
8
8
 
9
9
  import cf_log
10
10
  from cf_checks import detect_correction
11
- from cf_core import _log, ensure_utf8_io, normalize_path, resolve_quality_loop, resolve_session_id, load_config
11
+ from cf_core import (
12
+ _log,
13
+ ensure_utf8_io,
14
+ load_config,
15
+ normalize_path,
16
+ resolve_enforcement,
17
+ resolve_quality_loop,
18
+ resolve_session_id,
19
+ )
20
+ from cf_session_state import load_session_state, save_session_state
12
21
  from cf_spec_router import RouterError, route_prompt
13
22
 
14
23
 
15
24
  _PATH_RE = re.compile(r'[@`]?([a-zA-Z0-9_.][a-zA-Z0-9_./\\\-]*\.[a-zA-Z]{1,6})(?![a-zA-Z0-9_])')
16
25
  _EXT_RE = re.compile(r'\.(py|js|ts|go|rs|java|rb|cs|cpp|c|h)$')
17
26
 
27
+ _COMPRESS_INTERVAL = 25
28
+ _REINJECT_INTERVAL = 25
29
+ _REMINDER_TEXT = (
30
+ "⚠ 本会话已处理较多轮次,上下文较长;批量任务建议在任务间压缩上下文,或拆到新会话继续。"
31
+ )
32
+ _WARN_TEXT = "⚠ Spec Workflow 校验未通过(warn 模式):{message} — 建议运行 cf-spec doctor 检查。"
33
+
18
34
 
19
35
  def extract_paths_from_prompt(prompt: str) -> list[str]:
20
36
  """Extract explicit file paths without semantic keyword inference."""
@@ -46,6 +62,49 @@ def _record_correction(root: str, prompt: str, paths: list[str], sid: str) -> No
46
62
  cf_log.append_event(root, "correction", {"phrase": correction["phrase"], "prompt_head": prompt[:200], "files": paths}, sid)
47
63
 
48
64
 
65
+ def _session_reminder(root: str, sid: str) -> str:
66
+ """One-line context-compress nudge once per COMPRESS_INTERVAL prompts per session.
67
+
68
+ Gated by quality_loop.compress_reminder; disabled → zero IO. Never raises: a
69
+ corrupt state file degrades to no reminder rather than breaking the hook.
70
+ """
71
+ config = load_config(root)
72
+ if not config or not resolve_quality_loop(config)["compress_reminder"]:
73
+ return ""
74
+ state = load_session_state(root)
75
+ try:
76
+ if state.get("session_id") != sid:
77
+ state = {"session_id": sid, "prompt_count": 0, "next_remind_at": _COMPRESS_INTERVAL}
78
+ state["prompt_count"] = int(state.get("prompt_count", 0)) + 1
79
+ reminder = ""
80
+ if state["prompt_count"] >= int(state.get("next_remind_at", _COMPRESS_INTERVAL)):
81
+ reminder = _REMINDER_TEXT
82
+ state["next_remind_at"] = int(state["next_remind_at"]) + _COMPRESS_INTERVAL
83
+ except (ValueError, TypeError):
84
+ return ""
85
+ save_session_state(root, state)
86
+ return reminder
87
+
88
+
89
+ def _route_with_enforcement(root: str, paths: list[str], sid: str, enforcement: str) -> tuple[object, object]:
90
+ """Route with enforcement-aware failure handling.
91
+
92
+ required — fail closed with the SPEC_WORKFLOW_BLOCKED message;
93
+ warn — degrade to a short advisory message;
94
+ inject — degrade silently.
95
+ Returns (result, error_payload); exactly one is not None.
96
+ """
97
+ try:
98
+ return route_prompt(root, paths, sid), None
99
+ except RouterError as exc:
100
+ if enforcement == "required":
101
+ message = f"SPEC_WORKFLOW_BLOCKED [{exc.code}]: {exc}. Run cf-spec doctor before continuing."
102
+ return None, _payload(message, "blocked")
103
+ if enforcement == "warn":
104
+ return None, _payload(_WARN_TEXT.format(message=str(exc)), "warn")
105
+ return None, None
106
+
107
+
49
108
  def main() -> None:
50
109
  try:
51
110
  ensure_utf8_io()
@@ -58,16 +117,53 @@ def main() -> None:
58
117
  return
59
118
  root = os.getcwd()
60
119
  sid = resolve_session_id(data)
120
+ config = load_config(root)
121
+ enforcement = resolve_enforcement(config) if config else "required"
61
122
  paths = extract_paths_from_prompt(prompt)
62
123
  _record_correction(root, prompt, paths, sid)
63
- result = route_prompt(root, paths, sid)
64
- if not result.text:
124
+ result, error_payload = _route_with_enforcement(root, paths, sid, enforcement)
125
+ if error_payload is not None:
126
+ sys.stdout.write(json.dumps(error_payload, ensure_ascii=False))
65
127
  return
66
- cf_log.append_event(root, "inject", {"specs": list(result.specs), "mode": result.mode, "source": result.mode}, sid)
67
- sys.stdout.write(json.dumps(_payload(result.text, result.mode), ensure_ascii=False))
68
- except RouterError as exc:
69
- message = f"SPEC_WORKFLOW_BLOCKED [{exc.code}]: {exc}. Run cf-spec doctor before continuing."
70
- sys.stdout.write(json.dumps(_payload(message, "blocked"), ensure_ascii=False))
128
+ if result is None:
129
+ return
130
+ reminder = ""
131
+ if config and resolve_quality_loop(config)["compress_reminder"]:
132
+ reminder = _session_reminder(root, sid)
133
+ text = result.text
134
+ if result.mode == "task" and result.context_sha256:
135
+ # Dedup: inject the projection once per session/context, refreshing
136
+ # at the compress cadence so long sessions do not re-pay 12k chars
137
+ # on every prompt.
138
+ state = load_session_state(root)
139
+ count = int(state.get("prompt_count", 0))
140
+ dirty = False
141
+ if not (config and resolve_quality_loop(config)["compress_reminder"]):
142
+ # compress_reminder off → _session_reminder never bumps the
143
+ # counter; maintain the cadence here so the periodic refresh
144
+ # still fires after compaction.
145
+ count += 1
146
+ state["prompt_count"] = count
147
+ dirty = True
148
+ injected = state.get("injected_sha256")
149
+ last_inject = int(state.get("last_inject_prompt", 0))
150
+ if injected == result.context_sha256 and count - last_inject < _REINJECT_INTERVAL:
151
+ text = ""
152
+ else:
153
+ state["injected_sha256"] = result.context_sha256
154
+ state["last_inject_prompt"] = count
155
+ dirty = True
156
+ if dirty:
157
+ save_session_state(root, state)
158
+ if not text and not reminder:
159
+ return
160
+ if text:
161
+ cf_log.append_event(root, "inject", {"specs": list(result.specs), "mode": result.mode, "source": result.mode}, sid)
162
+ if text and reminder:
163
+ text = text.rstrip() + "\n\n" + reminder
164
+ elif reminder:
165
+ text = reminder
166
+ sys.stdout.write(json.dumps(_payload(text, result.mode if text else "reminder"), ensure_ascii=False))
71
167
  except (json.JSONDecodeError, UnicodeError, OSError, ValueError) as exc:
72
168
  _log(f"cf_user_prompt_hook error: {exc}")
73
169