@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.
- package/README.md +47 -3
- package/docs/USAGE.md +1529 -0
- package/docs/acceptance-contract.md +217 -0
- package/docs/ci/acceptance-evidence.md +77 -0
- package/docs/migrations/runtime-0.7.0.md +41 -0
- package/docs/migrations/runtime-0.8.0.md +154 -0
- package/docs/migrations/runtime-0.9.0.md +40 -0
- package/docs/spec-injection-hardening.md +54 -0
- package/docs/spec-injection-performance.json +73 -0
- package/docs/spec-optimization.md +12 -0
- package/package.json +18 -3
- package/src/adapters/claude/CLAUDE.md +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -3
- package/src/adapters/claude/commands/cf-spec.md +23 -3
- package/src/adapters/claude/commands/cf-stats.md +11 -1
- package/src/adapters/claude/commands/cf-sync.md +20 -6
- package/src/adapters/claude/commands/cf-task/align.md +25 -3
- package/src/adapters/claude/commands/cf-task/archive.md +36 -40
- package/src/adapters/claude/commands/cf-task/block.md +15 -2
- package/src/adapters/claude/commands/cf-task/graph.md +13 -1
- package/src/adapters/claude/commands/cf-task/note.md +13 -1
- package/src/adapters/claude/commands/cf-task/plan.md +137 -23
- package/src/adapters/claude/commands/cf-task/prd.md +13 -2
- package/src/adapters/claude/commands/cf-task/start.md +67 -35
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/claude/commands/cf-validate.md +30 -5
- package/src/adapters/codex/AGENTS.md +1 -1
- package/src/adapters/codex/hooks.json +14 -10
- package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
- package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
- package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -40
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +67 -35
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
- package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
- package/src/adapters/commands.json +115 -0
- package/src/adapters/costrict/CLAUDE.md +1 -1
- package/src/adapters/costrict/commands/cf-init.md +1 -3
- package/src/adapters/costrict/commands/cf-spec.md +23 -3
- package/src/adapters/costrict/commands/cf-stats.md +11 -1
- package/src/adapters/costrict/commands/cf-sync.md +20 -6
- package/src/adapters/costrict/commands/cf-task/align.md +25 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +36 -40
- package/src/adapters/costrict/commands/cf-task/block.md +15 -2
- package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
- package/src/adapters/costrict/commands/cf-task/note.md +13 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
- package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
- package/src/adapters/costrict/commands/cf-task/start.md +67 -35
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/costrict/commands/cf-validate.md +30 -5
- package/src/adapters/install-plan.json +100 -0
- package/src/adapters/opencode/AGENTS.md +1 -1
- package/src/adapters/opencode/commands/cf-init.md +7 -7
- package/src/adapters/opencode/commands/cf-learn.md +6 -6
- package/src/adapters/opencode/commands/cf-spec.md +34 -2
- package/src/adapters/opencode/commands/cf-stats.md +17 -7
- package/src/adapters/opencode/commands/cf-sync.md +21 -6
- package/src/adapters/opencode/commands/cf-task/align.md +25 -8
- package/src/adapters/opencode/commands/cf-task/archive.md +36 -40
- package/src/adapters/opencode/commands/cf-task/block.md +16 -2
- package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
- package/src/adapters/opencode/commands/cf-task/note.md +14 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
- package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
- package/src/adapters/opencode/commands/cf-task/start.md +68 -41
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/opencode/commands/cf-validate.md +35 -9
- package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
- package/src/adapters/platform-bindings.json +370 -0
- package/src/cli.js +369 -316
- package/src/core/code-flow/config.yml +4 -1
- package/src/core/code-flow/runtime-commands.json +259 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
- package/src/core/code-flow/scripts/cf_audit.py +10 -0
- package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
- package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
- package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
- package/src/core/code-flow/scripts/cf_feedback.py +5 -51
- package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
- package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +5 -1714
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
- package/src/core/code-flow/scripts/cf_stats.py +5 -501
- package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
- package/src/core/code-flow/scripts/cf_sync.py +5 -135
- package/src/core/code-flow/scripts/cf_task_index.py +5 -143
- package/src/core/code-flow/scripts/cf_task_parallel.py +5 -380
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
- package/src/core/code-flow/scripts/cf_validation.py +6 -77
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
- package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
- package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
- package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
- package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
- package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +37 -3
- package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
- package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
- package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
- package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +252 -26
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
- package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
- package/src/core/code-flow/scripts/runtime-resources.json +29 -0
- package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
- package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
- package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
- package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
- package/src/core/code-flow/validation.yml +9 -3
- package/src/migrate/runtime.js +213 -0
- package/src/migrate/spec-workflow.js +16 -1
- package/src/runtime.js +219 -0
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -299
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
|
@@ -1,381 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
Flow: session edit events (cf_log) → match validator triggers → run commands
|
|
5
|
-
serially under a total budget → failures surface as {"decision": "block",
|
|
6
|
-
"reason": ...} so the agent fixes them before the session truly ends.
|
|
7
|
-
|
|
8
|
-
Protocol notes (deviation from the injection-hook contract, recorded in
|
|
9
|
-
TASK-010): the Stop event's feedback channel is decision/reason — there is no
|
|
10
|
-
additionalContext for Stop. `stop_hook_active` is respected so an already
|
|
11
|
-
continued session is never re-blocked (no loops). All-pass → silent exit 0.
|
|
12
|
-
"""
|
|
13
|
-
import fnmatch
|
|
14
|
-
import json
|
|
15
|
-
import math
|
|
16
|
-
import os
|
|
17
|
-
import re
|
|
2
|
+
"""Path-frozen entry shim: implementation lives in codeflow.hooks.cf_stop_hook."""
|
|
18
3
|
import sys
|
|
19
|
-
import time
|
|
20
|
-
|
|
21
|
-
import cf_log
|
|
22
|
-
from cf_exec_base import build_argv, remaining_seconds, run_command, execution_session
|
|
23
|
-
from cf_spec_context import load_active_task
|
|
24
|
-
from cf_task_runtime import run_done_gate
|
|
25
|
-
from cf_acceptance_evidence import line_status, scenario_line
|
|
26
|
-
from cf_core import (
|
|
27
|
-
_log,
|
|
28
|
-
ensure_utf8_io,
|
|
29
|
-
load_config,
|
|
30
|
-
normalize_path,
|
|
31
|
-
resolve_enforcement,
|
|
32
|
-
resolve_quality_loop,
|
|
33
|
-
resolve_session_id,
|
|
34
|
-
timing_log,
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
TOTAL_BUDGET_SECONDS = 30.0
|
|
38
|
-
GATE_BUDGET_SECONDS = 25.0
|
|
39
|
-
_BRACE_RE = re.compile(r"\{([^{}]+)\}")
|
|
40
|
-
_TASK_SECTION_RE = re.compile(
|
|
41
|
-
r"(?ms)^## (TASK-\d+):.*?(?=^## TASK-\d+:|\Z)"
|
|
42
|
-
)
|
|
43
|
-
_SCENARIO_RE = re.compile(r"\b[SEB]-\d+\b")
|
|
44
|
-
_UNVERIFIED_RE = re.compile(r"\b(?:planned|pending|tbd)\b", re.IGNORECASE)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def expand_braces(pattern: str) -> list:
|
|
48
|
-
"""Expand one level of `{a,b}` alternation → list of fnmatch patterns."""
|
|
49
|
-
match = _BRACE_RE.search(pattern)
|
|
50
|
-
if not match:
|
|
51
|
-
return [pattern]
|
|
52
|
-
head, tail = pattern[: match.start()], pattern[match.end():]
|
|
53
|
-
out = []
|
|
54
|
-
for option in match.group(1).split(","):
|
|
55
|
-
out.extend(expand_braces(head + option + tail))
|
|
56
|
-
return out
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def trigger_matches(trigger: str, rel_path: str) -> bool:
|
|
60
|
-
"""fnmatch with brace expansion; `**/` prefix also matches root files."""
|
|
61
|
-
rel_path = normalize_path(rel_path)
|
|
62
|
-
for pattern in expand_braces(trigger or ""):
|
|
63
|
-
candidates = [pattern]
|
|
64
|
-
if pattern.startswith("**/"):
|
|
65
|
-
candidates.append(pattern[3:])
|
|
66
|
-
if any(fnmatch.fnmatch(rel_path, p) for p in candidates):
|
|
67
|
-
return True
|
|
68
|
-
return False
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def load_validators(project_root: str) -> list:
|
|
72
|
-
path = os.path.join(project_root, ".code-flow", "validation.yml")
|
|
73
|
-
if not os.path.exists(path):
|
|
74
|
-
return []
|
|
75
|
-
try:
|
|
76
|
-
import yaml
|
|
77
|
-
with open(path, "r", encoding="utf-8") as f:
|
|
78
|
-
data = yaml.safe_load(f) or {}
|
|
79
|
-
validators = data.get("validators")
|
|
80
|
-
return validators if isinstance(validators, list) else []
|
|
81
|
-
except Exception as exc:
|
|
82
|
-
_log(f"cf_stop_hook validation.yml parse failed: {exc}")
|
|
83
|
-
return []
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def session_edited_files(project_root: str, sid: str) -> list:
|
|
87
|
-
files = []
|
|
88
|
-
seen = set()
|
|
89
|
-
for event in cf_log.read_events(project_root, days=2, events=("edit",)):
|
|
90
|
-
if event.get("sid") != sid:
|
|
91
|
-
continue
|
|
92
|
-
rel = (event.get("data") or {}).get("file")
|
|
93
|
-
if rel and rel not in seen:
|
|
94
|
-
seen.add(rel)
|
|
95
|
-
files.append(rel)
|
|
96
|
-
return files
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _root_scoped_files(project_root: str, files: list) -> list:
|
|
100
|
-
"""Drop worktree-internal / outside-root paths.
|
|
101
|
-
|
|
102
|
-
Parallel subagents edit another working copy (.code-flow/worktrees/<run>/<TASK>/);
|
|
103
|
-
those paths must not trigger this root's validators.
|
|
104
|
-
"""
|
|
105
|
-
root = os.path.realpath(project_root)
|
|
106
|
-
scoped = []
|
|
107
|
-
for rel in files:
|
|
108
|
-
path = normalize_path(rel)
|
|
109
|
-
if path.startswith(".code-flow/worktrees/"):
|
|
110
|
-
continue
|
|
111
|
-
absolute = os.path.realpath(os.path.join(root, path))
|
|
112
|
-
if absolute == root or absolute.startswith(root + os.sep):
|
|
113
|
-
scoped.append(rel)
|
|
114
|
-
return scoped
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _stop_validators(project_root: str) -> list:
|
|
118
|
-
"""Per-stop validators: heavy entries (full tests / e2e suites) are skipped
|
|
119
|
-
on every Stop and only run via the explicit `cf-validate` command (or CI)."""
|
|
120
|
-
return [
|
|
121
|
-
validator
|
|
122
|
-
for validator in load_validators(project_root)
|
|
123
|
-
if not (isinstance(validator, dict) and validator.get("heavy") is True)
|
|
124
|
-
]
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def _is_active_task_file(rel_path: str) -> bool:
|
|
128
|
-
path = normalize_path(rel_path)
|
|
129
|
-
if not path.startswith(".code-flow/tasks/") or "/archived/" in path:
|
|
130
|
-
return False
|
|
131
|
-
return path.endswith(".md") and not path.endswith((".design.md", ".prd.md"))
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def _subsection(section: str, heading: str) -> str:
|
|
135
|
-
match = re.search(
|
|
136
|
-
rf"(?ms)^### {re.escape(heading)}\s*$\n(.*?)(?=^### |^## |\Z)",
|
|
137
|
-
section,
|
|
138
|
-
)
|
|
139
|
-
return match.group(1) if match else ""
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def _acceptance_gap(task_id: str, section: str, coverage: str) -> str:
|
|
143
|
-
status = re.search(r"(?m)^- \*\*Status\*\*: ([^\n]+)", section)
|
|
144
|
-
# done = implementation finished; verified = E2E/final acceptance closed.
|
|
145
|
-
# Both enter the same contract checks; anything else is still in flight.
|
|
146
|
-
if not status or status.group(1).strip() not in ("done", "verified"):
|
|
147
|
-
return ""
|
|
148
|
-
refs_match = re.search(r"(?m)^- \*\*Acceptance-Refs\*\*: ([^\n]+)", section)
|
|
149
|
-
if not refs_match:
|
|
150
|
-
return f"{task_id} 缺少 Acceptance-Refs"
|
|
151
|
-
refs = refs_match.group(1).strip()
|
|
152
|
-
if refs.upper().startswith("N/A"):
|
|
153
|
-
return ""
|
|
154
|
-
scenarios = sorted(set(_SCENARIO_RE.findall(refs)))
|
|
155
|
-
if not scenarios:
|
|
156
|
-
return f"{task_id} Acceptance-Refs 未引用 S/E/B 场景"
|
|
157
|
-
contract = _subsection(section, "Acceptance Contract")
|
|
158
|
-
evidence = _subsection(section, "Acceptance Evidence")
|
|
159
|
-
if not contract or not evidence:
|
|
160
|
-
return f"{task_id} 缺少 Acceptance Contract 或 Acceptance Evidence"
|
|
161
|
-
for scenario in scenarios:
|
|
162
|
-
coverage_rows = [line for line in coverage.splitlines() if scenario_line(line, scenario)]
|
|
163
|
-
cells = coverage_rows[0].strip().strip("|").split("|") if coverage_rows else []
|
|
164
|
-
deferred = status.group(1).strip() == "done" and len(cells) >= 3 and cells[2].strip().lower() == "e2e"
|
|
165
|
-
allowed = {"verified", "e2e_deferred"} if deferred else {"verified"}
|
|
166
|
-
states = []
|
|
167
|
-
for body in (contract, evidence, coverage):
|
|
168
|
-
found = [line_status(line, scenario) for line in body.splitlines() if scenario_line(line, scenario)]
|
|
169
|
-
states.append(found[-1] if found else "")
|
|
170
|
-
if not all(state in allowed for state in states):
|
|
171
|
-
return f"{task_id} 的 {scenario} 状态未闭环(planned/pending/TBD/failed 或缺少 verified;仅实现阶段 E2E 可 e2e_deferred)"
|
|
172
|
-
return ""
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def task_acceptance_failures(project_root: str, files: list) -> list:
|
|
176
|
-
"""Check new-format task files; legacy tasks without coverage are ignored."""
|
|
177
|
-
failures = []
|
|
178
|
-
for rel_path in files:
|
|
179
|
-
if not _is_active_task_file(rel_path):
|
|
180
|
-
continue
|
|
181
|
-
path = os.path.join(project_root, normalize_path(rel_path))
|
|
182
|
-
try:
|
|
183
|
-
with open(path, "r", encoding="utf-8") as f:
|
|
184
|
-
text = f.read()
|
|
185
|
-
except OSError as exc:
|
|
186
|
-
_log(f"cf_stop_hook task read failed: {exc}")
|
|
187
|
-
continue
|
|
188
|
-
if "## Acceptance Coverage" not in text:
|
|
189
|
-
continue
|
|
190
|
-
coverage = text.split("## Acceptance Coverage", 1)[1].split("## TASK-", 1)[0]
|
|
191
|
-
for match in _TASK_SECTION_RE.finditer(text):
|
|
192
|
-
detail = _acceptance_gap(match.group(1), match.group(0), coverage)
|
|
193
|
-
if detail:
|
|
194
|
-
failures.append({
|
|
195
|
-
"name": "任务验收契约",
|
|
196
|
-
"on_fail": "补齐设计场景的测试映射与执行证据后再完成任务",
|
|
197
|
-
"detail": detail,
|
|
198
|
-
})
|
|
199
|
-
return failures
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def _validator_failure(validator: dict, detail: str, incomplete: bool = False) -> dict:
|
|
203
|
-
result = {"name": validator.get("name", "validator"),
|
|
204
|
-
"on_fail": validator.get("on_fail", ""), "detail": detail}
|
|
205
|
-
if incomplete:
|
|
206
|
-
result["incomplete"] = True
|
|
207
|
-
return result
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _validator_argv(root: str, validator: dict, matched: list, strict: bool) -> list:
|
|
211
|
-
existing = [name for name in matched if os.path.isfile(os.path.join(root, name))]
|
|
212
|
-
template = str(validator.get("command", ""))
|
|
213
|
-
if "{files}" in template and not existing:
|
|
214
|
-
return []
|
|
215
|
-
argv = build_argv(template, existing)
|
|
216
|
-
if not argv and strict:
|
|
217
|
-
raise ValueError("validator command missing")
|
|
218
|
-
return argv
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def _validator_result(root: str, validator: dict, argv: list, sid: str,
|
|
222
|
-
deadline: float, strict: bool) -> tuple[list, bool]:
|
|
223
|
-
try:
|
|
224
|
-
timeout = float(validator.get("timeout", 30000)) / 1000.0
|
|
225
|
-
except (ValueError, TypeError):
|
|
226
|
-
if strict:
|
|
227
|
-
return [_validator_failure(validator, "invalid validator timeout")], False
|
|
228
|
-
timeout = 30.0
|
|
229
|
-
if not math.isfinite(timeout) or timeout <= 0:
|
|
230
|
-
return [_validator_failure(validator, "invalid validator timeout")], False
|
|
231
|
-
outcome = run_command(argv, root, timeout, deadline)
|
|
232
|
-
passed = outcome["status"] == "ok" and outcome["returncode"] == 0
|
|
233
|
-
if outcome["status"] == "deadline_exceeded":
|
|
234
|
-
return [_validator_failure(validator, "预算耗尽未执行", True)], True
|
|
235
|
-
timed_out = outcome["status"] == "timeout"
|
|
236
|
-
detail = (f"超时(>{timeout:.0f}s),未完成" if timed_out else
|
|
237
|
-
(str(outcome.get("stdout", "")) + str(outcome.get("stderr", ""))).strip()[-400:])
|
|
238
|
-
if outcome["status"] == "spawn_error":
|
|
239
|
-
cf_log.degrade(root, "stop_check", f"{validator.get('name')}:{outcome['stderr']}", sid)
|
|
240
|
-
if not strict:
|
|
241
|
-
return [], False
|
|
242
|
-
cf_log.append_event(root, "stop_check",
|
|
243
|
-
{"trigger": validator.get("trigger", ""),
|
|
244
|
-
"cmd": str(validator.get("command", ""))[:120], "passed": passed}, sid)
|
|
245
|
-
return ([] if passed else [_validator_failure(validator, detail)]), timed_out
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
def run_validators(
|
|
249
|
-
project_root: str, validators: list, files: list, sid: str,
|
|
250
|
-
total_budget: float = TOTAL_BUDGET_SECONDS,
|
|
251
|
-
deadline: float = 0.0,
|
|
252
|
-
strict: bool = False,
|
|
253
|
-
) -> tuple:
|
|
254
|
-
"""Match validators, execute argv under one deadline, report unrun work."""
|
|
255
|
-
failures, truncated = [], False
|
|
256
|
-
deadline = deadline or time.monotonic() + total_budget
|
|
257
|
-
for validator in validators:
|
|
258
|
-
if not isinstance(validator, dict):
|
|
259
|
-
if strict:
|
|
260
|
-
failures.append(_validator_failure({}, "invalid validator record"))
|
|
261
|
-
continue
|
|
262
|
-
matched = [f for f in files if trigger_matches(validator.get("trigger", ""), f)]
|
|
263
|
-
if not matched:
|
|
264
|
-
continue
|
|
265
|
-
remaining = remaining_seconds(deadline)
|
|
266
|
-
if remaining is not None and remaining <= 0:
|
|
267
|
-
failures.append(_validator_failure(validator, "预算耗尽未执行", True))
|
|
268
|
-
truncated = True
|
|
269
|
-
continue
|
|
270
|
-
try:
|
|
271
|
-
argv = _validator_argv(project_root, validator, matched, strict)
|
|
272
|
-
except ValueError as exc:
|
|
273
|
-
failures.append(_validator_failure(validator, f"validator 配置错误: {exc}"))
|
|
274
|
-
continue
|
|
275
|
-
if not argv:
|
|
276
|
-
continue
|
|
277
|
-
errors, incomplete = _validator_result(project_root, validator, argv, sid, deadline, strict)
|
|
278
|
-
failures.extend(errors)
|
|
279
|
-
truncated = truncated or incomplete
|
|
280
|
-
return failures, truncated
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def _reason_text(failures: list, truncated: bool) -> str:
|
|
284
|
-
lines = ["收尾校验未通过(cf-stop):"]
|
|
285
|
-
for item in failures:
|
|
286
|
-
tag = "(未执行)" if item.get("incomplete") else ""
|
|
287
|
-
lines.append(f"✗ {item['name']}{tag}:{item['on_fail']}")
|
|
288
|
-
if item["detail"]:
|
|
289
|
-
lines.append(f" 输出片段: {item['detail'][:200]}")
|
|
290
|
-
if truncated:
|
|
291
|
-
lines.append("(总预算 30s 已用尽,以上为已完成部分)")
|
|
292
|
-
lines.append("请修复后再结束。")
|
|
293
|
-
return "\n".join(lines)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
def _main() -> None:
|
|
297
|
-
try:
|
|
298
|
-
ensure_utf8_io()
|
|
299
|
-
raw = sys.stdin.read()
|
|
300
|
-
if not raw.strip():
|
|
301
|
-
return
|
|
302
|
-
data = json.loads(raw)
|
|
303
|
-
if data.get("stop_hook_active"):
|
|
304
|
-
return # 已因本 hook 续跑过一轮,避免循环
|
|
305
|
-
project_root = os.getcwd()
|
|
306
|
-
sid = resolve_session_id(data)
|
|
307
|
-
config = load_config(project_root)
|
|
308
|
-
if not config:
|
|
309
|
-
return
|
|
310
|
-
enforcement = resolve_enforcement(config)
|
|
311
|
-
if enforcement == "inject":
|
|
312
|
-
return # 轻量模式:只注入不门禁,停止会话不受限
|
|
313
|
-
marker = os.path.join(project_root, ".code-flow", ".active-task.json")
|
|
314
|
-
has_active = os.path.exists(marker)
|
|
315
|
-
files = []
|
|
316
|
-
# Single entry deadline constrains the whole chain (Done + validators),
|
|
317
|
-
# so long checks end as `incomplete` instead of being killed by the
|
|
318
|
-
# host without a verdict.
|
|
319
|
-
deadline = time.monotonic() + TOTAL_BUDGET_SECONDS
|
|
320
|
-
if has_active:
|
|
321
|
-
try:
|
|
322
|
-
active = load_active_task(project_root)
|
|
323
|
-
task_dir = os.path.join(project_root, active.task_dir)
|
|
324
|
-
gate_remaining = deadline - time.monotonic()
|
|
325
|
-
done = run_done_gate(project_root, task_dir, cheap=True, budget=min(GATE_BUDGET_SECONDS, max(gate_remaining, 0.01)))
|
|
326
|
-
except (OSError, ValueError) as exc:
|
|
327
|
-
if enforcement == "required":
|
|
328
|
-
payload = {"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: active task is invalid: {exc}"}
|
|
329
|
-
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
330
|
-
return
|
|
331
|
-
cf_log.append_event(project_root, "stop_check", {"gate": "invalid_active_nonfatal", "error": str(exc)}, sid)
|
|
332
|
-
files = []
|
|
333
|
-
else:
|
|
334
|
-
if done.decision != "pass":
|
|
335
|
-
reason = done.message or "当前 TASK required Spec verifier/Evidence 未通过;修复或重新对齐后再 Done。"
|
|
336
|
-
if any(item.get("error_code") == "verifier_budget_exhausted" for item in done.evidence):
|
|
337
|
-
reason += "(验证预算不足,部分 verifier 未运行,请拆分 TASK 或减少验证命令)"
|
|
338
|
-
if enforcement == "required":
|
|
339
|
-
payload = {"decision": "block", "reason": reason}
|
|
340
|
-
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
341
|
-
return
|
|
342
|
-
cf_log.append_event(project_root, "stop_check", {"gate": "blocked_nonfatal", "reason": reason}, sid)
|
|
343
|
-
files = list(done.files)
|
|
344
|
-
if not resolve_quality_loop(config)["stop_check"]:
|
|
345
|
-
return
|
|
346
|
-
if not has_active:
|
|
347
|
-
files = _root_scoped_files(project_root, session_edited_files(project_root, sid))
|
|
348
|
-
if not files:
|
|
349
|
-
return
|
|
350
|
-
acceptance_failures = task_acceptance_failures(project_root, files)
|
|
351
|
-
validators = _stop_validators(project_root)
|
|
352
|
-
failures, truncated = run_validators(
|
|
353
|
-
project_root, validators, files, sid, deadline=deadline
|
|
354
|
-
) if validators else ([], False)
|
|
355
|
-
failures = acceptance_failures + failures
|
|
356
|
-
if not failures:
|
|
357
|
-
return # 全过或无 validation.yml 且无验收缺口时静默
|
|
358
|
-
if enforcement == "warn":
|
|
359
|
-
cf_log.append_event(
|
|
360
|
-
project_root, "stop_check",
|
|
361
|
-
{"failures": [item["name"] for item in failures], "nonfatal": True},
|
|
362
|
-
sid,
|
|
363
|
-
)
|
|
364
|
-
return
|
|
365
|
-
payload = {"decision": "block", "reason": _reason_text(failures, truncated)}
|
|
366
|
-
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
367
|
-
except Exception as exc:
|
|
368
|
-
_log(f"cf_stop_hook error: {exc}")
|
|
369
|
-
if locals().get("enforcement") == "required":
|
|
370
|
-
sys.stdout.write(json.dumps({"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: {exc}"}, ensure_ascii=False))
|
|
371
|
-
return
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
def main() -> None:
|
|
375
|
-
with execution_session():
|
|
376
|
-
_main()
|
|
377
4
|
|
|
5
|
+
from codeflow.hooks import cf_stop_hook as _impl
|
|
378
6
|
|
|
379
7
|
if __name__ == "__main__":
|
|
380
|
-
main()
|
|
381
|
-
timing_log("cf_stop_hook")
|
|
8
|
+
_impl.main()
|
|
9
|
+
_impl.timing_log("cf_stop_hook")
|
|
10
|
+
else:
|
|
11
|
+
sys.modules[__name__] = _impl
|
|
@@ -1,140 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
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
|
|
2
|
+
"""Path-frozen entry shim: implementation lives in codeflow.cli.cf_sync."""
|
|
25
3
|
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
4
|
|
|
5
|
+
from codeflow.cli import cf_sync as _impl
|
|
138
6
|
|
|
139
7
|
if __name__ == "__main__":
|
|
140
|
-
raise SystemExit(main())
|
|
8
|
+
raise SystemExit(_impl.main())
|
|
9
|
+
else:
|
|
10
|
+
sys.modules[__name__] = _impl
|
|
@@ -1,148 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
Single worktrees allow one active TASK at a time; batches list *independently
|
|
5
|
-
developable* tasks (no dependency path between them). `cf-task:start` may
|
|
6
|
-
dispatch batch tasks in their own worktrees for parallel subagents — the
|
|
7
|
-
single-active constraint stays per worktree.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import argparse
|
|
13
|
-
import json
|
|
14
|
-
import re
|
|
2
|
+
"""Path-frozen entry shim: implementation lives in codeflow.cli.cf_task_index."""
|
|
15
3
|
import sys
|
|
16
|
-
from dataclasses import dataclass
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
from typing import IO, Mapping, Optional, Sequence
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
from cf_task_state import FINISHED_STATUSES as _DONE_STATUSES
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@dataclass(frozen=True)
|
|
25
|
-
class TaskNode:
|
|
26
|
-
task_id: str
|
|
27
|
-
status: str
|
|
28
|
-
depends: tuple[str, ...]
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def parse_task_file(task_file: str) -> tuple[TaskNode, ...]:
|
|
32
|
-
"""Parse every `## TASK-xxx` section into status/depends nodes.
|
|
33
|
-
|
|
34
|
-
Unknown statuses are preserved verbatim (e.g. `verified` from the E2E
|
|
35
|
-
flow) — the parser never rejects them, completion checks treat done and
|
|
36
|
-
verified as finished.
|
|
37
|
-
"""
|
|
38
|
-
try:
|
|
39
|
-
text = Path(task_file).read_text(encoding="utf-8")
|
|
40
|
-
except (OSError, UnicodeError) as exc:
|
|
41
|
-
raise ValueError(f"task_read_error: {exc}") from exc
|
|
42
|
-
nodes: list[TaskNode] = []
|
|
43
|
-
matches = list(re.finditer(r"(?m)^## (TASK-\d+):[^\n]*$", text))
|
|
44
|
-
for index, match in enumerate(matches):
|
|
45
|
-
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
|
46
|
-
section = text[match.start():end]
|
|
47
|
-
status_match = re.search(r"(?m)^- \*\*Status\*\*:[ \t]*([^\n]+)", section)
|
|
48
|
-
status = status_match.group(1).strip() if status_match else "draft"
|
|
49
|
-
depends_match = re.search(r"(?m)^- \*\*Depends\*\*:[ \t]*([^\n]*)", section)
|
|
50
|
-
depends: tuple[str, ...] = ()
|
|
51
|
-
if depends_match:
|
|
52
|
-
depends = tuple(
|
|
53
|
-
part.strip()
|
|
54
|
-
for part in re.split(r"[,,\s]+", depends_match.group(1).strip())
|
|
55
|
-
if re.fullmatch(r"TASK-\d+", part.strip())
|
|
56
|
-
)
|
|
57
|
-
nodes.append(TaskNode(match.group(1), status, depends))
|
|
58
|
-
if not nodes:
|
|
59
|
-
raise ValueError("no TASK sections found")
|
|
60
|
-
return tuple(nodes)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def task_batches(nodes: Sequence[TaskNode]) -> list[list[str]]:
|
|
64
|
-
"""Topological batches over unfinished TASKs; unknown deps and cycles raise."""
|
|
65
|
-
ids = {node.task_id for node in nodes}
|
|
66
|
-
for node in nodes:
|
|
67
|
-
unknown = [dep for dep in node.depends if dep not in ids]
|
|
68
|
-
if unknown:
|
|
69
|
-
raise ValueError(f"unknown depends: {node.task_id} -> {', '.join(unknown)}")
|
|
70
|
-
pending = {node.task_id for node in nodes if node.status not in _DONE_STATUSES}
|
|
71
|
-
finished = {node.task_id for node in nodes if node.status in _DONE_STATUSES}
|
|
72
|
-
deps = {node.task_id: set(node.depends) for node in nodes}
|
|
73
|
-
batches: list[list[str]] = []
|
|
74
|
-
while pending:
|
|
75
|
-
ready = sorted(task for task in pending if deps[task] <= finished)
|
|
76
|
-
if not ready:
|
|
77
|
-
raise ValueError(f"dependency cycle among: {', '.join(sorted(pending))}")
|
|
78
|
-
batches.append(ready)
|
|
79
|
-
finished.update(ready)
|
|
80
|
-
pending.difference_update(ready)
|
|
81
|
-
return batches
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def dependency_components(nodes: Sequence[TaskNode]) -> list[list[str]]:
|
|
85
|
-
"""Connected dependency components; different components are independent."""
|
|
86
|
-
parent = {node.task_id: node.task_id for node in nodes}
|
|
87
|
-
|
|
88
|
-
def find(task: str) -> str:
|
|
89
|
-
while parent[task] != task:
|
|
90
|
-
parent[task] = parent[parent[task]]
|
|
91
|
-
task = parent[task]
|
|
92
|
-
return task
|
|
93
|
-
|
|
94
|
-
for node in nodes:
|
|
95
|
-
for dep in node.depends:
|
|
96
|
-
left, right = find(node.task_id), find(dep)
|
|
97
|
-
if left != right:
|
|
98
|
-
parent[left] = right
|
|
99
|
-
groups: dict[str, list[str]] = {}
|
|
100
|
-
for node in nodes:
|
|
101
|
-
groups.setdefault(find(node.task_id), []).append(node.task_id)
|
|
102
|
-
return [sorted(members) for members in groups.values()]
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def independent_groups(nodes: Sequence[TaskNode]) -> list[list[str]]:
|
|
106
|
-
"""Compatibility alias: groups are independent of each other, not within."""
|
|
107
|
-
return dependency_components(nodes)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def index_data(task_file: str) -> dict[str, object]:
|
|
111
|
-
nodes = parse_task_file(task_file)
|
|
112
|
-
return {
|
|
113
|
-
"tasks": [
|
|
114
|
-
{"id": node.task_id, "status": node.status, "depends": list(node.depends)}
|
|
115
|
-
for node in nodes
|
|
116
|
-
],
|
|
117
|
-
"batches": task_batches(nodes),
|
|
118
|
-
"independent_groups": independent_groups(nodes),
|
|
119
|
-
"dependency_components": dependency_components(nodes),
|
|
120
|
-
"note": "batches 组内无依赖;dependency_components 仅组间独立(independent_groups 为兼容别名);单 worktree 一次仅激活一个 TASK,批次内独立任务可由 cf-task:start 在各 worktree 并行派发",
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
125
|
-
parser = argparse.ArgumentParser(prog="cf_task_index.py")
|
|
126
|
-
parser.add_argument("--task-file", required=True)
|
|
127
|
-
parser.add_argument("--dag", action="store_true", help="输出拓扑批次与独立分组")
|
|
128
|
-
parser.add_argument("--json", action="store_true")
|
|
129
|
-
args = parser.parse_args(argv)
|
|
130
|
-
try:
|
|
131
|
-
data = index_data(args.task_file)
|
|
132
|
-
except (OSError, ValueError) as exc:
|
|
133
|
-
stdout.write(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
|
134
|
-
return 2
|
|
135
|
-
if args.json or args.dag:
|
|
136
|
-
stdout.write(json.dumps(data, ensure_ascii=False))
|
|
137
|
-
else:
|
|
138
|
-
lines = ["TASK 索引:"]
|
|
139
|
-
for item in data["tasks"]:
|
|
140
|
-
lines.append(f" - {item['id']}: {item['status']} (依赖: {', '.join(item['depends']) or '无'})")
|
|
141
|
-
for index, batch in enumerate(data["batches"], 1):
|
|
142
|
-
lines.append(f"批次 {index}(可独立开发): {', '.join(batch)}")
|
|
143
|
-
stdout.write("\n".join(lines) + "\n")
|
|
144
|
-
return 0
|
|
145
4
|
|
|
5
|
+
from codeflow.cli import cf_task_index as _impl
|
|
146
6
|
|
|
147
7
|
if __name__ == "__main__":
|
|
148
|
-
raise SystemExit(main())
|
|
8
|
+
raise SystemExit(_impl.main())
|
|
9
|
+
else:
|
|
10
|
+
sys.modules[__name__] = _impl
|