@michaelxwb/code-flow 0.6.0 → 0.6.2
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/package.json +1 -1
- package/src/adapters/claude/commands/cf-spec.md +13 -0
- package/src/adapters/claude/commands/cf-sync.md +25 -0
- package/src/adapters/claude/commands/cf-task/plan.md +40 -0
- package/src/adapters/claude/commands/cf-task/start.md +6 -7
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +111 -0
- package/src/adapters/codex/skills/cf-spec/SKILL.md +1 -0
- package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +6 -7
- package/src/adapters/costrict/commands/cf-spec.md +13 -0
- package/src/adapters/costrict/commands/cf-sync.md +25 -0
- package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
- package/src/adapters/costrict/commands/cf-task/start.md +6 -7
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +111 -0
- package/src/adapters/opencode/commands/cf-spec.md +1 -0
- package/src/adapters/opencode/commands/cf-sync.md +29 -0
- package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
- package/src/adapters/opencode/commands/cf-task/start.md +6 -7
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +111 -0
- package/src/adapters/opencode/plugins/code-flow/index.js +3 -3
- package/src/core/code-flow/config.yml +2 -1
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +199 -0
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +107 -0
- package/src/core/code-flow/scripts/cf_core.py +44 -1
- package/src/core/code-flow/scripts/cf_post_hook.py +5 -30
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +41 -17
- package/src/core/code-flow/scripts/cf_session_state.py +37 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +381 -29
- package/src/core/code-flow/scripts/cf_spec_gate.py +22 -6
- package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
- package/src/core/code-flow/scripts/cf_spec_resolver.py +78 -7
- package/src/core/code-flow/scripts/cf_spec_router.py +107 -10
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -9
- package/src/core/code-flow/scripts/cf_spec_verify.py +166 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +35 -12
- package/src/core/code-flow/scripts/cf_sync.py +140 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +95 -7
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +106 -8
|
@@ -10,9 +10,11 @@ import json
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
import re
|
|
12
12
|
import sys
|
|
13
|
+
import time
|
|
13
14
|
from typing import IO, Mapping, Optional, Sequence
|
|
14
15
|
|
|
15
16
|
from cf_spec_context import Decision, RuleBinding, RuleStageStatus, SpecBinding, SpecContext, load_context
|
|
17
|
+
from cf_core import phase_timing
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
@dataclass(frozen=True)
|
|
@@ -55,7 +57,7 @@ def _waiver_issue(decision: Optional[Decision], now: datetime) -> Optional[str]:
|
|
|
55
57
|
return "waiver_expired" if expires <= now else None
|
|
56
58
|
|
|
57
59
|
|
|
58
|
-
def _evidence_issue(rule: RuleBinding, status: RuleStageStatus) -> Optional[str]:
|
|
60
|
+
def _evidence_issue(rule: RuleBinding, status: RuleStageStatus, diff_sha256: Optional[str]) -> Optional[str]:
|
|
59
61
|
if not status.evidence:
|
|
60
62
|
return "evidence_missing"
|
|
61
63
|
for evidence in status.evidence:
|
|
@@ -63,17 +65,20 @@ def _evidence_issue(rule: RuleBinding, status: RuleStageStatus) -> Optional[str]
|
|
|
63
65
|
continue
|
|
64
66
|
if evidence.get("rule_text_sha256") != rule.text_sha256:
|
|
65
67
|
continue
|
|
68
|
+
evidence_diff = evidence.get("diff_sha256")
|
|
69
|
+
if evidence_diff is not None and diff_sha256 is not None and evidence_diff != diff_sha256:
|
|
70
|
+
continue
|
|
66
71
|
result_hash = evidence.get("result_sha256")
|
|
67
72
|
if isinstance(result_hash, str) and result_hash:
|
|
68
73
|
return None
|
|
69
74
|
return "stale_evidence"
|
|
70
75
|
|
|
71
76
|
|
|
72
|
-
def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime) -> Optional[str]:
|
|
77
|
+
def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime, diff_sha256: Optional[str]) -> Optional[str]:
|
|
73
78
|
if status.status == "applied":
|
|
74
79
|
return None
|
|
75
80
|
if status.status == "verified":
|
|
76
|
-
return _evidence_issue(rule, status)
|
|
81
|
+
return _evidence_issue(rule, status, diff_sha256)
|
|
77
82
|
if status.status == "not_applicable":
|
|
78
83
|
return None if _decision_valid(status.decision) else "decision_invalid"
|
|
79
84
|
if status.status == "waived":
|
|
@@ -81,7 +86,9 @@ def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime) ->
|
|
|
81
86
|
return status.status
|
|
82
87
|
|
|
83
88
|
|
|
84
|
-
def _binding_issues(
|
|
89
|
+
def _binding_issues(
|
|
90
|
+
binding: SpecBinding, stage: str, now: datetime, diff_sha256: Optional[str]
|
|
91
|
+
) -> tuple[list[GateIssue], list[GateIssue]]:
|
|
85
92
|
errors: list[GateIssue] = []
|
|
86
93
|
warnings: list[GateIssue] = []
|
|
87
94
|
for rule in binding.rules:
|
|
@@ -90,7 +97,7 @@ def _binding_issues(binding: SpecBinding, stage: str, now: datetime) -> tuple[li
|
|
|
90
97
|
continue
|
|
91
98
|
if rule.enforcement == "advisory" and status.status == "pending":
|
|
92
99
|
continue
|
|
93
|
-
code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now)
|
|
100
|
+
code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256)
|
|
94
101
|
if code is None:
|
|
95
102
|
continue
|
|
96
103
|
issue = GateIssue(code, binding.spec_id, rule.ref, stage, f"{binding.spec_id}#{rule.ref}: {code}")
|
|
@@ -105,13 +112,14 @@ def validate_stage(
|
|
|
105
112
|
artifact: str = "",
|
|
106
113
|
task_id: str = "",
|
|
107
114
|
now: Optional[datetime] = None,
|
|
115
|
+
diff_sha256: Optional[str] = None,
|
|
108
116
|
) -> GateResult:
|
|
109
117
|
del artifact, task_id
|
|
110
118
|
current = now or datetime.now(timezone.utc)
|
|
111
119
|
errors: list[GateIssue] = []
|
|
112
120
|
warnings: list[GateIssue] = []
|
|
113
121
|
for binding in context.bindings:
|
|
114
|
-
binding_errors, binding_warnings = _binding_issues(binding, stage, current)
|
|
122
|
+
binding_errors, binding_warnings = _binding_issues(binding, stage, current, diff_sha256)
|
|
115
123
|
errors.extend(binding_errors)
|
|
116
124
|
warnings.extend(binding_warnings)
|
|
117
125
|
refs = tuple(sorted({f"{item.spec_id}#{item.rule_ref}" for item in (*errors, *warnings)}))
|
|
@@ -206,13 +214,21 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
206
214
|
|
|
207
215
|
|
|
208
216
|
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
217
|
+
started = time.monotonic()
|
|
209
218
|
try:
|
|
210
219
|
args = _parser().parse_args(argv)
|
|
220
|
+
phase_started = time.monotonic()
|
|
211
221
|
context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
|
|
222
|
+
phase_timing("gate.load_context", phase_started)
|
|
223
|
+
phase_started = time.monotonic()
|
|
212
224
|
result = validate_stage(context, args.stage, task_id=args.task)
|
|
225
|
+
phase_timing("gate.validate_stage", phase_started)
|
|
213
226
|
if args.stage == "plan" and args.artifact:
|
|
227
|
+
phase_started = time.monotonic()
|
|
214
228
|
result = _merge_results(result, validate_plan_coverage(context, args.artifact))
|
|
229
|
+
phase_timing("gate.validate_plan", phase_started)
|
|
215
230
|
stdout.write(json.dumps(result_to_data(result), ensure_ascii=False))
|
|
231
|
+
phase_timing("gate.total", started)
|
|
216
232
|
return 0 if result.decision == "pass" else 3
|
|
217
233
|
except Exception as exc:
|
|
218
234
|
sys.stderr.write(f"cf_spec_gate error: {exc}\n")
|
|
@@ -10,7 +10,6 @@ from pathlib import Path
|
|
|
10
10
|
import re
|
|
11
11
|
from typing import Mapping, Optional
|
|
12
12
|
|
|
13
|
-
import yaml
|
|
14
13
|
|
|
15
14
|
|
|
16
15
|
ALLOWED_STAGES = frozenset(("prd", "design", "plan", "code", "review"))
|
|
@@ -20,6 +19,8 @@ _ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
|
20
19
|
_RULE_ID_RE = re.compile(r"^RULE-[a-z0-9]+(?:-[a-z0-9]+)*-\d{3}$")
|
|
21
20
|
_EXPLICIT_RULE_RE = re.compile(r"^\[(RULE-[a-z0-9-]+-\d{3})\]\s+(.+)$")
|
|
22
21
|
_FRONTMATTER_RE = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|$)", re.DOTALL)
|
|
22
|
+
_FRONTMATTER_OPEN_RE = re.compile(br"---[ \t]*\r?\n")
|
|
23
|
+
_FRONTMATTER_CLOSE_RE = re.compile(br"---[ \t]*(?:\r?\n)?")
|
|
23
24
|
_SECTION_RE = re.compile(r"^##\s+(Rules|Anti-Patterns|Patterns|Examples)\s*$")
|
|
24
25
|
_H2_RE = re.compile(r"^##\s+")
|
|
25
26
|
_BULLET_RE = re.compile(r"^\s*-\s+(.+\S|\S)\s*$")
|
|
@@ -63,6 +64,13 @@ class SpecRule:
|
|
|
63
64
|
line: int
|
|
64
65
|
|
|
65
66
|
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class SpecHeader:
|
|
69
|
+
id: str
|
|
70
|
+
stages: tuple[str, ...]
|
|
71
|
+
enforcement: str
|
|
72
|
+
|
|
73
|
+
|
|
66
74
|
@dataclass(frozen=True)
|
|
67
75
|
class SpecMetadata:
|
|
68
76
|
id: str
|
|
@@ -116,6 +124,11 @@ def _split_frontmatter(content: str, path: str) -> tuple[Mapping[str, object], s
|
|
|
116
124
|
if not match:
|
|
117
125
|
raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
|
|
118
126
|
raw = match.group(1)
|
|
127
|
+
return _parse_frontmatter(raw, path), raw, content[match.end():]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_frontmatter(raw: str, path: str) -> Mapping[str, object]:
|
|
131
|
+
import yaml
|
|
119
132
|
try:
|
|
120
133
|
loaded = yaml.safe_load(raw) or {}
|
|
121
134
|
except yaml.YAMLError as exc:
|
|
@@ -126,7 +139,7 @@ def _split_frontmatter(content: str, path: str) -> tuple[Mapping[str, object], s
|
|
|
126
139
|
raise SpecMetadataError(path, "frontmatter", 2, "顶层必须是 mapping")
|
|
127
140
|
if not all(isinstance(key, str) for key in loaded):
|
|
128
141
|
raise SpecMetadataError(path, "frontmatter", 2, "所有顶层 key 必须是字符串")
|
|
129
|
-
return loaded
|
|
142
|
+
return loaded
|
|
130
143
|
|
|
131
144
|
|
|
132
145
|
def _required_string(meta: Mapping[str, object], key: str, path: str, raw: str) -> str:
|
|
@@ -149,6 +162,46 @@ def _validate_stages(meta: Mapping[str, object], path: str, raw: str) -> tuple[s
|
|
|
149
162
|
return stages
|
|
150
163
|
|
|
151
164
|
|
|
165
|
+
def _spec_header(meta: Mapping[str, object], raw: str, path: str) -> SpecHeader:
|
|
166
|
+
spec_id = _required_string(meta, "id", path, raw)
|
|
167
|
+
if not _ID_RE.fullmatch(spec_id):
|
|
168
|
+
raise SpecMetadataError(path, "id", _field_line(raw, "id"), "必须是 kebab-case")
|
|
169
|
+
stages = _validate_stages(meta, path, raw)
|
|
170
|
+
enforcement = _required_string(meta, "enforcement", path, raw)
|
|
171
|
+
if enforcement not in ALLOWED_ENFORCEMENT:
|
|
172
|
+
line = _field_line(raw, "enforcement")
|
|
173
|
+
raise SpecMetadataError(path, "enforcement", line, f"必须是 {sorted(ALLOWED_ENFORCEMENT)}")
|
|
174
|
+
return SpecHeader(spec_id, stages, enforcement)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _read_frontmatter(path: str) -> str:
|
|
178
|
+
lines: list[bytes] = []
|
|
179
|
+
try:
|
|
180
|
+
with Path(path).open("rb") as source:
|
|
181
|
+
if not _FRONTMATTER_OPEN_RE.fullmatch(source.readline()):
|
|
182
|
+
raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
|
|
183
|
+
for line in source:
|
|
184
|
+
if _FRONTMATTER_CLOSE_RE.fullmatch(line):
|
|
185
|
+
break
|
|
186
|
+
lines.append(line)
|
|
187
|
+
else:
|
|
188
|
+
raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
|
|
189
|
+
except OSError as exc:
|
|
190
|
+
raise SpecMetadataError(path, "file", 1, f"读取失败: {exc}") from exc
|
|
191
|
+
raw = b"".join(lines)
|
|
192
|
+
try:
|
|
193
|
+
return raw.decode("utf-8")
|
|
194
|
+
except UnicodeDecodeError as exc:
|
|
195
|
+
line = raw[:exc.start].count(b"\n") + 2
|
|
196
|
+
raise SpecMetadataError(path, "encoding", line, f"必须是 UTF-8: {exc}") from exc
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def load_spec_header(path: str) -> SpecHeader:
|
|
200
|
+
"""Load only routing fields, stopping at the frontmatter delimiter."""
|
|
201
|
+
raw = _read_frontmatter(path)
|
|
202
|
+
return _spec_header(_parse_frontmatter(raw, path), raw, path)
|
|
203
|
+
|
|
204
|
+
|
|
152
205
|
def _validate_checks(meta: Mapping[str, object], path: str, raw: str) -> tuple[Mapping[str, object], ...]:
|
|
153
206
|
value = meta.get("checks", [])
|
|
154
207
|
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
|
@@ -265,15 +318,8 @@ def _validate_links(
|
|
|
265
318
|
|
|
266
319
|
def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optional[bytes] = None) -> SpecMetadata:
|
|
267
320
|
meta, raw_frontmatter, body = _split_frontmatter(content, path)
|
|
268
|
-
|
|
269
|
-
if not _ID_RE.fullmatch(spec_id):
|
|
270
|
-
raise SpecMetadataError(path, "id", _field_line(raw_frontmatter, "id"), "必须是 kebab-case")
|
|
321
|
+
header = _spec_header(meta, raw_frontmatter, path)
|
|
271
322
|
description = _required_string(meta, "description", path, raw_frontmatter)
|
|
272
|
-
stages = _validate_stages(meta, path, raw_frontmatter)
|
|
273
|
-
enforcement = _required_string(meta, "enforcement", path, raw_frontmatter)
|
|
274
|
-
if enforcement not in ALLOWED_ENFORCEMENT:
|
|
275
|
-
line = _field_line(raw_frontmatter, "enforcement")
|
|
276
|
-
raise SpecMetadataError(path, "enforcement", line, f"必须是 {sorted(ALLOWED_ENFORCEMENT)}")
|
|
277
323
|
owner = meta.get("owner")
|
|
278
324
|
if owner is not None and (not isinstance(owner, str) or not owner.strip()):
|
|
279
325
|
raise SpecMetadataError(path, "owner", _field_line(raw_frontmatter, "owner"), "必须是非空字符串")
|
|
@@ -289,7 +335,10 @@ def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optiona
|
|
|
289
335
|
metadata_sha256=_sha256(_canonical_json(meta, path, "frontmatter", 1)),
|
|
290
336
|
rules_sha256=_sha256(_canonical_json(required_rules, path, "rules", 1)),
|
|
291
337
|
)
|
|
292
|
-
return SpecMetadata(
|
|
338
|
+
return SpecMetadata(
|
|
339
|
+
header.id, description, header.stages, header.enforcement,
|
|
340
|
+
owner, checks, verifiers, rules, hashes, path,
|
|
341
|
+
)
|
|
293
342
|
|
|
294
343
|
|
|
295
344
|
def load_spec_metadata(path: str) -> SpecMetadata:
|
|
@@ -8,9 +8,14 @@ import fnmatch
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Mapping, Optional, Sequence
|
|
10
10
|
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
from cf_spec_metadata import (
|
|
12
|
+
ALLOWED_STAGES,
|
|
13
|
+
SpecHeader,
|
|
14
|
+
SpecMetadata,
|
|
15
|
+
SpecRule,
|
|
16
|
+
load_spec_header,
|
|
17
|
+
load_spec_metadata,
|
|
18
|
+
)
|
|
14
19
|
|
|
15
20
|
|
|
16
21
|
SCOPE_PRIORITY = {"global": 1, "path": 2, "task": 3}
|
|
@@ -44,6 +49,16 @@ class SpecCandidate:
|
|
|
44
49
|
return sum(rule.enforcement == "required" for rule in self.metadata.rules)
|
|
45
50
|
|
|
46
51
|
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class SpecCandidateHeader:
|
|
54
|
+
spec_id: str
|
|
55
|
+
path: str
|
|
56
|
+
scope: str
|
|
57
|
+
priority: int
|
|
58
|
+
matched_paths: tuple[str, ...]
|
|
59
|
+
enforcement: str
|
|
60
|
+
|
|
61
|
+
|
|
47
62
|
@dataclass(frozen=True)
|
|
48
63
|
class ResolvedRule:
|
|
49
64
|
ref: str
|
|
@@ -93,6 +108,7 @@ def _fingerprint(path: Path, field: str) -> tuple[int, int]:
|
|
|
93
108
|
|
|
94
109
|
|
|
95
110
|
def _load_config(root: str) -> Mapping[str, object]:
|
|
111
|
+
import yaml
|
|
96
112
|
path = Path(root) / ".code-flow" / "config.yml"
|
|
97
113
|
if not path.is_file():
|
|
98
114
|
raise SpecResolutionError(str(path), "config", "缺失 config.yml")
|
|
@@ -180,6 +196,36 @@ def _candidate_for(
|
|
|
180
196
|
return SpecCandidate(metadata.id, relative, scope, SCOPE_PRIORITY[scope], matched, metadata)
|
|
181
197
|
|
|
182
198
|
|
|
199
|
+
def _candidate_header_for(
|
|
200
|
+
path: Path,
|
|
201
|
+
spec_root: Path,
|
|
202
|
+
mapping: Mapping[str, object],
|
|
203
|
+
config_path: str,
|
|
204
|
+
stage: str,
|
|
205
|
+
paths: Sequence[str],
|
|
206
|
+
) -> Optional[SpecCandidateHeader]:
|
|
207
|
+
relative = path.relative_to(spec_root).as_posix()
|
|
208
|
+
header: SpecHeader = load_spec_header(str(path))
|
|
209
|
+
if stage not in header.stages:
|
|
210
|
+
return None
|
|
211
|
+
domain = relative.split("/", 1)[0]
|
|
212
|
+
config = _domain_config(mapping, domain, config_path)
|
|
213
|
+
patterns = _patterns(config, f"path_mapping.{domain}.patterns", config_path)
|
|
214
|
+
scope, matched = _candidate_scope(relative, patterns, paths)
|
|
215
|
+
return SpecCandidateHeader(
|
|
216
|
+
header.id, relative, scope, SCOPE_PRIORITY[scope], matched, header.enforcement
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _spec_paths(spec_root: Path, excluded: set[str]) -> tuple[Path, ...]:
|
|
221
|
+
result: list[Path] = []
|
|
222
|
+
for path in sorted(spec_root.rglob("*.md")):
|
|
223
|
+
relative = path.relative_to(spec_root).as_posix()
|
|
224
|
+
if path.name != "_map.md" and not relative.startswith("_session/") and relative not in excluded:
|
|
225
|
+
result.append(path)
|
|
226
|
+
return tuple(result)
|
|
227
|
+
|
|
228
|
+
|
|
183
229
|
def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[SpecCandidate, ...]:
|
|
184
230
|
if stage not in ALLOWED_STAGES:
|
|
185
231
|
raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
|
|
@@ -193,10 +239,7 @@ def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[Spe
|
|
|
193
239
|
raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
|
|
194
240
|
candidates: list[SpecCandidate] = []
|
|
195
241
|
ids: dict[str, str] = {}
|
|
196
|
-
for path in
|
|
197
|
-
relative = path.relative_to(spec_root).as_posix()
|
|
198
|
-
if path.name == "_map.md" or relative.startswith("_session/") or relative in excluded:
|
|
199
|
-
continue
|
|
242
|
+
for path in _spec_paths(spec_root, excluded):
|
|
200
243
|
candidate = _candidate_for(path, spec_root, mapping, config_path, stage, normalized_paths)
|
|
201
244
|
if candidate is None:
|
|
202
245
|
continue
|
|
@@ -208,6 +251,34 @@ def resolve_candidates(root: str, stage: str, paths: Sequence[str]) -> tuple[Spe
|
|
|
208
251
|
return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
|
|
209
252
|
|
|
210
253
|
|
|
254
|
+
def resolve_candidate_headers(
|
|
255
|
+
root: str, stage: str, paths: Sequence[str]
|
|
256
|
+
) -> tuple[SpecCandidateHeader, ...]:
|
|
257
|
+
"""Resolve candidates from routing frontmatter without parsing bodies or hashes."""
|
|
258
|
+
if stage not in ALLOWED_STAGES:
|
|
259
|
+
raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
|
|
260
|
+
normalized_paths = tuple(path.replace("\\", "/") for path in paths)
|
|
261
|
+
config = _load_config(root)
|
|
262
|
+
config_path = str(Path(root) / ".code-flow" / "config.yml")
|
|
263
|
+
mapping = _path_mapping(config, config_path)
|
|
264
|
+
excluded = _non_injectable(mapping, config_path)
|
|
265
|
+
spec_root = Path(root) / ".code-flow" / "specs"
|
|
266
|
+
if not spec_root.is_dir():
|
|
267
|
+
raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
|
|
268
|
+
candidates: list[SpecCandidateHeader] = []
|
|
269
|
+
ids: dict[str, str] = {}
|
|
270
|
+
for path in _spec_paths(spec_root, excluded):
|
|
271
|
+
candidate = _candidate_header_for(path, spec_root, mapping, config_path, stage, normalized_paths)
|
|
272
|
+
if candidate is None:
|
|
273
|
+
continue
|
|
274
|
+
if candidate.spec_id in ids:
|
|
275
|
+
detail = f"重复 id {candidate.spec_id}: {ids[candidate.spec_id]}, {candidate.path}"
|
|
276
|
+
raise SpecResolutionError(str(spec_root), "spec_id", detail)
|
|
277
|
+
ids[candidate.spec_id] = candidate.path
|
|
278
|
+
candidates.append(candidate)
|
|
279
|
+
return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
|
|
280
|
+
|
|
281
|
+
|
|
211
282
|
def _effective_enforcement(candidate: SpecCandidate, rule: SpecRule) -> str:
|
|
212
283
|
if candidate.metadata.enforcement == "advisory" and rule.enforcement == "required":
|
|
213
284
|
return "advisory"
|
|
@@ -8,19 +8,31 @@ import json
|
|
|
8
8
|
import os
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
import tempfile
|
|
11
|
-
from typing import Mapping, Sequence
|
|
12
|
-
|
|
13
|
-
from cf_core import
|
|
11
|
+
from typing import Mapping, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
from cf_core import (
|
|
14
|
+
assemble_context,
|
|
15
|
+
build_effective_mapping,
|
|
16
|
+
build_spec_catalog,
|
|
17
|
+
compress_content,
|
|
18
|
+
load_config,
|
|
19
|
+
parse_spec_frontmatter,
|
|
20
|
+
resolve_compress,
|
|
21
|
+
)
|
|
14
22
|
from cf_spec_context import ContextError, load_active_task, load_context
|
|
15
23
|
from cf_spec_resolver import resolve_candidates
|
|
16
24
|
from cf_spec_session import context_sha256, project_task_session
|
|
17
25
|
|
|
18
26
|
|
|
27
|
+
_PROJECTION_CACHE_PATH = ".code-flow/.task-projection-state.json"
|
|
28
|
+
|
|
29
|
+
|
|
19
30
|
@dataclass(frozen=True)
|
|
20
31
|
class RouteResult:
|
|
21
32
|
mode: str
|
|
22
33
|
text: str
|
|
23
34
|
specs: tuple[str, ...]
|
|
35
|
+
context_sha256: Optional[str] = None
|
|
24
36
|
|
|
25
37
|
|
|
26
38
|
class RouterError(ValueError):
|
|
@@ -43,6 +55,77 @@ def _task_file(task_dir: Path, task_id: str) -> Path:
|
|
|
43
55
|
return matches[0]
|
|
44
56
|
|
|
45
57
|
|
|
58
|
+
def _projection_cache_path(root: str) -> Path:
|
|
59
|
+
return Path(root) / _PROJECTION_CACHE_PATH
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_projection_cache(root: str) -> Mapping[str, object]:
|
|
63
|
+
try:
|
|
64
|
+
value = json.loads(_projection_cache_path(root).read_text(encoding="utf-8"))
|
|
65
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
66
|
+
return {}
|
|
67
|
+
return value if isinstance(value, dict) else {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cached_projection(
|
|
71
|
+
root: str, task_dir: Path, task_id: str, current_hash: str
|
|
72
|
+
) -> Optional[tuple[str, bool]]:
|
|
73
|
+
cache = _load_projection_cache(root)
|
|
74
|
+
if (
|
|
75
|
+
cache.get("version") != 1
|
|
76
|
+
or cache.get("task_id") != task_id
|
|
77
|
+
or cache.get("context_sha256") != current_hash
|
|
78
|
+
or not isinstance(cache.get("task_file"), str)
|
|
79
|
+
or not isinstance(cache.get("text"), str)
|
|
80
|
+
or not isinstance(cache.get("truncated"), bool)
|
|
81
|
+
):
|
|
82
|
+
return None
|
|
83
|
+
task_file = Path(str(cache["task_file"]))
|
|
84
|
+
if task_file.parent != task_dir or not task_file.is_file():
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
directory_mtime = task_dir.stat().st_mtime_ns
|
|
88
|
+
task_mtime = task_file.stat().st_mtime_ns
|
|
89
|
+
except OSError:
|
|
90
|
+
return None
|
|
91
|
+
if cache.get("task_dir_mtime_ns") != directory_mtime or cache.get("task_mtime_ns") != task_mtime:
|
|
92
|
+
return None
|
|
93
|
+
return str(cache["text"]), bool(cache["truncated"])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _save_projection_cache(
|
|
97
|
+
root: str,
|
|
98
|
+
task_dir: Path,
|
|
99
|
+
task_file: Path,
|
|
100
|
+
task_id: str,
|
|
101
|
+
current_hash: str,
|
|
102
|
+
text: str,
|
|
103
|
+
truncated: bool,
|
|
104
|
+
) -> None:
|
|
105
|
+
try:
|
|
106
|
+
payload = {
|
|
107
|
+
"version": 1,
|
|
108
|
+
"task_id": task_id,
|
|
109
|
+
"context_sha256": current_hash,
|
|
110
|
+
"task_file": str(task_file),
|
|
111
|
+
"task_dir_mtime_ns": task_dir.stat().st_mtime_ns,
|
|
112
|
+
"task_mtime_ns": task_file.stat().st_mtime_ns,
|
|
113
|
+
"text": text,
|
|
114
|
+
"truncated": truncated,
|
|
115
|
+
}
|
|
116
|
+
path = _projection_cache_path(root)
|
|
117
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
119
|
+
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
120
|
+
os.replace(temporary, path)
|
|
121
|
+
except (OSError, TypeError, UnicodeError):
|
|
122
|
+
if "temporary" in locals() and temporary.exists():
|
|
123
|
+
try:
|
|
124
|
+
temporary.unlink()
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
46
129
|
def _active_route(root: str) -> RouteResult:
|
|
47
130
|
try:
|
|
48
131
|
active = load_active_task(root)
|
|
@@ -52,15 +135,27 @@ def _active_route(root: str) -> RouteResult:
|
|
|
52
135
|
raise RouterError("invalid_active_task", str(exc)) from exc
|
|
53
136
|
current_hash = context_sha256(context)
|
|
54
137
|
if current_hash != active.context_sha256:
|
|
55
|
-
raise RouterError(
|
|
56
|
-
|
|
57
|
-
|
|
138
|
+
raise RouterError(
|
|
139
|
+
"active_context_drift",
|
|
140
|
+
"active marker Context hash does not match; run `cf-spec doctor` (resync) to re-sync the marker before continuing",
|
|
141
|
+
)
|
|
142
|
+
cached = _cached_projection(root, task_dir, active.task_id, current_hash)
|
|
143
|
+
if cached is None:
|
|
144
|
+
task_file = _task_file(task_dir, active.task_id)
|
|
145
|
+
projection = project_task_session(context, str(task_file), active.task_id)
|
|
146
|
+
_save_projection_cache(
|
|
147
|
+
root, task_dir, task_file, active.task_id, current_hash, projection.text, projection.truncated
|
|
148
|
+
)
|
|
149
|
+
text, truncated = projection.text, projection.truncated
|
|
150
|
+
else:
|
|
151
|
+
text, truncated = cached
|
|
152
|
+
if truncated:
|
|
58
153
|
raise RouterError("task_projection_truncated", "split the TASK before coding")
|
|
59
154
|
refs = tuple(binding.spec_id for binding in context.bindings)
|
|
60
|
-
return RouteResult("task",
|
|
155
|
+
return RouteResult("task", text, refs, current_hash)
|
|
61
156
|
|
|
62
157
|
|
|
63
|
-
def _read_candidates(root: str, paths: Sequence[str]) -> RouteResult:
|
|
158
|
+
def _read_candidates(root: str, paths: Sequence[str], compress: bool = True) -> RouteResult:
|
|
64
159
|
try:
|
|
65
160
|
candidates = resolve_candidates(root, "code", tuple(paths))
|
|
66
161
|
except ValueError as exc:
|
|
@@ -74,7 +169,8 @@ def _read_candidates(root: str, paths: Sequence[str]) -> RouteResult:
|
|
|
74
169
|
raise RouterError("spec_read_failed", f"{candidate.path}: {exc}") from exc
|
|
75
170
|
unused_frontmatter, content = parse_spec_frontmatter(raw)
|
|
76
171
|
del unused_frontmatter
|
|
77
|
-
|
|
172
|
+
final_content = compress_content(content.strip()) if compress else content.strip()
|
|
173
|
+
specs.append({"path": candidate.path, "content": final_content, "tier": 1})
|
|
78
174
|
text = assemble_context(specs, "## Active Specs (path-mapped)") if specs else ""
|
|
79
175
|
return RouteResult("path", text, tuple(item.path for item in candidates))
|
|
80
176
|
|
|
@@ -141,5 +237,6 @@ def route_prompt(root: str, paths: Sequence[str], session_id: str) -> RouteResul
|
|
|
141
237
|
if not config:
|
|
142
238
|
return RouteResult("none", "", ())
|
|
143
239
|
if paths:
|
|
144
|
-
|
|
240
|
+
quality = config.get("quality_loop")
|
|
241
|
+
return _read_candidates(root, paths, resolve_compress(quality if isinstance(quality, dict) else {}))
|
|
145
242
|
return _catalog_route(root, session_id, config)
|
|
@@ -5,16 +5,13 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
7
|
from dataclasses import dataclass
|
|
8
|
-
import hashlib
|
|
9
8
|
import json
|
|
10
9
|
from pathlib import Path
|
|
11
10
|
import re
|
|
12
11
|
import sys
|
|
13
12
|
from typing import IO, Mapping, Optional, Sequence
|
|
14
13
|
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
from cf_spec_context import RuleBinding, SpecContext, context_to_data, load_context
|
|
14
|
+
from cf_spec_context import RuleBinding, SpecContext, context_sha256, load_context
|
|
18
15
|
|
|
19
16
|
|
|
20
17
|
@dataclass(frozen=True)
|
|
@@ -25,9 +22,8 @@ class SessionProjection:
|
|
|
25
22
|
truncated: bool
|
|
26
23
|
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return hashlib.sha256(data).hexdigest()
|
|
25
|
+
# context_sha256 is defined next to the identity projection in cf_spec_context
|
|
26
|
+
# and re-exported here for callers that import the session module.
|
|
31
27
|
|
|
32
28
|
|
|
33
29
|
def _task_section(text: str, task_id: str) -> str:
|
|
@@ -70,7 +66,7 @@ def _rule_line(ref: str, rule: RuleBinding, compact: bool = False) -> str:
|
|
|
70
66
|
|
|
71
67
|
|
|
72
68
|
def project_task_session(
|
|
73
|
-
context: SpecContext, task_file: str, task_id: str, max_chars: int =
|
|
69
|
+
context: SpecContext, task_file: str, task_id: str, max_chars: int = 12000
|
|
74
70
|
) -> SessionProjection:
|
|
75
71
|
section = _task_section(Path(task_file).read_text(encoding="utf-8"), task_id)
|
|
76
72
|
refs = _refs(section)
|
|
@@ -100,7 +96,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
100
96
|
parser.add_argument("--task-file", required=True)
|
|
101
97
|
parser.add_argument("--task", required=True)
|
|
102
98
|
parser.add_argument("--output", required=True)
|
|
103
|
-
parser.add_argument("--budget", type=int, default=
|
|
99
|
+
parser.add_argument("--budget", type=int, default=12000)
|
|
104
100
|
parser.add_argument("--json", action="store_true")
|
|
105
101
|
return parser
|
|
106
102
|
|