@michaelxwb/code-flow 0.5.2 → 0.6.0

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 (76) 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 +50 -0
  6. package/src/adapters/claude/commands/cf-task/align.md +23 -1
  7. package/src/adapters/claude/commands/cf-task/archive.md +7 -0
  8. package/src/adapters/claude/commands/cf-task/plan.md +16 -0
  9. package/src/adapters/claude/commands/cf-task/prd.md +24 -0
  10. package/src/adapters/claude/commands/cf-task/start.md +13 -10
  11. package/src/adapters/claude/settings.local.json +1 -11
  12. package/src/adapters/codex/AGENTS.md +11 -17
  13. package/src/adapters/codex/hooks.json +6 -4
  14. package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
  15. package/src/adapters/codex/skills/cf-spec/SKILL.md +55 -0
  16. package/src/adapters/codex/skills/cf-task-align/SKILL.md +23 -1
  17. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +7 -0
  18. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +16 -0
  19. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
  20. package/src/adapters/codex/skills/cf-task-start/SKILL.md +13 -10
  21. package/src/adapters/costrict/CLAUDE.md +11 -17
  22. package/src/adapters/costrict/commands/cf-init.md +5 -4
  23. package/src/adapters/costrict/commands/cf-spec.md +50 -0
  24. package/src/adapters/costrict/commands/cf-task/align.md +23 -1
  25. package/src/adapters/costrict/commands/cf-task/archive.md +7 -0
  26. package/src/adapters/costrict/commands/cf-task/plan.md +16 -0
  27. package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
  28. package/src/adapters/costrict/commands/cf-task/start.md +13 -10
  29. package/src/adapters/costrict/settings.local.json +1 -11
  30. package/src/adapters/opencode/AGENTS.md +11 -17
  31. package/src/adapters/opencode/commands/cf-init.md +4 -3
  32. package/src/adapters/opencode/commands/cf-spec.md +54 -0
  33. package/src/adapters/opencode/commands/cf-task/align.md +24 -1
  34. package/src/adapters/opencode/commands/cf-task/archive.md +8 -0
  35. package/src/adapters/opencode/commands/cf-task/plan.md +17 -0
  36. package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
  37. package/src/adapters/opencode/commands/cf-task/start.md +14 -10
  38. package/src/adapters/opencode/plugins/code-flow/index.js +0 -1
  39. package/src/cli.js +51 -2
  40. package/src/core/code-flow/config.yml +13 -16
  41. package/src/core/code-flow/scripts/cf_checks.py +30 -0
  42. package/src/core/code-flow/scripts/cf_core.py +12 -255
  43. package/src/core/code-flow/scripts/cf_log.py +8 -0
  44. package/src/core/code-flow/scripts/cf_post_hook.py +33 -9
  45. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +65 -0
  46. package/src/core/code-flow/scripts/cf_spec_context.py +1233 -0
  47. package/src/core/code-flow/scripts/cf_spec_gate.py +224 -0
  48. package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
  49. package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
  50. package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
  51. package/src/core/code-flow/scripts/cf_spec_router.py +145 -0
  52. package/src/core/code-flow/scripts/cf_spec_session.py +122 -0
  53. package/src/core/code-flow/scripts/cf_spec_verify.py +293 -0
  54. package/src/core/code-flow/scripts/cf_stats.py +40 -5
  55. package/src/core/code-flow/scripts/cf_stop_hook.py +22 -2
  56. package/src/core/code-flow/scripts/cf_task_runtime.py +142 -0
  57. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +34 -360
  58. package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
  59. package/src/core/code-flow/specs/backend/database.md +13 -1
  60. package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
  61. package/src/core/code-flow/specs/backend/logging.md +13 -1
  62. package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
  63. package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
  64. package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
  65. package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
  66. package/src/core/code-flow/specs/shared/design/design-frontend.md +10 -0
  67. package/src/core/code-flow/specs/shared/design/design-full.md +10 -0
  68. package/src/core/code-flow/specs/shared/design/design-lite.md +10 -0
  69. package/src/core/code-flow/specs/shared/prd-template.md +14 -3
  70. package/src/migrate/spec-workflow.js +238 -0
  71. package/src/adapters/claude/commands/cf-inject.md +0 -55
  72. package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
  73. package/src/adapters/costrict/commands/cf-inject.md +0 -55
  74. package/src/adapters/opencode/commands/cf-inject.md +0 -59
  75. package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
  76. package/src/core/code-flow/scripts/cf_session_hook.py +0 -42
