@michaelxwb/code-flow 0.6.4 → 0.8.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 (207) hide show
  1. package/README.md +47 -3
  2. package/docs/USAGE.md +1529 -0
  3. package/docs/acceptance-contract.md +217 -0
  4. package/docs/ci/acceptance-evidence.md +77 -0
  5. package/docs/migrations/runtime-0.7.0.md +41 -0
  6. package/docs/migrations/runtime-0.8.0.md +154 -0
  7. package/docs/migrations/runtime-0.9.0.md +40 -0
  8. package/docs/spec-injection-hardening.md +54 -0
  9. package/docs/spec-injection-performance.json +73 -0
  10. package/docs/spec-optimization.md +12 -0
  11. package/package.json +18 -3
  12. package/src/adapters/claude/CLAUDE.md +1 -1
  13. package/src/adapters/claude/commands/cf-init.md +1 -3
  14. package/src/adapters/claude/commands/cf-spec.md +23 -3
  15. package/src/adapters/claude/commands/cf-stats.md +11 -1
  16. package/src/adapters/claude/commands/cf-sync.md +20 -6
  17. package/src/adapters/claude/commands/cf-task/align.md +25 -3
  18. package/src/adapters/claude/commands/cf-task/archive.md +36 -40
  19. package/src/adapters/claude/commands/cf-task/block.md +15 -2
  20. package/src/adapters/claude/commands/cf-task/graph.md +13 -1
  21. package/src/adapters/claude/commands/cf-task/note.md +13 -1
  22. package/src/adapters/claude/commands/cf-task/plan.md +137 -23
  23. package/src/adapters/claude/commands/cf-task/prd.md +13 -2
  24. package/src/adapters/claude/commands/cf-task/start.md +67 -35
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
  27. package/src/adapters/claude/commands/cf-validate.md +30 -5
  28. package/src/adapters/codex/AGENTS.md +1 -1
  29. package/src/adapters/codex/hooks.json +14 -10
  30. package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
  31. package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
  32. package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
  33. package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
  34. package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
  35. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -40
  36. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
  37. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
  38. package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
  39. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
  40. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
  41. package/src/adapters/codex/skills/cf-task-start/SKILL.md +67 -35
  42. package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
  43. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
  44. package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
  45. package/src/adapters/commands.json +115 -0
  46. package/src/adapters/costrict/CLAUDE.md +1 -1
  47. package/src/adapters/costrict/commands/cf-init.md +1 -3
  48. package/src/adapters/costrict/commands/cf-spec.md +23 -3
  49. package/src/adapters/costrict/commands/cf-stats.md +11 -1
  50. package/src/adapters/costrict/commands/cf-sync.md +20 -6
  51. package/src/adapters/costrict/commands/cf-task/align.md +25 -3
  52. package/src/adapters/costrict/commands/cf-task/archive.md +36 -40
  53. package/src/adapters/costrict/commands/cf-task/block.md +15 -2
  54. package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
  55. package/src/adapters/costrict/commands/cf-task/note.md +13 -1
  56. package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
  57. package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
  58. package/src/adapters/costrict/commands/cf-task/start.md +67 -35
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
  61. package/src/adapters/costrict/commands/cf-validate.md +30 -5
  62. package/src/adapters/install-plan.json +100 -0
  63. package/src/adapters/opencode/AGENTS.md +1 -1
  64. package/src/adapters/opencode/commands/cf-init.md +7 -7
  65. package/src/adapters/opencode/commands/cf-learn.md +6 -6
  66. package/src/adapters/opencode/commands/cf-spec.md +34 -2
  67. package/src/adapters/opencode/commands/cf-stats.md +17 -7
  68. package/src/adapters/opencode/commands/cf-sync.md +21 -6
  69. package/src/adapters/opencode/commands/cf-task/align.md +25 -8
  70. package/src/adapters/opencode/commands/cf-task/archive.md +36 -40
  71. package/src/adapters/opencode/commands/cf-task/block.md +16 -2
  72. package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
  73. package/src/adapters/opencode/commands/cf-task/note.md +14 -1
  74. package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
  75. package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
  76. package/src/adapters/opencode/commands/cf-task/start.md +68 -41
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
  79. package/src/adapters/opencode/commands/cf-validate.md +35 -9
  80. package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
  81. package/src/adapters/platform-bindings.json +370 -0
  82. package/src/cli.js +369 -316
  83. package/src/core/code-flow/config.yml +4 -1
  84. package/src/core/code-flow/runtime-commands.json +259 -0
  85. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
  86. package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
  87. package/src/core/code-flow/scripts/cf_audit.py +10 -0
  88. package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
  89. package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
  90. package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
  91. package/src/core/code-flow/scripts/cf_feedback.py +5 -51
  92. package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
  93. package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
  94. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
  95. package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
  96. package/src/core/code-flow/scripts/cf_spec_context.py +5 -1714
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
  98. package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
  99. package/src/core/code-flow/scripts/cf_stats.py +5 -501
  100. package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
  101. package/src/core/code-flow/scripts/cf_sync.py +5 -135
  102. package/src/core/code-flow/scripts/cf_task_index.py +5 -143
  103. package/src/core/code-flow/scripts/cf_task_parallel.py +5 -380
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
  105. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
  106. package/src/core/code-flow/scripts/cf_validation.py +6 -77
  107. package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
  108. package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
  109. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
  110. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
  111. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
  112. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
  113. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
  114. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
  115. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
  116. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
  117. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
  118. package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
  119. package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
  120. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
  121. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
  122. package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
  123. package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
  124. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
  125. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
  126. package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
  127. package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
  128. package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
  129. package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
  130. package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
  131. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
  132. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
  133. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
  134. package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
  135. package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
  136. package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
  137. package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
  138. package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
  139. package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
  140. package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
  141. package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
  142. package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
  143. package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
  144. package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
  145. package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
  146. package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
  147. package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
  148. package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
  149. package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
  150. package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
  151. package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
  152. package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
  153. package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
  154. package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
  155. package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
  156. package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
  157. package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
  158. package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
  159. package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
  160. package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
  161. package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
  162. package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
  163. package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
  164. package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
  165. package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
  166. package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
  167. package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
  168. package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
  169. package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
  170. package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
  171. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
  172. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
  173. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
  174. package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +37 -3
  175. package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
  176. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
  177. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
  178. package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
  179. package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
  180. package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +252 -26
  181. package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
  182. package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
  183. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
  184. package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
  185. package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
  186. package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
  187. package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
  188. package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
  189. package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
  190. package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
  191. package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
  192. package/src/core/code-flow/scripts/runtime-resources.json +29 -0
  193. package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
  194. package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
  195. package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
  196. package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
  197. package/src/core/code-flow/validation.yml +9 -3
  198. package/src/migrate/runtime.js +213 -0
  199. package/src/migrate/spec-workflow.js +16 -1
  200. package/src/runtime.js +219 -0
  201. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  202. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  203. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  204. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -299
  205. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  206. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  207. /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+ """CLI wrapper for the fail-closed Spec Context Stage Gate."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import sys
10
+ import time
11
+ from typing import IO, Optional, Sequence
12
+
13
+ from codeflow.core.cf_core import phase_timing
14
+ from codeflow.spec.cf_spec_context_model import load_context
15
+ from codeflow.spec.cf_stage_gate import (
16
+ GateIssue, GateResult, evidence_deferral_kind, result_to_data, validate_plan_coverage, validate_stage,
17
+ )
18
+
19
+
20
+ def _merge_results(first: GateResult, second: GateResult) -> GateResult:
21
+ errors = (*first.errors, *second.errors)
22
+ warnings = (*first.warnings, *second.warnings)
23
+ refs = tuple(sorted(set((*first.affected_refs, *second.affected_refs))))
24
+ return GateResult("block" if errors else "pass", errors, warnings, refs)
25
+
26
+
27
+ def _parser() -> argparse.ArgumentParser:
28
+ parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND", "cf_spec_gate.py"))
29
+ parser.add_argument("--task-dir", required=True)
30
+ parser.add_argument("--stage", required=True)
31
+ parser.add_argument("--task", default="")
32
+ parser.add_argument("--artifact", default="")
33
+ parser.add_argument("--json", action="store_true")
34
+ return parser
35
+
36
+
37
+ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
38
+ started = time.monotonic()
39
+ try:
40
+ args = _parser().parse_args(argv)
41
+ phase_started = time.monotonic()
42
+ context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
43
+ phase_timing("gate.load_context", phase_started)
44
+ phase_started = time.monotonic()
45
+ directory = Path(args.task_dir).resolve()
46
+ root = next((parent.parent for parent in (directory, *directory.parents)
47
+ if parent.name == ".code-flow"), directory)
48
+ result = validate_stage(context, args.stage, task_id=args.task, root=str(root), task_dir=args.task_dir)
49
+ phase_timing("gate.validate_stage", phase_started)
50
+ if args.stage == "plan" and args.artifact:
51
+ phase_started = time.monotonic()
52
+ result = _merge_results(result, validate_plan_coverage(context, args.artifact))
53
+ phase_timing("gate.validate_plan", phase_started)
54
+ stdout.write(json.dumps(result_to_data(result), ensure_ascii=False))
55
+ phase_timing("gate.total", started)
56
+ return 0 if result.decision == "pass" else 3
57
+ except Exception as exc:
58
+ sys.stderr.write(f"cf_spec_gate error: {exc}\n")
59
+ stdout.write(json.dumps({"decision": "block", "errors": [{"code": "gate_error"}]}, ensure_ascii=False))
60
+ return 2
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env python3
2
+ """CLI wrapper for TASK-bound Spec Context projection."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import sys
10
+ from typing import IO, Optional, Sequence
11
+
12
+ from codeflow.spec.cf_spec_context_model import context_sha256, load_context
13
+ from codeflow.spec.cf_task_projection import SessionProjection, project_task_session
14
+ from codeflow.task.cf_task_doc import task_section, task_spec_refs
15
+
16
+
17
+ def _task_section(text: str, task_id: str) -> str:
18
+ return task_section(text, task_id).body
19
+
20
+
21
+ def _refs(section: str) -> tuple[str, ...]:
22
+ return task_spec_refs(section)
23
+
24
+
25
+ def _parser() -> argparse.ArgumentParser:
26
+ parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND", "cf_spec_session.py"))
27
+ parser.add_argument("--task-dir", required=True)
28
+ parser.add_argument("--task-file", required=True)
29
+ parser.add_argument("--task", required=True)
30
+ parser.add_argument("--output", required=True)
31
+ parser.add_argument("--budget", type=int, default=12000)
32
+ parser.add_argument("--json", action="store_true")
33
+ return parser
34
+
35
+
36
+ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
37
+ try:
38
+ args = _parser().parse_args(argv)
39
+ context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
40
+ result = project_task_session(context, args.task_file, args.task, args.budget)
41
+ Path(args.output).write_text(result.text, encoding="utf-8")
42
+ stdout.write(json.dumps(result.__dict__, ensure_ascii=False))
43
+ return 0
44
+ except Exception as exc:
45
+ stdout.write(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
46
+ return 3
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from codeflow.core import cf_log
10
+ from codeflow.acceptance.cf_checks import load_check_state
11
+ from codeflow.core.cf_core import (
12
+ build_effective_mapping,
13
+ build_spec_catalog,
14
+ compress_content,
15
+ estimate_tokens,
16
+ load_config,
17
+ project_instruction_file,
18
+ non_injectable_specs,
19
+ resolve_quality_loop,
20
+ )
21
+
22
+
23
+ def _violation_fixed(index: int, events: list) -> bool:
24
+ """修正口径:违规后同会话同文件有后续编辑,且其后无同 check 再违规。
25
+
26
+ 用日志追加顺序判先后(ts 仅秒级精度,同秒事件无法靠时间戳排序)。
27
+ 保留单点查询语义;批量聚合请用 violation_fixed_batch(一次线性扫描)。
28
+ """
29
+ violation = events[index]
30
+ v_data = violation.get("data") or {}
31
+ later_edit = False
32
+ for event in events[index + 1:]:
33
+ if event.get("sid") != violation.get("sid"):
34
+ continue
35
+ data = event.get("data") or {}
36
+ if event.get("event") == "edit" and data.get("file") == v_data.get("file"):
37
+ later_edit = True
38
+ if (
39
+ later_edit
40
+ and event.get("event") == "violation"
41
+ and data.get("check_id") == v_data.get("check_id")
42
+ and data.get("file") == v_data.get("file")
43
+ ):
44
+ return False
45
+ return later_edit
46
+
47
+
48
+ def violation_fixed_batch(events: list) -> list[bool]:
49
+ """Batch twin of _violation_fixed: one reverse pass, identical semantics.
50
+
51
+ Segments the timeline per (session, file) at edits: a violation is fixed
52
+ iff an edit exists after it and no same-check violation exists in a later
53
+ segment. No slicing, no per-violation rescan: O(n) time.
54
+ """
55
+ fixed: dict[int, bool] = {}
56
+ edit_right: set[tuple[str, str]] = set()
57
+ current: dict[tuple[str, str], set[str]] = {}
58
+ later: dict[tuple[str, str], set[str]] = {}
59
+ for index in range(len(events) - 1, -1, -1):
60
+ event = events[index]
61
+ if not isinstance(event, dict):
62
+ continue
63
+ kind = event.get("event")
64
+ data = event.get("data")
65
+ if not isinstance(data, dict):
66
+ data = {}
67
+ key = (str(event.get("sid")), str(data.get("file")))
68
+ if kind == "edit":
69
+ later.setdefault(key, set()).update(current.get(key, ()))
70
+ current[key] = set()
71
+ edit_right.add(key)
72
+ elif kind == "violation":
73
+ check = str(data.get("check_id"))
74
+ fixed[index] = key in edit_right and check not in later.get(key, ())
75
+ current.setdefault(key, set()).add(check)
76
+ return [fixed[index] for index, event in enumerate(events) if isinstance(event, dict) and event.get("event") == "violation"]
77
+
78
+
79
+ def quality_loop_summary(project_root: str, config: dict) -> dict:
80
+ """FEAT-05/02 度量聚合:Top 违规榜 / 修正率 / 误报与停用 / 降级组件。"""
81
+ switches = resolve_quality_loop(config)
82
+ events = cf_log.read_events(project_root, days=30)
83
+ summary = {"switches": switches, "window_days": 30}
84
+ if not events:
85
+ summary["note"] = "暂无数据"
86
+ return summary
87
+
88
+ violations = [e for e in events if e.get("event") == "violation"]
89
+ counts: dict = {}
90
+ for v in violations:
91
+ data = v.get("data") or {}
92
+ key = f"{data.get('spec', '?')}#{data.get('check_id', '?')}"
93
+ counts[key] = counts.get(key, 0) + 1
94
+ summary["top_violations"] = sorted(
95
+ ({"rule": k, "count": n} for k, n in counts.items()),
96
+ key=lambda item: -item["count"],
97
+ )[:10]
98
+
99
+ fixed = violation_fixed_batch(events)
100
+ summary["violation_total"] = len(violations)
101
+ summary["fix_rate"] = (
102
+ f"{round(sum(fixed) * 100 / len(violations))}%" if violations else "n/a"
103
+ )
104
+
105
+ state = load_check_state(project_root)
106
+ summary["checks"] = {
107
+ cid: {
108
+ "hit_count": entry.get("hit_count", 0),
109
+ "fp_count": entry.get("fp_count", 0),
110
+ "disabled": bool(entry.get("disabled")),
111
+ "disabled_reason": entry.get("disabled_reason", ""),
112
+ }
113
+ for cid, entry in state.items()
114
+ if isinstance(entry, dict) and not cid.startswith("_")
115
+ }
116
+
117
+ degraded: dict = {}
118
+ for event in events:
119
+ if event.get("event") != "degrade":
120
+ continue
121
+ data = event.get("data") or {}
122
+ component = data.get("component", "?")
123
+ item = degraded.setdefault(component, {"count": 0, "last_error": ""})
124
+ item["count"] += 1
125
+ item["last_error"] = data.get("error", "")
126
+ summary["degraded"] = degraded
127
+ return summary
128
+
129
+
130
+ def _percent(numerator: int, denominator: int) -> str:
131
+ return f"{round(numerator * 100 / denominator)}%" if denominator else "n/a"
132
+
133
+
134
+ def spec_workflow_summary(project_root: str) -> dict:
135
+ events = cf_log.read_events(project_root, days=30)
136
+ candidates = {
137
+ (str((event.get("data") or {}).get("task")), str((event.get("data") or {}).get("rule")))
138
+ for event in events if event.get("event") == "spec_candidate"
139
+ }
140
+ bound = {
141
+ (str((event.get("data") or {}).get("task")), str((event.get("data") or {}).get("rule")))
142
+ for event in events if event.get("event") == "spec_bound"
143
+ }
144
+ gates = [event for event in events if event.get("event") == "spec_gate"]
145
+ first_pass = sum((event.get("data") or {}).get("first_pass") is True for event in gates)
146
+ late = sum(event.get("event") == "late_violation" for event in events)
147
+ statuses = {"stale": 0, "conflict": 0, "not_applicable": 0}
148
+ for event in events:
149
+ status = (event.get("data") or {}).get("status")
150
+ if status in statuses:
151
+ statuses[status] += 1
152
+ degraded = sum(event.get("event") == "metrics_degraded" for event in events)
153
+ return {
154
+ "window_days": 30,
155
+ "coverage": _percent(len(bound & candidates), len(candidates)),
156
+ "first_compliance_rate": _percent(first_pass, len(gates)),
157
+ "late_violation_rate": _percent(late, len(bound)),
158
+ "statuses": statuses,
159
+ "data_complete": degraded == 0,
160
+ "degraded_events": degraded,
161
+ "read_note": "read 仅表示读取,不代表理解",
162
+ }
163
+
164
+
165
+ def read_text(path: str) -> str:
166
+ try:
167
+ with open(path, "r", encoding="utf-8") as file:
168
+ return file.read().strip()
169
+ except Exception:
170
+ return ""
171
+
172
+
173
+ def normalize_rel_path(path: str) -> str:
174
+ return path.replace(os.sep, "/")
175
+
176
+
177
+ def extract_spec_path(spec_entry) -> str:
178
+ if isinstance(spec_entry, dict):
179
+ return normalize_rel_path(spec_entry.get("path", ""))
180
+ if isinstance(spec_entry, str):
181
+ return normalize_rel_path(spec_entry)
182
+ return ""
183
+
184
+
185
+ def discover_specs(specs_root: str) -> dict:
186
+ discovered = {}
187
+ if not os.path.isdir(specs_root):
188
+ return discovered
189
+
190
+ for root, _, files in os.walk(specs_root):
191
+ for filename in files:
192
+ if not filename.endswith(".md"):
193
+ continue
194
+ full_path = os.path.join(root, filename)
195
+ rel = normalize_rel_path(os.path.relpath(full_path, specs_root))
196
+ parts = rel.split("/", 1)
197
+ if len(parts) < 2:
198
+ continue
199
+ domain = parts[0]
200
+ discovered.setdefault(domain, []).append(rel)
201
+
202
+ for domain in discovered:
203
+ discovered[domain] = sorted(set(discovered[domain]))
204
+ return discovered
205
+
206
+
207
+ def configured_specs(config: dict, domain: str) -> list:
208
+ mapping = (config.get("path_mapping") or {}).get(domain) or {}
209
+ specs_config = mapping.get("specs") or []
210
+ result = []
211
+ for spec_entry in specs_config:
212
+ rel = extract_spec_path(spec_entry)
213
+ if rel:
214
+ result.append(rel)
215
+ return result
216
+
217
+
218
+ def resolve_domains(config: dict, discovered: dict, domain_filter: Optional[str]) -> list:
219
+ if domain_filter:
220
+ if domain_filter in discovered:
221
+ return [domain_filter]
222
+ if domain_filter in (config.get("path_mapping") or {}):
223
+ return [domain_filter]
224
+ return []
225
+
226
+ if discovered:
227
+ return sorted(discovered.keys())
228
+ return sorted((config.get("path_mapping") or {}).keys())
229
+
230
+
231
+ def _build_item(rel: str, raw_content: str) -> dict:
232
+ raw_tokens = estimate_tokens(raw_content)
233
+ compressed_tokens = estimate_tokens(compress_content(raw_content))
234
+ saved_pct = (
235
+ round((raw_tokens - compressed_tokens) * 100 / raw_tokens, 1)
236
+ if raw_tokens
237
+ else 0.0
238
+ )
239
+ return {
240
+ "path": rel,
241
+ "tokens": compressed_tokens,
242
+ "tokens_raw": raw_tokens,
243
+ "tokens_compressed": compressed_tokens,
244
+ "saved_pct": saved_pct,
245
+ }
246
+
247
+
248
+ def collect_domain_items(
249
+ specs_root: str,
250
+ domain: str,
251
+ configured: list,
252
+ discovered: list,
253
+ ) -> tuple:
254
+ items = []
255
+ missing = []
256
+ seen = set()
257
+
258
+ for rel in configured:
259
+ if rel in seen:
260
+ continue
261
+ seen.add(rel)
262
+ full_path = os.path.join(specs_root, rel)
263
+ if not os.path.exists(full_path):
264
+ missing.append({"domain": domain, "path": rel})
265
+ continue
266
+ content = read_text(full_path)
267
+ if content:
268
+ items.append(_build_item(rel, content))
269
+
270
+ for rel in discovered:
271
+ if rel in seen:
272
+ continue
273
+ seen.add(rel)
274
+ full_path = os.path.join(specs_root, rel)
275
+ content = read_text(full_path)
276
+ if content:
277
+ items.append(_build_item(rel, content))
278
+
279
+ return items, missing
280
+
281
+
282
+ def _arguments() -> argparse.Namespace:
283
+ parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND", "cf_stats.py"))
284
+ formats = parser.add_mutually_exclusive_group()
285
+ formats.add_argument("--human", action="store_true")
286
+ formats.add_argument("--json", action="store_true")
287
+ parser.add_argument("--audit", action="store_true")
288
+ parser.add_argument("--domain", default=None)
289
+ parser.add_argument("--platform", choices=("claude", "codex", "costrict", "opencode"), default="")
290
+ return parser.parse_args()
291
+
292
+
293
+ def _budget_number(config: dict[str, object], key: str, default: int) -> int:
294
+ try:
295
+ return int(config.get(key, default))
296
+ except (TypeError, ValueError) as exc:
297
+ sys.stderr.write(f"cf-stats invalid {key}: {exc}; using {default}\n")
298
+ return default
299
+
300
+
301
+ def _domain_usage(root: str, config: dict[str, object], domain_filter: Optional[str]) -> dict[str, object]:
302
+ specs_root = os.path.join(root, ".code-flow/specs")
303
+ discovered = discover_specs(specs_root)
304
+ mapping = build_effective_mapping(root, config.get("path_mapping") or {})
305
+ excluded = non_injectable_specs(mapping)
306
+ l1, spec_domains, missing_specs, empty_domains = {}, {}, [], []
307
+ for domain in resolve_domains(config, discovered, domain_filter):
308
+ configured = configured_specs(config, domain)
309
+ discovered_paths = discovered.get(domain, [])
310
+ items, missing = collect_domain_items(specs_root, domain, configured, discovered_paths)
311
+ for relative in configured + discovered_paths:
312
+ if relative:
313
+ spec_domains[relative] = domain
314
+ missing_specs.extend(missing)
315
+ for item in items:
316
+ item["injectable"] = item["path"] not in excluded
317
+ if items:
318
+ l1[domain] = items
319
+ elif configured and (not discovered or discovered_paths):
320
+ empty_domains.append(domain)
321
+ tokens = sum(item["tokens"] for items in l1.values() for item in items if item["injectable"])
322
+ templates = sum(item["tokens"] for items in l1.values() for item in items if not item["injectable"])
323
+ return {"l1": l1, "tokens": tokens, "template_tokens": templates, "excluded": sorted(excluded),
324
+ "missing_specs": missing_specs, "empty_domains": empty_domains, "spec_domain_map": spec_domains}
325
+
326
+
327
+ def _compression_usage(l1: dict[str, list[dict[str, object]]]) -> dict[str, object]:
328
+ items = [item for values in l1.values() for item in values if item.get("injectable", True)]
329
+ raw = sum(item.get("tokens_raw", item["tokens"]) for item in items)
330
+ compressed = sum(item.get("tokens_compressed", item["tokens"]) for item in items)
331
+ return {"total_raw": raw, "total_compressed": compressed,
332
+ "total_saved_pct": round((raw - compressed) * 100 / raw, 1) if raw else 0.0}
333
+
334
+
335
+ def _usage_warnings(l0_tokens: int, l0_budget: int, l1_budget: int, total: int,
336
+ total_budget: int, usage: dict[str, object]) -> list[str]:
337
+ warnings = []
338
+ for exceeded, message in ((l0_tokens > l0_budget, "L0 超出预算"),
339
+ (total - l0_tokens > min(total_budget, l1_budget), "L1 全量聚合超过单次注入上限(不代表单次编辑超限)"),
340
+ (total > total_budget, "总预算超出")):
341
+ if exceeded:
342
+ warnings.append(message)
343
+ if usage["missing_specs"]:
344
+ warnings.append(f"配置的 spec 文件缺失: {len(usage['missing_specs'])} 个")
345
+ if usage["empty_domains"]:
346
+ domains = ", ".join(sorted(set(usage["empty_domains"])))
347
+ warnings.append(f"以下域未加载到任何 L1 spec: {domains}")
348
+ return warnings
349
+
350
+
351
+ def _catalog_usage(root: str, config: dict[str, object], budget: dict[str, object]) -> dict[str, object]:
352
+ maximum = _budget_number(budget, "catalog_max", 200)
353
+ mapping = build_effective_mapping(root, config.get("path_mapping") or {})
354
+ text = build_spec_catalog(root, mapping, maximum)
355
+ return {"mode": "context_first", "tokens": estimate_tokens(text), "budget": maximum,
356
+ "entries": text.count("\n- `")}
357
+
358
+
359
+ def _runtime_manifest_counts(manifest: Path) -> dict[str, int]:
360
+ from codeflow.acceptance.cf_acceptance_risk import risk_counts
361
+ from codeflow.acceptance.cf_acceptance_schema import scenario_check_ids
362
+ if not manifest.is_file():
363
+ return {}
364
+ try:
365
+ data = json.loads(manifest.read_text(encoding="utf-8"))
366
+ if not isinstance(data, dict):
367
+ raise ValueError("manifest must be an object")
368
+ except (OSError, ValueError) as exc:
369
+ sys.stderr.write(f"cf_stats: cannot read {manifest}: {exc}\n")
370
+ return {}
371
+ stale: set[str] = set()
372
+ for row in data.get("scenarios", []):
373
+ if isinstance(row, dict) and row.get("status") == "stale":
374
+ ids = row.get("stale_checks") or scenario_check_ids(row) or [str(row.get("id"))]
375
+ stale.update(str(value) for value in ids)
376
+ return {"stale_checks": len(stale), **risk_counts(data)}
377
+
378
+
379
+ def _deferred_rule_count(context: Path) -> int:
380
+ from codeflow.cli.cf_spec_context import ContextError, load_context
381
+ from codeflow.spec.cf_stage_gate import evidence_deferral_kind
382
+ if not context.is_file():
383
+ return 0
384
+ try:
385
+ data = load_context(str(context))
386
+ except (OSError, ValueError, ContextError) as exc:
387
+ sys.stderr.write(f"cf_stats: cannot read {context}: {exc}\n")
388
+ return 0
389
+ return sum(any(
390
+ status.status == "unverified" and bool(status.evidence)
391
+ and evidence_deferral_kind(status.evidence[-1]) in ("review_stage", "heavy")
392
+ for status in rule.stage_status.values()
393
+ ) for binding in data.bindings for rule in binding.rules)
394
+
395
+
396
+ def _runtime_usage(root: str) -> dict[str, object]:
397
+ """Counts describe unique checks, live risk acceptances and deferred rules."""
398
+ from codeflow.evidence.cf_evidence_store import EvidenceStore, requirement_key
399
+ marker = Path(root) / ".code-flow/.active-task.json"
400
+ task_dir = ""
401
+ if marker.is_file():
402
+ try:
403
+ data = json.loads(marker.read_text(encoding="utf-8"))
404
+ task_dir = str(data.get("task_dir") or "") if isinstance(data, dict) else ""
405
+ except (OSError, ValueError) as exc:
406
+ sys.stderr.write(f"cf_stats: cannot read {marker}: {exc}\n")
407
+ runtime: dict[str, object] = {
408
+ "task_dir": task_dir, "context_bytes": 0, "evidence_runs": 0,
409
+ "deferred_rules": 0, "stale_checks": 0, "accepted_risks": 0,
410
+ "recorded_risks": 0, "inactive_risks": 0,
411
+ }
412
+ if not task_dir:
413
+ return runtime
414
+ directory = (Path(root) / task_dir).resolve()
415
+ context = directory / "spec-context.yml"
416
+ if context.is_file():
417
+ runtime["context_bytes"] = context.stat().st_size
418
+ try:
419
+ store = EvidenceStore(root, requirement=requirement_key(directory))
420
+ runtime["evidence_runs"] = sum(
421
+ 1 for path in store.directory.rglob("*.json")
422
+ if path.name != "latest.json" and "_context" not in path.parts
423
+ )
424
+ except (OSError, ValueError) as exc:
425
+ sys.stderr.write(f"cf_stats: cannot count evidence: {exc}\n")
426
+ runtime.update(_runtime_manifest_counts(directory / ".acceptance-manifest.json"))
427
+ runtime["deferred_rules"] = _deferred_rule_count(context)
428
+ return runtime
429
+
430
+
431
+ def _stats_report(root: str, args: argparse.Namespace) -> dict[str, object]:
432
+ config = load_config(root)
433
+ budget = config.get("budget") or {}
434
+ l0_budget = _budget_number(budget, "l0_max", 800)
435
+ l1_budget = _budget_number(budget, "l1_max", 1700)
436
+ total_budget = _budget_number(budget, "total", l0_budget + l1_budget)
437
+ instruction = project_instruction_file(root, args.platform)
438
+ instruction_path = os.path.join(root, instruction)
439
+ l0_tokens = estimate_tokens(read_text(instruction_path)) if os.path.exists(instruction_path) else 0
440
+ usage = _domain_usage(root, config, args.domain)
441
+ total = l0_tokens + usage["tokens"]
442
+ report = {
443
+ "l0": {"file": instruction, "tokens": l0_tokens, "budget": l0_budget},
444
+ "l1": usage["l1"], "total_tokens": total, "total_budget": total_budget,
445
+ "budget_summary": {"aggregate_total": usage["tokens"], "single_edit_limit": min(total_budget, l1_budget)},
446
+ "utilization": f"{round(total * 100 / total_budget)}%" if total_budget else "0%",
447
+ "warnings": _usage_warnings(l0_tokens, l0_budget, l1_budget, total, total_budget, usage),
448
+ "spec_domain_map": usage["spec_domain_map"], "missing_specs": usage["missing_specs"],
449
+ "compression_summary": _compression_usage(usage["l1"]), "catalog": _catalog_usage(root, config, budget),
450
+ "quality_loop": quality_loop_summary(root, config), "spec_workflow": spec_workflow_summary(root),
451
+ "templates": {"tokens": usage["template_tokens"], "files": usage["excluded"],
452
+ "note": "tags:[] 命令专用模板,永不自动注入,不计预算"},
453
+ "runtime": _runtime_usage(root),
454
+ }
455
+ if args.audit:
456
+ from codeflow.spec.cf_scan import build_report
457
+ scan = build_report(root, args.platform)
458
+ report["audit"] = {"files": [entry for entry in scan["files"] if entry.get("issues")],
459
+ "review": scan["review"], "warnings": scan["audit_warnings"],
460
+ "path_resolutions": [{"path": entry["path"], "resolved_paths": entry["resolved_paths"]}
461
+ for entry in scan["files"] if entry.get("resolved_paths")]}
462
+ return report
463
+
464
+
465
+ def _print_domains(report: dict[str, object]) -> None:
466
+ for domain, items in report["l1"].items():
467
+ print(f"L1 {domain}:", sum(item["tokens"] for item in items if item.get("injectable", True)))
468
+ for item in items:
469
+ raw, compressed = item.get("tokens_raw", item["tokens"]), item.get("tokens_compressed", item["tokens"])
470
+ suffix = "" if item.get("injectable", True) else "(模板,不计预算)"
471
+ print(" -", item["path"], item["tokens"], f"(raw={raw}→compressed={compressed}, -{item.get('saved_pct', 0.0)}%)" + suffix)
472
+ if report["templates"]["tokens"]:
473
+ print("TEMPLATES (非注入):", f"{report['templates']['tokens']} tokens,不计预算")
474
+ if report["missing_specs"]:
475
+ print("MISSING SPECS:")
476
+ for item in report["missing_specs"]:
477
+ print(" -", item["domain"], item["path"])
478
+
479
+
480
+ def _print_quality(summary: dict[str, object]) -> None:
481
+ print("QUALITY-LOOP:", "enabled" if summary["switches"]["enabled"] else "disabled")
482
+ if summary.get("note"):
483
+ print(" -", summary["note"])
484
+ return
485
+ print(" - violations:", summary.get("violation_total", 0), "| fix_rate:", summary.get("fix_rate", "n/a"))
486
+ for item in summary.get("top_violations", [])[:5]:
487
+ print(" ·", item["rule"], item["count"])
488
+ for check_id, info in summary.get("checks", {}).items():
489
+ if info["disabled"] or info["fp_count"]:
490
+ suffix = f" DISABLED({info['disabled_reason']})" if info["disabled"] else ""
491
+ print(f" · {check_id}: hits={info['hit_count']} fp={info['fp_count']}" + suffix)
492
+ for component, info in summary.get("degraded", {}).items():
493
+ print(f" · degraded {component}: {info['count']} ({info['last_error']})")
494
+
495
+
496
+ def _print_audit(audit: Optional[dict[str, object]]) -> None:
497
+ if audit is None:
498
+ print("AUDIT: 运行 code-flow stats --audit 查看规范质量问题与待复审清单")
499
+ return
500
+ print("AUDIT (规范质量):")
501
+ if not audit["files"] and not audit["review"] and not audit.get("warnings"):
502
+ print(" - 无问题")
503
+ for warning in audit.get("warnings", []):
504
+ print(f" - 无效审计路径根: {warning['path']}")
505
+ for entry in audit["files"]:
506
+ print(" -", entry["path"], "|", " / ".join(entry["issues"]))
507
+ if audit["review"]:
508
+ print(" REVIEW (待复审):")
509
+ for item in audit["review"]:
510
+ print(f" · {item['item']}: {item['reason']}")
511
+
512
+
513
+ def _print_stats(report: dict[str, object]) -> None:
514
+ l0, compression, catalog = report["l0"], report["compression_summary"], report["catalog"]
515
+ print(f"L0 ({l0['file']}):", f"{l0['tokens']} / {l0['budget']}")
516
+ _print_domains(report)
517
+ print("TOTAL:", f"{report['total_tokens']} / {report['total_budget']}")
518
+ print("UTILIZATION:", report["utilization"])
519
+ print("COMPRESSION:", f"{compression['total_raw']} → {compression['total_compressed']} (-{compression['total_saved_pct']}%)")
520
+ print("CATALOG:", "mode=context_first,", f"{catalog['tokens']} / {catalog['budget']} tokens,", f"{catalog['entries']} entries")
521
+ _print_quality(report["quality_loop"])
522
+ runtime = report.get("runtime") or {}
523
+ if runtime.get("task_dir"):
524
+ print("RUNTIME:", f"context={runtime.get('context_bytes', 0)}B,",
525
+ f"evidence_runs={runtime.get('evidence_runs', 0)},",
526
+ f"deferred_rules={runtime.get('deferred_rules', 0)},",
527
+ f"stale_checks={runtime.get('stale_checks', 0)},",
528
+ f"accepted_risks={runtime.get('accepted_risks', 0)}")
529
+ if report["warnings"]:
530
+ print("WARNINGS:", "; ".join(report["warnings"]))
531
+ _print_audit(report.get("audit"))
532
+
533
+
534
+ def main() -> None:
535
+ args = _arguments()
536
+ report = _stats_report(os.getcwd(), args)
537
+ if args.human:
538
+ _print_stats(report)
539
+ else:
540
+ sys.stdout.write(json.dumps(report, ensure_ascii=False) + "\n")