@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.
Files changed (82) hide show
  1. package/docs/migrations/spec-workflow-0.6.0.md +42 -0
  2. package/package.json +4 -2
  3. package/src/adapters/claude/CLAUDE.md +11 -17
  4. package/src/adapters/claude/commands/cf-init.md +5 -4
  5. package/src/adapters/claude/commands/cf-spec.md +63 -0
  6. package/src/adapters/claude/commands/cf-sync.md +25 -0
  7. package/src/adapters/claude/commands/cf-task/align.md +23 -1
  8. package/src/adapters/claude/commands/cf-task/archive.md +7 -0
  9. package/src/adapters/claude/commands/cf-task/plan.md +16 -0
  10. package/src/adapters/claude/commands/cf-task/prd.md +24 -0
  11. package/src/adapters/claude/commands/cf-task/start.md +13 -10
  12. package/src/adapters/claude/settings.local.json +1 -11
  13. package/src/adapters/codex/AGENTS.md +11 -17
  14. package/src/adapters/codex/hooks.json +6 -4
  15. package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
  16. package/src/adapters/codex/skills/cf-spec/SKILL.md +56 -0
  17. package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
  18. package/src/adapters/codex/skills/cf-task-align/SKILL.md +23 -1
  19. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +7 -0
  20. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +16 -0
  21. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
  22. package/src/adapters/codex/skills/cf-task-start/SKILL.md +13 -10
  23. package/src/adapters/costrict/CLAUDE.md +11 -17
  24. package/src/adapters/costrict/commands/cf-init.md +5 -4
  25. package/src/adapters/costrict/commands/cf-spec.md +63 -0
  26. package/src/adapters/costrict/commands/cf-sync.md +25 -0
  27. package/src/adapters/costrict/commands/cf-task/align.md +23 -1
  28. package/src/adapters/costrict/commands/cf-task/archive.md +7 -0
  29. package/src/adapters/costrict/commands/cf-task/plan.md +16 -0
  30. package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
  31. package/src/adapters/costrict/commands/cf-task/start.md +13 -10
  32. package/src/adapters/costrict/settings.local.json +1 -11
  33. package/src/adapters/opencode/AGENTS.md +11 -17
  34. package/src/adapters/opencode/commands/cf-init.md +4 -3
  35. package/src/adapters/opencode/commands/cf-spec.md +55 -0
  36. package/src/adapters/opencode/commands/cf-sync.md +29 -0
  37. package/src/adapters/opencode/commands/cf-task/align.md +24 -1
  38. package/src/adapters/opencode/commands/cf-task/archive.md +8 -0
  39. package/src/adapters/opencode/commands/cf-task/plan.md +17 -0
  40. package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
  41. package/src/adapters/opencode/commands/cf-task/start.md +14 -10
  42. package/src/adapters/opencode/plugins/code-flow/index.js +3 -4
  43. package/src/cli.js +51 -2
  44. package/src/core/code-flow/config.yml +14 -16
  45. package/src/core/code-flow/scripts/cf_checks.py +30 -0
  46. package/src/core/code-flow/scripts/cf_core.py +36 -256
  47. package/src/core/code-flow/scripts/cf_log.py +8 -0
  48. package/src/core/code-flow/scripts/cf_post_hook.py +6 -9
  49. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +88 -0
  50. package/src/core/code-flow/scripts/cf_session_state.py +37 -0
  51. package/src/core/code-flow/scripts/cf_spec_context.py +1470 -0
  52. package/src/core/code-flow/scripts/cf_spec_gate.py +230 -0
  53. package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
  54. package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
  55. package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
  56. package/src/core/code-flow/scripts/cf_spec_router.py +149 -0
  57. package/src/core/code-flow/scripts/cf_spec_session.py +118 -0
  58. package/src/core/code-flow/scripts/cf_spec_verify.py +340 -0
  59. package/src/core/code-flow/scripts/cf_stats.py +40 -5
  60. package/src/core/code-flow/scripts/cf_stop_hook.py +43 -2
  61. package/src/core/code-flow/scripts/cf_sync.py +140 -0
  62. package/src/core/code-flow/scripts/cf_task_runtime.py +184 -0
  63. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +115 -345
  64. package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
  65. package/src/core/code-flow/specs/backend/database.md +13 -1
  66. package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
  67. package/src/core/code-flow/specs/backend/logging.md +13 -1
  68. package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
  69. package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
  70. package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
  71. package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
  72. package/src/core/code-flow/specs/shared/design/design-frontend.md +10 -0
  73. package/src/core/code-flow/specs/shared/design/design-full.md +10 -0
  74. package/src/core/code-flow/specs/shared/design/design-lite.md +10 -0
  75. package/src/core/code-flow/specs/shared/prd-template.md +14 -3
  76. package/src/migrate/spec-workflow.js +238 -0
  77. package/src/adapters/claude/commands/cf-inject.md +0 -55
  78. package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
  79. package/src/adapters/costrict/commands/cf-inject.md +0 -55
  80. package/src/adapters/opencode/commands/cf-inject.md +0 -59
  81. package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
  82. 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 Exception:
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": inject_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
- f"mode={inject_mode},",
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
- sid = resolve_session_id(data)
252
- files = session_edited_files(project_root, sid)
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())