@@ -0,0 +1,293 @@
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
+ from typing import Mapping, Optional, Sequence
15
+
16
+ from cf_checks import run_regex_verifier
17
+ from cf_spec_metadata import SpecMetadata, SpecRule, SpecVerifier
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class VerificationScope:
22
+ root: str
23
+ files: tuple[str, ...]
24
+ diff_sha256: Optional[str]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class VerificationEvidence:
29
+ verifier_ref: str
30
+ executed_at: str
31
+ status: str
32
+ rule_text_sha256: str
33
+ artifact_sha256: Optional[str]
34
+ diff_sha256: Optional[str]
35
+ result_sha256: str
36
+ error_code: Optional[str]
37
+ details: Mapping[str, object]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class VerificationResult:
42
+ evidence: tuple[VerificationEvidence, ...]
43
+ passed: bool
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class _Outcome:
48
+ passed: bool
49
+ error_code: Optional[str]
50
+ artifact_sha256: Optional[str]
51
+ details: Mapping[str, object]
52
+
53
+
54
+ def _hash_bytes(data: bytes) -> str:
55
+ return hashlib.sha256(data).hexdigest()
56
+
57
+
58
+ def _result_hash(status: str, error: Optional[str], details: Mapping[str, object]) -> str:
59
+ payload = {"status": status, "error_code": error, "details": details}
60
+ encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
61
+ return _hash_bytes(encoded)
62
+
63
+
64
+ def _config_string(config: Mapping[str, object], key: str) -> Optional[str]:
65
+ value = config.get(key)
66
+ return value if isinstance(value, str) and value else None
67
+
68
+
69
+ def _read_scope_files(scope: VerificationScope) -> tuple[dict[str, str], Optional[_Outcome]]:
70
+ contents: dict[str, str] = {}
71
+ for relative in scope.files:
72
+ path = Path(scope.root) / relative
73
+ try:
74
+ contents[relative] = path.read_text(encoding="utf-8")
75
+ except (OSError, UnicodeError) as exc:
76
+ outcome = _Outcome(False, "file_read_error", None, {"file": relative, "error": str(exc)})
77
+ return {}, outcome
78
+ return contents, None
79
+
80
+
81
+ def _document(config: Mapping[str, object], scope: VerificationScope) -> _Outcome:
82
+ relative = _config_string(config, "artifact")
83
+ section = _config_string(config, "section_id")
84
+ item = _config_string(config, "item_id")
85
+ if not relative or not section or not item:
86
+ return _Outcome(False, "invalid_verifier_config", None, {"required": ["artifact", "section_id", "item_id"]})
87
+ path = Path(scope.root) / relative
88
+ try:
89
+ content = path.read_text(encoding="utf-8")
90
+ artifact_hash = _hash_bytes(path.read_bytes())
91
+ except (OSError, UnicodeError) as exc:
92
+ return _Outcome(False, "artifact_read_error", None, {"artifact": relative, "error": str(exc)})
93
+ expected = _config_string(config, "artifact_sha256")
94
+ present = section in content and item in content
95
+ hash_matches = expected is None or expected == artifact_hash
96
+ details = {"artifact": relative, "section_present": section in content, "item_present": item in content, "hash_matches": hash_matches}
97
+ return _Outcome(present and hash_matches, None if present and hash_matches else "document_mismatch", artifact_hash, details)
98
+
99
+
100
+ def _regex(config: Mapping[str, object], metadata: SpecMetadata, scope: VerificationScope) -> _Outcome:
101
+ pattern = _config_string(config, "pattern")
102
+ files = _config_string(config, "files") or "*"
103
+ check_id = _config_string(config, "check_id")
104
+ if check_id:
105
+ check = next((item for item in metadata.checks if item.get("id") == check_id), None)
106
+ if check is None:
107
+ return _Outcome(False, "check_not_found", None, {"check_id": check_id})
108
+ pattern = check.get("pattern") if isinstance(check.get("pattern"), str) else None
109
+ files = check.get("files") if isinstance(check.get("files"), str) else "*"
110
+ if pattern is None:
111
+ return _Outcome(False, "invalid_verifier_config", None, {"required": ["pattern"]})
112
+ selected = tuple(
113
+ relative
114
+ for relative in scope.files
115
+ if fnmatch.fnmatch(relative, files) and (Path(scope.root) / relative).is_file()
116
+ )
117
+ contents, error = _read_scope_files(VerificationScope(scope.root, selected, scope.diff_sha256))
118
+ if error is not None:
119
+ return error
120
+ violations, skipped = run_regex_verifier(pattern, files, contents)
121
+ if skipped:
122
+ return _Outcome(False, "regex_unavailable", None, {"skipped": skipped})
123
+ details = {"checked_files": len(contents), "violations": violations}
124
+ return _Outcome(not violations, None if not violations else "regex_violation", None, details)
125
+
126
+
127
+ def _call_name(node: ast.Call) -> str:
128
+ if isinstance(node.func, ast.Name):
129
+ return node.func.id
130
+ if isinstance(node.func, ast.Attribute):
131
+ return node.func.attr
132
+ return ""
133
+
134
+
135
+ def _ast(config: Mapping[str, object], scope: VerificationScope) -> _Outcome:
136
+ if _config_string(config, "language") != "python":
137
+ return _Outcome(False, "unsupported_ast_language", None, {"language": config.get("language")})
138
+ assertion = _config_string(config, "assertion") or "parse"
139
+ contents, error = _read_scope_files(scope)
140
+ if error is not None:
141
+ return error
142
+ parsed: list[ast.AST] = []
143
+ try:
144
+ parsed = [ast.parse(content, filename=relative) for relative, content in contents.items()]
145
+ except SyntaxError as exc:
146
+ return _Outcome(False, "ast_parse_error", None, {"error": str(exc)})
147
+ if assertion == "parse":
148
+ return _Outcome(True, None, None, {"parsed_files": len(parsed)})
149
+ if assertion == "forbid_call":
150
+ call = _config_string(config, "call")
151
+ hits = sum(_call_name(node) == call for tree in parsed for node in ast.walk(tree) if isinstance(node, ast.Call))
152
+ return _Outcome(hits == 0, None if hits == 0 else "ast_assertion_failed", None, {"call": call, "hits": hits})
153
+ return _Outcome(False, "unsupported_ast_assertion", None, {"assertion": assertion})
154
+
155
+
156
+ def _argv(config: Mapping[str, object]) -> Optional[list[str]]:
157
+ value = config.get("argv")
158
+ if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value):
159
+ return None
160
+ return list(value)
161
+
162
+
163
+ def _command(config: Mapping[str, object], scope: VerificationScope) -> _Outcome:
164
+ argv = _argv(config)
165
+ if argv is None:
166
+ return _Outcome(False, "invalid_verifier_config", None, {"required": ["argv"]})
167
+ cwd = _config_string(config, "cwd") or "."
168
+ timeout_value = config.get("timeout", 30)
169
+ timeout = float(timeout_value) if isinstance(timeout_value, (int, float)) else 30.0
170
+ allowed_value = config.get("allowed_exit_codes", [0])
171
+ allowed = tuple(item for item in allowed_value if isinstance(item, int)) if isinstance(allowed_value, list) else (0,)
172
+ try:
173
+ completed = subprocess.run(
174
+ argv,
175
+ cwd=str(Path(scope.root) / cwd),
176
+ capture_output=True,
177
+ text=True,
178
+ timeout=timeout,
179
+ check=False,
180
+ )
181
+ except subprocess.TimeoutExpired:
182
+ return _Outcome(False, "verifier_timeout", None, {"timeout": timeout, "argv": argv})
183
+ except OSError as exc:
184
+ return _Outcome(False, "command_unavailable", None, {"argv": argv, "error": str(exc)})
185
+ details = {
186
+ "argv": argv,
187
+ "exit_code": completed.returncode,
188
+ "stdout_sha256": _hash_bytes(completed.stdout.encode()),
189
+ "stderr_sha256": _hash_bytes(completed.stderr.encode()),
190
+ }
191
+ passed = completed.returncode in allowed
192
+ return _Outcome(passed, None if passed else "command_failed", None, details)
193
+
194
+
195
+ def _manual(config: Mapping[str, object], confirmation: Optional[Mapping[str, object]]) -> _Outcome:
196
+ if confirmation is None:
197
+ return _Outcome(False, "manual_confirmation_missing", None, {"checklist": config.get("checklist")})
198
+ required = ("reason", "confirmed_by", "confirmed_at", "source")
199
+ if not all(isinstance(confirmation.get(key), str) and confirmation.get(key) for key in required):
200
+ return _Outcome(False, "manual_confirmation_invalid", None, {"required": list(required)})
201
+ identity = str(confirmation["confirmed_by"]).lower().split(":", 1)[0]
202
+ if identity in ("agent", "assistant", "codex", "claude", "opencode", "costrict"):
203
+ return _Outcome(False, "manual_agent_forbidden", None, {"confirmed_by": confirmation["confirmed_by"]})
204
+ return _Outcome(True, None, None, {"source": confirmation["source"], "owner": config.get("owner")})
205
+
206
+
207
+ def _run(
208
+ verifier: SpecVerifier,
209
+ metadata: SpecMetadata,
210
+ scope: VerificationScope,
211
+ confirmation: Optional[Mapping[str, object]],
212
+ ) -> _Outcome:
213
+ if verifier.type == "document":
214
+ return _document(verifier.config, scope)
215
+ if verifier.type == "regex":
216
+ return _regex(verifier.config, metadata, scope)
217
+ if verifier.type == "ast":
218
+ return _ast(verifier.config, scope)
219
+ if verifier.type in ("command", "test"):
220
+ return _command(verifier.config, scope)
221
+ if verifier.type == "manual":
222
+ return _manual(verifier.config, confirmation)
223
+ return _Outcome(False, "verifier_type_unimplemented", None, {"type": verifier.type})
224
+
225
+
226
+ def _evidence(
227
+ metadata: SpecMetadata,
228
+ rule: SpecRule,
229
+ verifier: SpecVerifier,
230
+ scope: VerificationScope,
231
+ confirmation: Optional[Mapping[str, object]],
232
+ ) -> VerificationEvidence:
233
+ outcome = _run(verifier, metadata, scope, confirmation)
234
+ status = "verified" if outcome.passed else "unverified"
235
+ diff_hash = None if verifier.type == "document" else scope.diff_sha256
236
+ return VerificationEvidence(
237
+ f"{metadata.id}#{rule.ref}",
238
+ datetime.now(timezone.utc).isoformat(),
239
+ status,
240
+ rule.text_sha256,
241
+ outcome.artifact_sha256,
242
+ diff_hash,
243
+ _result_hash(status, outcome.error_code, outcome.details),
244
+ outcome.error_code,
245
+ outcome.details,
246
+ )
247
+
248
+
249
+ def run_all_verifiers(
250
+ metadata: SpecMetadata,
251
+ scope: VerificationScope,
252
+ confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
253
+ ) -> VerificationResult:
254
+ verifier_by_rule = {item.rule: item for item in metadata.verifiers}
255
+ confirmation_by_rule = confirmations or {}
256
+ evidence: list[VerificationEvidence] = []
257
+ for rule in metadata.rules:
258
+ if rule.enforcement != "required":
259
+ continue
260
+ verifier = verifier_by_rule.get(rule.ref)
261
+ if verifier is None:
262
+ details = {"rule": rule.ref}
263
+ evidence.append(
264
+ VerificationEvidence(
265
+ f"{metadata.id}#{rule.ref}",
266
+ datetime.now(timezone.utc).isoformat(),
267
+ "unverified",
268
+ rule.text_sha256,
269
+ None,
270
+ scope.diff_sha256,
271
+ _result_hash("unverified", "verifier_missing", details),
272
+ "verifier_missing",
273
+ details,
274
+ )
275
+ )
276
+ continue
277
+ evidence.append(_evidence(metadata, rule, verifier, scope, confirmation_by_rule.get(rule.ref)))
278
+ result = tuple(evidence)
279
+ return VerificationResult(result, bool(result) and all(item.status == "verified" for item in result))
280
+
281
+
282
+ def evidence_is_fresh(
283
+ evidence: VerificationEvidence,
284
+ rule_text_sha256: str,
285
+ artifact_sha256: Optional[str],
286
+ diff_sha256: Optional[str],
287
+ ) -> bool:
288
+ return (
289
+ evidence.status == "verified"
290
+ and evidence.rule_text_sha256 == rule_text_sha256
291
+ and evidence.artifact_sha256 == artifact_sha256
292
+ and evidence.diff_sha256 == diff_sha256
293
+ )
@@ -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,6 +18,8 @@ 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,
@@ -243,13 +245,31 @@ def main() -> None:
243
245
  if data.get("stop_hook_active"):
