@michaelxwb/code-flow 0.6.3 → 0.6.5
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 +2 -2
- package/package.json +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -0
- package/src/adapters/claude/commands/cf-spec.md +7 -3
- package/src/adapters/claude/commands/cf-task/archive.md +2 -0
- package/src/adapters/claude/commands/cf-task/graph.md +1 -1
- package/src/adapters/claude/commands/cf-task/plan.md +2 -2
- package/src/adapters/claude/commands/cf-task/start.md +99 -10
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +6 -1
- package/src/adapters/claude/commands/cf-validate.md +3 -0
- package/src/adapters/codex/skills/cf-init/SKILL.md +1 -0
- package/src/adapters/codex/skills/cf-spec/SKILL.md +6 -2
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +2 -0
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +1 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +2 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +99 -10
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +6 -1
- package/src/adapters/codex/skills/cf-validate/SKILL.md +3 -0
- package/src/adapters/costrict/commands/cf-init.md +1 -0
- package/src/adapters/costrict/commands/cf-spec.md +7 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +2 -0
- package/src/adapters/costrict/commands/cf-task/graph.md +1 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +2 -2
- package/src/adapters/costrict/commands/cf-task/start.md +99 -10
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +6 -1
- package/src/adapters/costrict/commands/cf-validate.md +3 -0
- package/src/adapters/opencode/commands/cf-init.md +9 -4
- package/src/adapters/opencode/commands/cf-spec.md +6 -2
- package/src/adapters/opencode/commands/cf-task/archive.md +2 -0
- package/src/adapters/opencode/commands/cf-task/graph.md +1 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +2 -2
- package/src/adapters/opencode/commands/cf-task/start.md +99 -10
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +6 -1
- package/src/adapters/opencode/commands/cf-validate.md +3 -0
- package/src/adapters/opencode/opencode.json +1 -2
- package/src/adapters/opencode/plugins/code-flow/index.js +138 -78
- package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
- package/src/cli.js +51 -1
- package/src/core/code-flow/config.yml +2 -1
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +54 -8
- package/src/core/code-flow/scripts/cf_core.py +28 -1
- package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +14 -3
- package/src/core/code-flow/scripts/cf_spec_context.py +53 -16
- package/src/core/code-flow/scripts/cf_spec_gate.py +28 -4
- package/src/core/code-flow/scripts/cf_spec_metadata.py +34 -3
- package/src/core/code-flow/scripts/cf_spec_router.py +1 -1
- package/src/core/code-flow/scripts/cf_spec_verify.py +107 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +31 -3
- package/src/core/code-flow/scripts/cf_task_index.py +5 -4
- package/src/core/code-flow/scripts/cf_task_parallel.py +394 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +49 -10
- package/src/core/code-flow/scripts/cf_task_workflow.py +111 -13
- package/src/core/code-flow/validation.yml +5 -0
|
@@ -42,6 +42,7 @@ DEFAULT_ACTIVE_EXCLUDES = (
|
|
|
42
42
|
".code-flow/.session-log.jsonl",
|
|
43
43
|
".code-flow/.active-task.json",
|
|
44
44
|
".code-flow/.active-task.lock",
|
|
45
|
+
".code-flow/worktrees/*",
|
|
45
46
|
)
|
|
46
47
|
|
|
47
48
|
|
|
@@ -100,6 +101,7 @@ class RuleBinding:
|
|
|
100
101
|
enforcement: str
|
|
101
102
|
verifier_ref: str
|
|
102
103
|
stage_status: Mapping[str, RuleStageStatus]
|
|
104
|
+
verifier_stage: str = "code"
|
|
103
105
|
|
|
104
106
|
|
|
105
107
|
@dataclass(frozen=True)
|
|
@@ -805,6 +807,7 @@ def _rule_from_data(value: object, path: str) -> RuleBinding:
|
|
|
805
807
|
_string(data.get("enforcement"), "rules[].enforcement", path),
|
|
806
808
|
_string(data.get("verifier_ref"), "rules[].verifier_ref", path),
|
|
807
809
|
parsed,
|
|
810
|
+
_string(data.get("verifier_stage", "code"), "rules[].verifier_stage", path),
|
|
808
811
|
)
|
|
809
812
|
|
|
810
813
|
|
|
@@ -815,6 +818,7 @@ def _rule_data(rule: RuleBinding) -> dict[str, object]:
|
|
|
815
818
|
"text_sha256": rule.text_sha256,
|
|
816
819
|
"enforcement": rule.enforcement,
|
|
817
820
|
"verifier_ref": rule.verifier_ref,
|
|
821
|
+
"verifier_stage": rule.verifier_stage,
|
|
818
822
|
"stage_status": {stage: _stage_data(status) for stage, status in rule.stage_status.items()},
|
|
819
823
|
}
|
|
820
824
|
|
|
@@ -989,7 +993,12 @@ def _binding_from_input(selection: BindingInput) -> SpecBinding:
|
|
|
989
993
|
}
|
|
990
994
|
verifier = verifier_by_rule.get(rule.ref)
|
|
991
995
|
verifier_ref = f"{candidate.spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
|
|
992
|
-
rules.append(
|
|
996
|
+
rules.append(
|
|
997
|
+
RuleBinding(
|
|
998
|
+
rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses,
|
|
999
|
+
verifier.stage if verifier is not None else "code",
|
|
1000
|
+
)
|
|
1001
|
+
)
|
|
993
1002
|
return SpecBinding(
|
|
994
1003
|
candidate.spec_id,
|
|
995
1004
|
candidate.path,
|
|
@@ -1002,12 +1011,26 @@ def _binding_from_input(selection: BindingInput) -> SpecBinding:
|
|
|
1002
1011
|
)
|
|
1003
1012
|
|
|
1004
1013
|
|
|
1014
|
+
def _with_declared_stages(rule: RuleBinding, stages: Sequence[str]) -> RuleBinding:
|
|
1015
|
+
"""兼容补齐:绑定旧版本时声明的 stages 缺状态,按声明补 pending。"""
|
|
1016
|
+
missing = [stage for stage in stages if stage not in rule.stage_status]
|
|
1017
|
+
if not missing:
|
|
1018
|
+
return rule
|
|
1019
|
+
statuses = dict(rule.stage_status)
|
|
1020
|
+
for stage in missing:
|
|
1021
|
+
statuses[stage] = RuleStageStatus("pending", (), None, ())
|
|
1022
|
+
return replace(rule, stage_status=statuses)
|
|
1023
|
+
|
|
1024
|
+
|
|
1005
1025
|
def _rule_from_metadata(spec_id: str, metadata: SpecMetadata, rule: SpecRule) -> RuleBinding:
|
|
1006
1026
|
enforcement = "advisory" if metadata.enforcement == "advisory" else rule.enforcement
|
|
1007
1027
|
statuses = {stage: RuleStageStatus("pending", (), None, ()) for stage in metadata.stages}
|
|
1008
|
-
|
|
1009
|
-
verifier_ref = f"{spec_id}#{rule.ref}" if
|
|
1010
|
-
return RuleBinding(
|
|
1028
|
+
verifier = next((item for item in metadata.verifiers if item.rule == rule.ref), None)
|
|
1029
|
+
verifier_ref = f"{spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
|
|
1030
|
+
return RuleBinding(
|
|
1031
|
+
rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses,
|
|
1032
|
+
verifier.stage if verifier is not None else "code",
|
|
1033
|
+
)
|
|
1011
1034
|
|
|
1012
1035
|
|
|
1013
1036
|
def bind_specs(context: SpecContext, selections: Sequence[BindingInput]) -> SpecContext:
|
|
@@ -1155,14 +1178,15 @@ def _refresh_existing_rule(
|
|
|
1155
1178
|
binding: SpecBinding, existing: RuleBinding, current: SpecRule, metadata: SpecMetadata
|
|
1156
1179
|
) -> tuple[RuleBinding, Optional[DriftChange]]:
|
|
1157
1180
|
enforcement = "advisory" if metadata.enforcement == "advisory" else current.enforcement
|
|
1158
|
-
|
|
1159
|
-
verifier_ref = f"{binding.spec_id}#{current.ref}" if
|
|
1181
|
+
verifier = next((item for item in metadata.verifiers if item.rule == current.ref), None)
|
|
1182
|
+
verifier_ref = f"{binding.spec_id}#{current.ref}" if verifier is not None else "advisory:none"
|
|
1160
1183
|
updated = replace(
|
|
1161
1184
|
existing,
|
|
1162
1185
|
summary=current.text,
|
|
1163
1186
|
text_sha256=current.text_sha256,
|
|
1164
1187
|
enforcement=enforcement,
|
|
1165
1188
|
verifier_ref=verifier_ref,
|
|
1189
|
+
verifier_stage=verifier.stage if verifier is not None else "code",
|
|
1166
1190
|
)
|
|
1167
1191
|
if existing.text_sha256 == current.text_sha256:
|
|
1168
1192
|
return updated, None
|
|
@@ -1199,6 +1223,7 @@ def _refresh_binding(
|
|
|
1199
1223
|
rule, change = _refresh_existing_rule(binding, prior, current, metadata)
|
|
1200
1224
|
if change is not None:
|
|
1201
1225
|
changes.append(change)
|
|
1226
|
+
rule = _with_declared_stages(rule, metadata.stages)
|
|
1202
1227
|
rule, artifact_changes = _refresh_artifacts(binding.spec_id, rule, artifact_root)
|
|
1203
1228
|
rules.append(rule)
|
|
1204
1229
|
changes.extend(artifact_changes)
|
|
@@ -1314,8 +1339,15 @@ def apply_artifact_ref(
|
|
|
1314
1339
|
|
|
1315
1340
|
def _json_payload(stream: IO[str]) -> Mapping[str, object]:
|
|
1316
1341
|
try:
|
|
1317
|
-
|
|
1318
|
-
except (
|
|
1342
|
+
raw = stream.read()
|
|
1343
|
+
except (OSError, UnicodeError) as exc:
|
|
1344
|
+
raise ContextError("invalid_json", "stdin", str(exc)) from exc
|
|
1345
|
+
if not raw.strip():
|
|
1346
|
+
# 空 stdin = 无 payload(例如 active doctor --resync 全参数走 CLI 开关)。
|
|
1347
|
+
return {}
|
|
1348
|
+
try:
|
|
1349
|
+
loaded = json.loads(raw)
|
|
1350
|
+
except json.JSONDecodeError as exc:
|
|
1319
1351
|
raise ContextError("invalid_json", "stdin", str(exc)) from exc
|
|
1320
1352
|
return _mapping(loaded, "stdin", "")
|
|
1321
1353
|
|
|
@@ -1508,12 +1540,11 @@ def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) ->
|
|
|
1508
1540
|
except WorkflowError as exc:
|
|
1509
1541
|
raise _reraise(exc) from exc
|
|
1510
1542
|
else:
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
)
|
|
1543
|
+
abandon = payload.get("abandon") is True or args.abandon
|
|
1544
|
+
resync = payload.get("resync") is True or args.resync
|
|
1545
|
+
if abandon and resync:
|
|
1546
|
+
raise ContextError("conflicting_recovery", "doctor", "abandon 与 resync 不能同时使用", args.task_dir)
|
|
1547
|
+
result = doctor_active_task(args.root, args.context_sha256, abandon, resync)
|
|
1517
1548
|
return {"ok": True, "action": result.action, "active": _active_data(result.active)}
|
|
1518
1549
|
return {"ok": True, "active": _active_data(active)}
|
|
1519
1550
|
|
|
@@ -1564,11 +1595,13 @@ def _status_command(args: argparse.Namespace) -> dict[str, object]:
|
|
|
1564
1595
|
rules = []
|
|
1565
1596
|
for rule in binding.rules:
|
|
1566
1597
|
status = rule.stage_status.get("code")
|
|
1598
|
+
stages = {stage: item.status for stage, item in sorted(rule.stage_status.items())}
|
|
1567
1599
|
rules.append(
|
|
1568
1600
|
{
|
|
1569
1601
|
"ref": f"{binding.spec_id}#{rule.ref}",
|
|
1570
1602
|
"enforcement": rule.enforcement,
|
|
1571
1603
|
"status": status.status if status else "missing",
|
|
1604
|
+
"stages": stages,
|
|
1572
1605
|
"verifier": rule.verifier_ref,
|
|
1573
1606
|
}
|
|
1574
1607
|
)
|
|
@@ -1589,7 +1622,7 @@ def _status_text(data: dict[str, object]) -> str:
|
|
|
1589
1622
|
match = "✓ 一致" if marker["hash_match"] else "✗ 漂移"
|
|
1590
1623
|
lines.append(f"- TASK {marker['task_id']}({marker['status']}),marker hash {match}")
|
|
1591
1624
|
if not marker["hash_match"]:
|
|
1592
|
-
lines.append(" 下一步: 运行 cf-spec doctor(
|
|
1625
|
+
lines.append(" 下一步: 运行 cf-spec refresh 自动重同步 marker hash;仍不一致时用 active doctor --resync(hash 取本命令 --json 的 context_sha256)")
|
|
1593
1626
|
else:
|
|
1594
1627
|
lines.append("- 无 active TASK(path/catalog 路由模式)")
|
|
1595
1628
|
gate = data["gate"]
|
|
@@ -1602,7 +1635,8 @@ def _status_text(data: dict[str, object]) -> str:
|
|
|
1602
1635
|
for binding in data["bindings"]:
|
|
1603
1636
|
lines.append(f"- {binding['spec_id']}({binding['path']})— {binding['status']}")
|
|
1604
1637
|
for rule in binding["rules"]:
|
|
1605
|
-
|
|
1638
|
+
stage_text = " ".join(f"{stage}={value}" for stage, value in rule.get("stages", {}).items())
|
|
1639
|
+
lines.append(f" {stage_text:<24} {rule['ref']}({rule['verifier']})")
|
|
1606
1640
|
return "\n".join(lines)
|
|
1607
1641
|
|
|
1608
1642
|
|
|
@@ -1643,6 +1677,9 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
1643
1677
|
command.add_argument("--task", required=True)
|
|
1644
1678
|
command.add_argument("--context-sha256", required=True)
|
|
1645
1679
|
command.add_argument("--json", action="store_true")
|
|
1680
|
+
if action == "doctor":
|
|
1681
|
+
command.add_argument("--abandon", action="store_true", help="确认放弃无法证明的 marker(需用户确认)")
|
|
1682
|
+
command.add_argument("--resync", action="store_true", help="用 --context-sha256 重新绑定 marker 并恢复 active")
|
|
1646
1683
|
start = commands.add_parser("start")
|
|
1647
1684
|
start.add_argument("--task-dir", required=True)
|
|
1648
1685
|
start.add_argument("--root", required=True)
|
|
@@ -74,11 +74,28 @@ def _evidence_issue(rule: RuleBinding, status: RuleStageStatus, diff_sha256: Opt
|
|
|
74
74
|
return "stale_evidence"
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
def
|
|
77
|
+
def _cheap_skip(status: RuleStageStatus) -> bool:
|
|
78
|
+
"""Latest evidence is a cheap-gate skip (command/test verifier deferred)."""
|
|
79
|
+
if not status.evidence:
|
|
80
|
+
return False
|
|
81
|
+
latest = status.evidence[-1]
|
|
82
|
+
return isinstance(latest, Mapping) and latest.get("error_code") == "skipped_in_cheap_gate"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _status_issue(
|
|
86
|
+
rule: RuleBinding,
|
|
87
|
+
status: RuleStageStatus,
|
|
88
|
+
now: datetime,
|
|
89
|
+
diff_sha256: Optional[str],
|
|
90
|
+
allow_cheap_skips: bool = False,
|
|
91
|
+
) -> Optional[str]:
|
|
78
92
|
if status.status == "applied":
|
|
79
93
|
return None
|
|
80
94
|
if status.status == "verified":
|
|
81
95
|
return _evidence_issue(rule, status, diff_sha256)
|
|
96
|
+
if allow_cheap_skips and status.status == "unverified" and _cheap_skip(status):
|
|
97
|
+
# 轻量门禁(stop 阶段):命令类 verifier 未执行属预期,留给 finish 全量门禁。
|
|
98
|
+
return None
|
|
82
99
|
if status.status == "not_applicable":
|
|
83
100
|
return None if _decision_valid(status.decision) else "decision_invalid"
|
|
84
101
|
if status.status == "waived":
|
|
@@ -87,7 +104,11 @@ def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime, dif
|
|
|
87
104
|
|
|
88
105
|
|
|
89
106
|
def _binding_issues(
|
|
90
|
-
binding: SpecBinding,
|
|
107
|
+
binding: SpecBinding,
|
|
108
|
+
stage: str,
|
|
109
|
+
now: datetime,
|
|
110
|
+
diff_sha256: Optional[str],
|
|
111
|
+
allow_cheap_skips: bool = False,
|
|
91
112
|
) -> tuple[list[GateIssue], list[GateIssue]]:
|
|
92
113
|
errors: list[GateIssue] = []
|
|
93
114
|
warnings: list[GateIssue] = []
|
|
@@ -95,9 +116,11 @@ def _binding_issues(
|
|
|
95
116
|
status = rule.stage_status.get(stage)
|
|
96
117
|
if status is None:
|
|
97
118
|
continue
|
|
119
|
+
if stage in ("code", "review") and getattr(rule, "verifier_stage", "code") != stage:
|
|
120
|
+
continue
|
|
98
121
|
if rule.enforcement == "advisory" and status.status == "pending":
|
|
99
122
|
continue
|
|
100
|
-
code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256)
|
|
123
|
+
code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256, allow_cheap_skips)
|
|
101
124
|
if code is None:
|
|
102
125
|
continue
|
|
103
126
|
issue = GateIssue(code, binding.spec_id, rule.ref, stage, f"{binding.spec_id}#{rule.ref}: {code}")
|
|
@@ -113,13 +136,14 @@ def validate_stage(
|
|
|
113
136
|
task_id: str = "",
|
|
114
137
|
now: Optional[datetime] = None,
|
|
115
138
|
diff_sha256: Optional[str] = None,
|
|
139
|
+
allow_cheap_skips: bool = False,
|
|
116
140
|
) -> GateResult:
|
|
117
141
|
del artifact, task_id
|
|
118
142
|
current = now or datetime.now(timezone.utc)
|
|
119
143
|
errors: list[GateIssue] = []
|
|
120
144
|
warnings: list[GateIssue] = []
|
|
121
145
|
for binding in context.bindings:
|
|
122
|
-
binding_errors, binding_warnings = _binding_issues(binding, stage, current, diff_sha256)
|
|
146
|
+
binding_errors, binding_warnings = _binding_issues(binding, stage, current, diff_sha256, allow_cheap_skips)
|
|
123
147
|
errors.extend(binding_errors)
|
|
124
148
|
warnings.extend(binding_warnings)
|
|
125
149
|
refs = tuple(sorted({f"{item.spec_id}#{item.rule_ref}" for item in (*errors, *warnings)}))
|
|
@@ -15,6 +15,7 @@ from typing import Mapping, Optional
|
|
|
15
15
|
ALLOWED_STAGES = frozenset(("prd", "design", "plan", "code", "review"))
|
|
16
16
|
ALLOWED_ENFORCEMENT = frozenset(("required", "advisory"))
|
|
17
17
|
ALLOWED_VERIFIERS = frozenset(("document", "regex", "ast", "command", "test", "manual"))
|
|
18
|
+
ALLOWED_VERIFIER_STAGES = frozenset(("code", "review"))
|
|
18
19
|
_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
19
20
|
_RULE_ID_RE = re.compile(r"^RULE-[a-z0-9]+(?:-[a-z0-9]+)*-\d{3}$")
|
|
20
21
|
_EXPLICIT_RULE_RE = re.compile(r"^\[(RULE-[a-z0-9-]+-\d{3})\]\s+(.+)$")
|
|
@@ -52,6 +53,8 @@ class SpecVerifier:
|
|
|
52
53
|
rule: str
|
|
53
54
|
type: str
|
|
54
55
|
config: Mapping[str, object]
|
|
56
|
+
stage: str = "code"
|
|
57
|
+
files: tuple[str, ...] = ()
|
|
55
58
|
|
|
56
59
|
|
|
57
60
|
@dataclass(frozen=True)
|
|
@@ -209,7 +212,26 @@ def _validate_checks(meta: Mapping[str, object], path: str, raw: str) -> tuple[M
|
|
|
209
212
|
return tuple(value)
|
|
210
213
|
|
|
211
214
|
|
|
212
|
-
def
|
|
215
|
+
def _parse_verifier_files(value: object, path: str, raw: str, index: int) -> tuple[str, ...]:
|
|
216
|
+
line = _verifier_field_line(raw, index, "files")
|
|
217
|
+
if not isinstance(value, list) or not value:
|
|
218
|
+
raise SpecMetadataError(path, f"verifiers[{index}].files", line, "必须是非空 glob 字符串数组")
|
|
219
|
+
files: list[str] = []
|
|
220
|
+
for item in value:
|
|
221
|
+
if not isinstance(item, str) or not item.strip():
|
|
222
|
+
raise SpecMetadataError(path, f"verifiers[{index}].files", line, "glob 必须是非空字符串")
|
|
223
|
+
text = item.strip().replace("\\", "/")
|
|
224
|
+
if text.startswith("/") or re.match(r"^[A-Za-z]:/", text):
|
|
225
|
+
raise SpecMetadataError(path, f"verifiers[{index}].files", line, f"禁止绝对路径: {item!r}")
|
|
226
|
+
if ".." in text.split("/"):
|
|
227
|
+
raise SpecMetadataError(path, f"verifiers[{index}].files", line, f"禁止 .. 跳出仓库: {item!r}")
|
|
228
|
+
files.append(text)
|
|
229
|
+
return tuple(files)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _parse_verifiers(
|
|
233
|
+
meta: Mapping[str, object], path: str, raw: str, stages: tuple[str, ...]
|
|
234
|
+
) -> tuple[SpecVerifier, ...]:
|
|
213
235
|
value = meta.get("verifiers", [])
|
|
214
236
|
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
|
215
237
|
raise SpecMetadataError(path, "verifiers", _field_line(raw, "verifiers"), "必须是 mapping 列表")
|
|
@@ -219,6 +241,8 @@ def _parse_verifiers(meta: Mapping[str, object], path: str, raw: str) -> tuple[S
|
|
|
219
241
|
rule = item.get("rule")
|
|
220
242
|
kind = item.get("type")
|
|
221
243
|
config = item.get("config")
|
|
244
|
+
explicit_stage = "stage" in item
|
|
245
|
+
stage = item.get("stage", "code")
|
|
222
246
|
if not isinstance(rule, str) or not _RULE_ID_RE.fullmatch(rule):
|
|
223
247
|
line = _verifier_field_line(raw, index, "rule")
|
|
224
248
|
raise SpecMetadataError(path, f"verifiers[{index}].rule", line, "必须是稳定 RULE ID")
|
|
@@ -228,10 +252,17 @@ def _parse_verifiers(meta: Mapping[str, object], path: str, raw: str) -> tuple[S
|
|
|
228
252
|
if not isinstance(config, dict):
|
|
229
253
|
line = _verifier_field_line(raw, index, "config")
|
|
230
254
|
raise SpecMetadataError(path, f"verifiers[{index}].config", line, "必须是 mapping")
|
|
255
|
+
if explicit_stage and (not isinstance(stage, str) or stage not in ALLOWED_VERIFIER_STAGES):
|
|
256
|
+
line = _verifier_field_line(raw, index, "stage")
|
|
257
|
+
raise SpecMetadataError(path, f"verifiers[{index}].stage", line, f"必须是 {sorted(ALLOWED_VERIFIER_STAGES)} 之一")
|
|
258
|
+
if explicit_stage and stage not in stages:
|
|
259
|
+
line = _verifier_field_line(raw, index, "stage")
|
|
260
|
+
raise SpecMetadataError(path, f"verifiers[{index}].stage", line, f"stage {stage!r} 未在 stages 声明: {', '.join(stages)}")
|
|
261
|
+
files = _parse_verifier_files(item["files"], path, raw, index) if "files" in item else ()
|
|
231
262
|
if rule in seen:
|
|
232
263
|
raise SpecMetadataError(path, "verifiers", _field_line(raw, "verifiers"), f"重复 verifier: {rule}")
|
|
233
264
|
seen.add(rule)
|
|
234
|
-
result.append(SpecVerifier(rule=rule, type=kind, config=config))
|
|
265
|
+
result.append(SpecVerifier(rule=rule, type=kind, config=config, stage=stage, files=files))
|
|
235
266
|
return tuple(result)
|
|
236
267
|
|
|
237
268
|
|
|
@@ -324,7 +355,7 @@ def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optiona
|
|
|
324
355
|
if owner is not None and (not isinstance(owner, str) or not owner.strip()):
|
|
325
356
|
raise SpecMetadataError(path, "owner", _field_line(raw_frontmatter, "owner"), "必须是非空字符串")
|
|
326
357
|
checks = _validate_checks(meta, path, raw_frontmatter)
|
|
327
|
-
verifiers = _parse_verifiers(meta, path, raw_frontmatter)
|
|
358
|
+
verifiers = _parse_verifiers(meta, path, raw_frontmatter, header.stages)
|
|
328
359
|
body_line_offset = raw_frontmatter.count("\n") + 3
|
|
329
360
|
rules = _parse_rules(body, path, body_line_offset)
|
|
330
361
|
_validate_links(rules, verifiers, path, raw_frontmatter)
|
|
@@ -137,7 +137,7 @@ def _active_route(root: str) -> RouteResult:
|
|
|
137
137
|
if current_hash != active.context_sha256:
|
|
138
138
|
raise RouterError(
|
|
139
139
|
"active_context_drift",
|
|
140
|
-
"active marker Context hash does not match; run `
|
|
140
|
+
"active marker Context hash does not match; run `cf_spec_context.py refresh --task-dir <dir> --root <root> --json` to re-sync it (refresh updates the marker hash), or `active doctor --resync` with the hash from `status --json`",
|
|
141
141
|
)
|
|
142
142
|
cached = _cached_projection(root, task_dir, active.task_id, current_hash)
|
|
143
143
|
if cached is None:
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import ast
|
|
7
|
-
from dataclasses import dataclass
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
8
|
from datetime import datetime, timezone
|
|
9
9
|
import fnmatch
|
|
10
10
|
import hashlib
|
|
@@ -59,19 +59,94 @@ def _cache_file(root: str) -> Path:
|
|
|
59
59
|
return Path(root) / ".code-flow" / ".verifier-cache.json"
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]]) -> str:
|
|
62
|
+
def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]], stage: str = "code") -> str:
|
|
63
63
|
payload = {
|
|
64
64
|
"spec": metadata.hashes.file_sha256,
|
|
65
65
|
"rule": rule.text_sha256,
|
|
66
66
|
"verifier": {"type": verifier.type, "config": verifier.config},
|
|
67
67
|
"files": scope.files,
|
|
68
68
|
"diff": scope.diff_sha256,
|
|
69
|
+
"stage": stage,
|
|
69
70
|
"confirmation": confirmation or {},
|
|
70
71
|
"artifact_content": _artifact_fingerprint(verifier, scope),
|
|
71
72
|
}
|
|
72
73
|
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
|
|
73
74
|
|
|
74
75
|
|
|
76
|
+
def _normalize_scope_path(value: str) -> str:
|
|
77
|
+
return value.replace("\\", "/").strip()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _matches_scope(path: str, patterns: Sequence[str]) -> bool:
|
|
81
|
+
for pattern in patterns:
|
|
82
|
+
if fnmatch.fnmatchcase(path, pattern):
|
|
83
|
+
return True
|
|
84
|
+
bare = pattern.rstrip("/")
|
|
85
|
+
if not any(char in pattern for char in "*?[") and path.startswith(bare + "/"):
|
|
86
|
+
return True
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _git_tracked_files(root: str) -> tuple[str, ...]:
|
|
91
|
+
try:
|
|
92
|
+
outcome = subprocess.run(
|
|
93
|
+
("git", "ls-files"), cwd=root, text=True, encoding="utf-8", capture_output=True, check=False
|
|
94
|
+
)
|
|
95
|
+
except OSError:
|
|
96
|
+
return ()
|
|
97
|
+
if outcome.returncode != 0:
|
|
98
|
+
return ()
|
|
99
|
+
return tuple(_normalize_scope_path(line) for line in outcome.stdout.splitlines() if line.strip())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def scoped_files(verifier: SpecVerifier, scope: VerificationScope) -> tuple[str, ...]:
|
|
103
|
+
"""作用域命中的文件集合:git tracked ∪ 任务持有文件,按 verifier.files 过滤。
|
|
104
|
+
|
|
105
|
+
决议仓库级集合(而非仅当前任务 diff)才能让未触及作用域的其他任务命中缓存。
|
|
106
|
+
"""
|
|
107
|
+
if not verifier.files:
|
|
108
|
+
return ()
|
|
109
|
+
candidates = {_normalize_scope_path(item) for item in scope.files if item.strip()}
|
|
110
|
+
candidates.update(_git_tracked_files(scope.root))
|
|
111
|
+
return tuple(sorted(path for path in candidates if _matches_scope(path, verifier.files)))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _scope_fingerprint(root: str, files: Sequence[str]) -> str:
|
|
115
|
+
digest = hashlib.sha256()
|
|
116
|
+
for relative in files:
|
|
117
|
+
path = Path(root) / relative
|
|
118
|
+
digest.update(relative.encode("utf-8"))
|
|
119
|
+
try:
|
|
120
|
+
digest.update(path.read_bytes() if path.is_file() else b"<deleted>")
|
|
121
|
+
except OSError:
|
|
122
|
+
digest.update(b"<unreadable>")
|
|
123
|
+
return digest.hexdigest()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _scoped_cache_key(
|
|
127
|
+
metadata: SpecMetadata,
|
|
128
|
+
rule: SpecRule,
|
|
129
|
+
verifier: SpecVerifier,
|
|
130
|
+
scope: VerificationScope,
|
|
131
|
+
confirmation: Optional[Mapping[str, object]],
|
|
132
|
+
stage: str,
|
|
133
|
+
) -> Optional[str]:
|
|
134
|
+
"""command/test 仅在声明 files 作用域时缓存;未声明保持全量执行。"""
|
|
135
|
+
if verifier.type not in ("command", "test") or not verifier.files:
|
|
136
|
+
return None
|
|
137
|
+
files = scoped_files(verifier, scope)
|
|
138
|
+
payload = {
|
|
139
|
+
"spec": metadata.hashes.file_sha256,
|
|
140
|
+
"rule": rule.text_sha256,
|
|
141
|
+
"verifier": {"type": verifier.type, "config": verifier.config, "files": list(verifier.files)},
|
|
142
|
+
"stage": stage,
|
|
143
|
+
"scope_files": files,
|
|
144
|
+
"scope_sha256": _scope_fingerprint(scope.root, files),
|
|
145
|
+
"confirmation": confirmation or {},
|
|
146
|
+
}
|
|
147
|
+
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
|
|
148
|
+
|
|
149
|
+
|
|
75
150
|
def _artifact_fingerprint(verifier: SpecVerifier, scope: VerificationScope) -> Optional[str]:
|
|
76
151
|
if verifier.type != "document":
|
|
77
152
|
return None
|
|
@@ -143,13 +218,32 @@ def _cached_or_run(
|
|
|
143
218
|
verifier: SpecVerifier,
|
|
144
219
|
scope: VerificationScope,
|
|
145
220
|
confirmation: Optional[Mapping[str, object]],
|
|
221
|
+
stage: str = "code",
|
|
146
222
|
) -> VerificationEvidence:
|
|
147
223
|
cacheable = verifier.type in _PURE_VERIFIER_TYPES
|
|
148
|
-
|
|
224
|
+
scoped_key: Optional[str] = None
|
|
225
|
+
if cacheable:
|
|
226
|
+
key = _cache_key(metadata, rule, verifier, scope, confirmation, stage)
|
|
227
|
+
else:
|
|
228
|
+
scoped_key = _scoped_cache_key(metadata, rule, verifier, scope, confirmation, stage)
|
|
229
|
+
if scoped_key is None:
|
|
230
|
+
key = ""
|
|
231
|
+
else:
|
|
232
|
+
cacheable, key = True, scoped_key
|
|
149
233
|
if cacheable:
|
|
150
234
|
cached = _cached_evidence(key)
|
|
151
235
|
if cached is not None:
|
|
152
|
-
|
|
236
|
+
if scoped_key is None:
|
|
237
|
+
return cached
|
|
238
|
+
details = dict(cached.details or {})
|
|
239
|
+
details["cache_reused"] = True
|
|
240
|
+
details["scope_files"] = list(scoped_files(verifier, scope))
|
|
241
|
+
return replace(
|
|
242
|
+
cached,
|
|
243
|
+
executed_at=datetime.now(timezone.utc).isoformat(),
|
|
244
|
+
diff_sha256=scope.diff_sha256,
|
|
245
|
+
details=details,
|
|
246
|
+
)
|
|
153
247
|
current = _evidence(metadata, rule, verifier, scope, confirmation)
|
|
154
248
|
if cacheable and current.status == "verified":
|
|
155
249
|
_save_result_cache(key, current)
|
|
@@ -400,6 +494,7 @@ def _run_all_verifiers(
|
|
|
400
494
|
confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
|
|
401
495
|
skip_command: bool = False,
|
|
402
496
|
timeout_budget: Optional[float] = None,
|
|
497
|
+
stage: str = "code",
|
|
403
498
|
) -> VerificationResult:
|
|
404
499
|
verifier_by_rule = {item.rule: item for item in metadata.verifiers}
|
|
405
500
|
confirmation_by_rule = confirmations or {}
|
|
@@ -410,12 +505,14 @@ def _run_all_verifiers(
|
|
|
410
505
|
for rule in metadata.rules:
|
|
411
506
|
if rule.enforcement != "required":
|
|
412
507
|
continue
|
|
508
|
+
verifier = verifier_by_rule.get(rule.ref)
|
|
509
|
+
if verifier is not None and verifier.stage != stage:
|
|
510
|
+
continue
|
|
413
511
|
if timeout_budget is not None:
|
|
414
512
|
remaining = timeout_budget - (time.monotonic() - started)
|
|
415
513
|
if remaining <= 0:
|
|
416
514
|
evidence_by_ref[rule.ref] = _budget_evidence(metadata, rule, scope, timeout_budget)
|
|
417
515
|
continue
|
|
418
|
-
verifier = verifier_by_rule.get(rule.ref)
|
|
419
516
|
if verifier is None:
|
|
420
517
|
evidence_by_ref[rule.ref] = _missing_evidence(metadata, rule, scope)
|
|
421
518
|
continue
|
|
@@ -427,11 +524,11 @@ def _run_all_verifiers(
|
|
|
427
524
|
parallel.append((rule, verifier, confirmation))
|
|
428
525
|
continue
|
|
429
526
|
cap = None if timeout_budget is None else max(0.1, timeout_budget - (time.monotonic() - started))
|
|
430
|
-
evidence_by_ref[rule.ref] = _cached_or_run(metadata, rule, verifier, scope, confirmation) if cap is None else _evidence(metadata, rule, verifier, scope, confirmation, cap)
|
|
527
|
+
evidence_by_ref[rule.ref] = _cached_or_run(metadata, rule, verifier, scope, confirmation, stage) if cap is None else _evidence(metadata, rule, verifier, scope, confirmation, cap)
|
|
431
528
|
if parallel:
|
|
432
529
|
with ThreadPoolExecutor(max_workers=min(8, len(parallel))) as pool:
|
|
433
530
|
futures = {
|
|
434
|
-
rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation)
|
|
531
|
+
rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation, stage)
|
|
435
532
|
for rule, verifier, confirmation in parallel
|
|
436
533
|
}
|
|
437
534
|
evidence_by_ref.update({ref: future.result() for ref, future in futures.items()})
|
|
@@ -442,9 +539,10 @@ def _run_all_verifiers(
|
|
|
442
539
|
|
|
443
540
|
def run_all_verifiers(metadata: SpecMetadata, scope: VerificationScope,
|
|
444
541
|
confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
|
|
445
|
-
skip_command: bool = False, timeout_budget: Optional[float] = None
|
|
542
|
+
skip_command: bool = False, timeout_budget: Optional[float] = None,
|
|
543
|
+
stage: str = "code") -> VerificationResult:
|
|
446
544
|
with execution_session():
|
|
447
|
-
return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget)
|
|
545
|
+
return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget, stage)
|
|
448
546
|
|
|
449
547
|
|
|
450
548
|
def evidence_is_fresh(
|
|
@@ -96,6 +96,34 @@ def session_edited_files(project_root: str, sid: str) -> list:
|
|
|
96
96
|
return files
|
|
97
97
|
|
|
98
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
|
+
|
|
99
127
|
def _is_active_task_file(rel_path: str) -> bool:
|
|
100
128
|
path = normalize_path(rel_path)
|
|
101
129
|
if not path.startswith(".code-flow/tasks/") or "/archived/" in path:
|
|
@@ -294,7 +322,7 @@ def _main() -> None:
|
|
|
294
322
|
active = load_active_task(project_root)
|
|
295
323
|
task_dir = os.path.join(project_root, active.task_dir)
|
|
296
324
|
gate_remaining = deadline - time.monotonic()
|
|
297
|
-
done = run_done_gate(project_root, task_dir, budget=min(GATE_BUDGET_SECONDS, max(gate_remaining, 0.01)))
|
|
325
|
+
done = run_done_gate(project_root, task_dir, cheap=True, budget=min(GATE_BUDGET_SECONDS, max(gate_remaining, 0.01)))
|
|
298
326
|
except (OSError, ValueError) as exc:
|
|
299
327
|
if enforcement == "required":
|
|
300
328
|
payload = {"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: active task is invalid: {exc}"}
|
|
@@ -316,11 +344,11 @@ def _main() -> None:
|
|
|
316
344
|
if not resolve_quality_loop(config)["stop_check"]:
|
|
317
345
|
return
|
|
318
346
|
if not has_active:
|
|
319
|
-
files = session_edited_files(project_root, sid)
|
|
347
|
+
files = _root_scoped_files(project_root, session_edited_files(project_root, sid))
|
|
320
348
|
if not files:
|
|
321
349
|
return
|
|
322
350
|
acceptance_failures = task_acceptance_failures(project_root, files)
|
|
323
|
-
validators =
|
|
351
|
+
validators = _stop_validators(project_root)
|
|
324
352
|
failures, truncated = run_validators(
|
|
325
353
|
project_root, validators, files, sid, deadline=deadline
|
|
326
354
|
) if validators else ([], False)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Programmatic task-file index: parse TASK statuses/depends, compute DAG batches.
|
|
3
3
|
|
|
4
|
-
Single worktrees allow one active TASK at a time
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
@@ -116,7 +117,7 @@ def index_data(task_file: str) -> dict[str, object]:
|
|
|
116
117
|
"batches": task_batches(nodes),
|
|
117
118
|
"independent_groups": independent_groups(nodes),
|
|
118
119
|
"dependency_components": dependency_components(nodes),
|
|
119
|
-
"note": "batches 组内无依赖;dependency_components 仅组间独立(independent_groups 为兼容别名);单 worktree 一次仅激活一个 TASK",
|
|
120
|
+
"note": "batches 组内无依赖;dependency_components 仅组间独立(independent_groups 为兼容别名);单 worktree 一次仅激活一个 TASK,批次内独立任务可由 cf-task:start 在各 worktree 并行派发",
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
|