244
246
  return # 已因本 hook 续跑过一轮,避免循环
245
247
  project_root = os.getcwd()
248
+ sid = resolve_session_id(data)
249
+ marker = os.path.join(project_root, ".code-flow", ".active-task.json")
250
+ has_active = os.path.exists(marker)
251
+ files = []
252
+ if has_active:
253
+ try:
254
+ active = load_active_task(project_root)
255
+ task_dir = os.path.join(project_root, active.task_dir)
256
+ done = run_done_gate(project_root, task_dir)
257
+ 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)
246
266
  config = load_config(project_root)
247
267
  if not config:
248
268
  return
249
269
  if not resolve_quality_loop(config)["stop_check"]:
250
270
  return
251
- sid = resolve_session_id(data)
252
- files = session_edited_files(project_root, sid)
271
+ if not has_active:
272
+ files = session_edited_files(project_root, sid)
253
273
  if not files:
254
274
  return
255
275
  acceptance_failures = task_acceptance_failures(project_root, files)
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env python3
2
+ """Active-task scope expansion and hash-bound Done verification."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, replace
7
+ import hashlib
8
+ from pathlib import Path
9
+ from typing import Mapping
10
+
11
+ from cf_spec_context import (
12
+ BindingInput,
13
+ RuleBinding,
14
+ RuleStageStatus,
15
+ SpecBinding,
16
+ SpecContext,
17
+ bind_specs,
18
+ current_owned_paths,
19
+ load_active_task,
20
+ load_context,
21
+ pause_active_task,
22
+ save_context,
23
+ )
24
+ from cf_spec_gate import validate_stage
25
+ from cf_spec_metadata import load_spec_metadata
26
+ from cf_spec_resolver import resolve_candidates, SpecCandidate
27
+ from cf_spec_verify import VerificationEvidence, VerificationScope, run_all_verifiers
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ScopeResult:
32
+ decision: str
33
+ files: tuple[str, ...]
34
+ new_specs: tuple[str, ...]
35
+ message: str
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class DoneResult:
40
+ decision: str
41
+ files: tuple[str, ...]
42
+ evidence: tuple[Mapping[str, object], ...]
43
+
44
+
45
+ def _context_path(task_dir: str) -> str:
46
+ return str(Path(task_dir) / "spec-context.yml")
47
+
48
+
49
+ def _required_candidate(candidate: SpecCandidate) -> bool:
50
+ metadata = candidate.metadata
51
+ return metadata.enforcement == "required" and any(
52
+ rule.enforcement == "required" for rule in metadata.rules
53
+ )
54
+
55
+
56
+ def evaluate_scope(root: str, task_dir: str) -> ScopeResult:
57
+ active = load_active_task(root)
58
+ files = current_owned_paths(root, active)
59
+ context = load_context(_context_path(task_dir))
60
+ candidates = resolve_candidates(root, "code", files)
61
+ existing = {binding.spec_id for binding in context.bindings}
62
+ new = tuple(
63
+ candidate
64
+ for candidate in candidates
65
+ if candidate.scope == "path" and candidate.spec_id not in existing
66
+ )
67
+ if not new:
68
+ return ScopeResult("continue", files, (), "scope unchanged")
69
+ selections = tuple(
70
+ BindingInput(candidate, "scope:path", f"Git diff matched {','.join(candidate.matched_paths)}")
71
+ for candidate in new
72
+ )
73
+ save_context(_context_path(task_dir), bind_specs(context, selections))
74
+ required = tuple(candidate.spec_id for candidate in new if _required_candidate(candidate))
75
+ if required:
76
+ pause_active_task(root)
77
+ message = "新增 required Spec 已暂停 TASK;请选择局部 Plan 或回 Align 更新设计"
78
+ return ScopeResult("pause", files, required, message)
79
+ return ScopeResult("continue", files, tuple(candidate.spec_id for candidate in new), "advisory scope expanded")
80
+
81
+
82
+ def _diff_hash(root: str, files: tuple[str, ...]) -> str:
83
+ digest = hashlib.sha256()
84
+ for relative in files:
85
+ path = Path(root) / relative
86
+ digest.update(relative.encode())
87
+ digest.update(path.read_bytes() if path.is_file() else b"<deleted>")
88
+ return digest.hexdigest()
89
+
90
+
91
+ def _evidence_data(evidence: VerificationEvidence) -> Mapping[str, object]:
92
+ return {
93
+ "verifier_ref": evidence.verifier_ref,
94
+ "executed_at": evidence.executed_at,
95
+ "status": evidence.status,
96
+ "rule_text_sha256": evidence.rule_text_sha256,
97
+ "artifact_sha256": evidence.artifact_sha256,
98
+ "diff_sha256": evidence.diff_sha256,
99
+ "result_sha256": evidence.result_sha256,
100
+ "error_code": evidence.error_code,
101
+ "details": evidence.details,
102
+ }
103
+
104
+
105
+ def _update_rule(rule: RuleBinding, evidence: Mapping[str, object]) -> RuleBinding:
106
+ if "code" not in rule.stage_status:
107
+ return rule
108
+ statuses = dict(rule.stage_status)
109
+ current = statuses["code"]
110
+ status = "verified" if evidence.get("status") == "verified" else "unverified"
111
+ statuses["code"] = replace(current, status=status, evidence=(*current.evidence, evidence))
112
+ return replace(rule, stage_status=statuses)
113
+
114
+
115
+ def _apply_evidence(context: SpecContext, evidence: tuple[Mapping[str, object], ...]) -> SpecContext:
116
+ by_ref = {str(item["verifier_ref"]): item for item in evidence}
117
+ bindings: list[SpecBinding] = []
118
+ for binding in context.bindings:
119
+ rules = tuple(
120
+ _update_rule(rule, by_ref[f"{binding.spec_id}#{rule.ref}"])
121
+ if f"{binding.spec_id}#{rule.ref}" in by_ref else rule
122
+ for rule in binding.rules
123
+ )
124
+ bindings.append(replace(binding, rules=rules))
125
+ return replace(context, bindings=tuple(bindings))
126
+
127
+
128
+ def run_done_gate(root: str, task_dir: str) -> DoneResult:
129
+ scope_result = evaluate_scope(root, task_dir)
130
+ if scope_result.decision == "pause":
131
+ return DoneResult("block", scope_result.files, ())
132
+ context = load_context(_context_path(task_dir))
133
+ diff_hash = _diff_hash(root, scope_result.files)
134
+ all_evidence: list[Mapping[str, object]] = []
135
+ for binding in context.bindings:
136
+ 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))
138
+ all_evidence.extend(_evidence_data(item) for item in result.evidence)
139
+ updated = _apply_evidence(context, tuple(all_evidence))
140
+ save_context(_context_path(task_dir), updated)
141
+ gate = validate_stage(updated, "code")
142
+ return DoneResult(gate.decision, scope_result.files, tuple(all_evidence))