@michaelxwb/code-flow 0.6.1 → 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-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-task-start/SKILL.md +6 -7
- 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-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/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 +20 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +2 -0
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +5 -4
- package/src/core/code-flow/scripts/cf_spec_context.py +131 -16
- package/src/core/code-flow/scripts/cf_spec_gate.py +10 -0
- 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 +102 -9
- package/src/core/code-flow/scripts/cf_spec_verify.py +118 -8
- package/src/core/code-flow/scripts/cf_stop_hook.py +2 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +48 -2
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +2 -0
|
@@ -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)
|
|
@@ -212,13 +214,21 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
212
214
|
|
|
213
215
|
|
|
214
216
|
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
217
|
+
started = time.monotonic()
|
|
215
218
|
try:
|
|
216
219
|
args = _parser().parse_args(argv)
|
|
220
|
+
phase_started = time.monotonic()
|
|
217
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()
|
|
218
224
|
result = validate_stage(context, args.stage, task_id=args.task)
|
|
225
|
+
phase_timing("gate.validate_stage", phase_started)
|
|
219
226
|
if args.stage == "plan" and args.artifact:
|
|
227
|
+
phase_started = time.monotonic()
|
|
220
228
|
result = _merge_results(result, validate_plan_coverage(context, args.artifact))
|
|
229
|
+
phase_timing("gate.validate_plan", phase_started)
|
|
221
230
|
stdout.write(json.dumps(result_to_data(result), ensure_ascii=False))
|
|
231
|
+
phase_timing("gate.total", started)
|
|
222
232
|
return 0 if result.decision == "pass" else 3
|
|
223
233
|
except Exception as exc:
|
|
224
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,14 +8,25 @@ 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
|
|
@@ -44,6 +55,77 @@ def _task_file(task_dir: Path, task_id: str) -> Path:
|
|
|
44
55
|
return matches[0]
|
|
45
56
|
|
|
46
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
|
+
|
|
47
129
|
def _active_route(root: str) -> RouteResult:
|
|
48
130
|
try:
|
|
49
131
|
active = load_active_task(root)
|
|
@@ -57,14 +139,23 @@ def _active_route(root: str) -> RouteResult:
|
|
|
57
139
|
"active_context_drift",
|
|
58
140
|
"active marker Context hash does not match; run `cf-spec doctor` (resync) to re-sync the marker before continuing",
|
|
59
141
|
)
|
|
60
|
-
|
|
61
|
-
if
|
|
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:
|
|
62
153
|
raise RouterError("task_projection_truncated", "split the TASK before coding")
|
|
63
154
|
refs = tuple(binding.spec_id for binding in context.bindings)
|
|
64
|
-
return RouteResult("task",
|
|
155
|
+
return RouteResult("task", text, refs, current_hash)
|
|
65
156
|
|
|
66
157
|
|
|
67
|
-
def _read_candidates(root: str, paths: Sequence[str]) -> RouteResult:
|
|
158
|
+
def _read_candidates(root: str, paths: Sequence[str], compress: bool = True) -> RouteResult:
|
|
68
159
|
try:
|
|
69
160
|
candidates = resolve_candidates(root, "code", tuple(paths))
|
|
70
161
|
except ValueError as exc:
|
|
@@ -78,7 +169,8 @@ def _read_candidates(root: str, paths: Sequence[str]) -> RouteResult:
|
|
|
78
169
|
raise RouterError("spec_read_failed", f"{candidate.path}: {exc}") from exc
|
|
79
170
|
unused_frontmatter, content = parse_spec_frontmatter(raw)
|
|
80
171
|
del unused_frontmatter
|
|
81
|
-
|
|
172
|
+
final_content = compress_content(content.strip()) if compress else content.strip()
|
|
173
|
+
specs.append({"path": candidate.path, "content": final_content, "tier": 1})
|
|
82
174
|
text = assemble_context(specs, "## Active Specs (path-mapped)") if specs else ""
|
|
83
175
|
return RouteResult("path", text, tuple(item.path for item in candidates))
|
|
84
176
|
|
|
@@ -145,5 +237,6 @@ def route_prompt(root: str, paths: Sequence[str], session_id: str) -> RouteResul
|
|
|
145
237
|
if not config:
|
|
146
238
|
return RouteResult("none", "", ())
|
|
147
239
|
if paths:
|
|
148
|
-
|
|
240
|
+
quality = config.get("quality_loop")
|
|
241
|
+
return _read_candidates(root, paths, resolve_compress(quality if isinstance(quality, dict) else {}))
|
|
149
242
|
return _catalog_route(root, session_id, config)
|
|
@@ -11,13 +11,23 @@ import hashlib
|
|
|
11
11
|
import json
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
import subprocess
|
|
14
|
+
from threading import Lock
|
|
14
15
|
import time
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
17
|
from typing import Mapping, Optional, Sequence
|
|
16
18
|
|
|
17
19
|
from cf_checks import run_regex_verifier
|
|
18
20
|
from cf_spec_metadata import SpecMetadata, SpecRule, SpecVerifier
|
|
19
21
|
|
|
20
22
|
|
|
23
|
+
_RESULT_CACHE: dict[str, dict[str, object]] = {}
|
|
24
|
+
_RESULT_CACHE_ROOT: Optional[str] = None
|
|
25
|
+
_RESULT_CACHE_DIRTY = False
|
|
26
|
+
_RESULT_CACHE_LOCK = Lock()
|
|
27
|
+
_MAX_RESULT_CACHE_ENTRIES = 2048
|
|
28
|
+
_PURE_VERIFIER_TYPES = frozenset(("document", "regex", "ast"))
|
|
29
|
+
|
|
30
|
+
|
|
21
31
|
@dataclass(frozen=True)
|
|
22
32
|
class VerificationScope:
|
|
23
33
|
root: str
|
|
@@ -44,6 +54,94 @@ class VerificationResult:
|
|
|
44
54
|
passed: bool
|
|
45
55
|
|
|
46
56
|
|
|
57
|
+
def _cache_file(root: str) -> Path:
|
|
58
|
+
return Path(root) / ".code-flow" / ".verifier-cache.json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]]) -> str:
|
|
62
|
+
payload = {
|
|
63
|
+
"spec": metadata.hashes.file_sha256,
|
|
64
|
+
"rule": rule.text_sha256,
|
|
65
|
+
"verifier": {"type": verifier.type, "config": verifier.config},
|
|
66
|
+
"files": scope.files,
|
|
67
|
+
"diff": scope.diff_sha256,
|
|
68
|
+
"confirmation": confirmation or {},
|
|
69
|
+
}
|
|
70
|
+
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_result_cache(root: str) -> None:
|
|
74
|
+
global _RESULT_CACHE_ROOT, _RESULT_CACHE, _RESULT_CACHE_DIRTY
|
|
75
|
+
if _RESULT_CACHE_ROOT == root:
|
|
76
|
+
return
|
|
77
|
+
_RESULT_CACHE_ROOT = root
|
|
78
|
+
_RESULT_CACHE = {}
|
|
79
|
+
_RESULT_CACHE_DIRTY = False
|
|
80
|
+
path = _cache_file(root)
|
|
81
|
+
try:
|
|
82
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
83
|
+
if isinstance(loaded, dict):
|
|
84
|
+
_RESULT_CACHE.update({str(key): value for key, value in loaded.items() if isinstance(value, dict)})
|
|
85
|
+
except (OSError, ValueError):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cached_evidence(key: str) -> Optional[VerificationEvidence]:
|
|
90
|
+
item = _RESULT_CACHE.get(key)
|
|
91
|
+
if not isinstance(item, dict) or item.get("status") != "verified":
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
return VerificationEvidence(**item)
|
|
95
|
+
except (TypeError, ValueError):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _save_result_cache(key: str, evidence: VerificationEvidence) -> None:
|
|
100
|
+
global _RESULT_CACHE_DIRTY
|
|
101
|
+
with _RESULT_CACHE_LOCK:
|
|
102
|
+
_RESULT_CACHE[key] = evidence.__dict__
|
|
103
|
+
_RESULT_CACHE_DIRTY = True
|
|
104
|
+
while len(_RESULT_CACHE) > _MAX_RESULT_CACHE_ENTRIES:
|
|
105
|
+
_RESULT_CACHE.pop(next(iter(_RESULT_CACHE)))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _flush_result_cache(root: str) -> None:
|
|
109
|
+
global _RESULT_CACHE_DIRTY
|
|
110
|
+
with _RESULT_CACHE_LOCK:
|
|
111
|
+
if not _RESULT_CACHE_DIRTY:
|
|
112
|
+
return
|
|
113
|
+
path = _cache_file(root)
|
|
114
|
+
try:
|
|
115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
temporary = path.with_suffix(".json.tmp")
|
|
117
|
+
temporary.write_text(
|
|
118
|
+
json.dumps(_RESULT_CACHE, ensure_ascii=False, sort_keys=True, default=str), encoding="utf-8"
|
|
119
|
+
)
|
|
120
|
+
temporary.replace(path)
|
|
121
|
+
_RESULT_CACHE_DIRTY = False
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cached_or_run(
|
|
127
|
+
metadata: SpecMetadata,
|
|
128
|
+
rule: SpecRule,
|
|
129
|
+
verifier: SpecVerifier,
|
|
130
|
+
scope: VerificationScope,
|
|
131
|
+
confirmation: Optional[Mapping[str, object]],
|
|
132
|
+
) -> VerificationEvidence:
|
|
133
|
+
cacheable = verifier.type in _PURE_VERIFIER_TYPES
|
|
134
|
+
key = _cache_key(metadata, rule, verifier, scope, confirmation) if cacheable else ""
|
|
135
|
+
if cacheable:
|
|
136
|
+
cached = _cached_evidence(key)
|
|
137
|
+
if cached is not None:
|
|
138
|
+
return cached
|
|
139
|
+
current = _evidence(metadata, rule, verifier, scope, confirmation)
|
|
140
|
+
if cacheable and current.status == "verified":
|
|
141
|
+
_save_result_cache(key, current)
|
|
142
|
+
return current
|
|
143
|
+
|
|
144
|
+
|
|
47
145
|
@dataclass(frozen=True)
|
|
48
146
|
class _Outcome:
|
|
49
147
|
passed: bool
|
|
@@ -290,7 +388,9 @@ def run_all_verifiers(
|
|
|
290
388
|
) -> VerificationResult:
|
|
291
389
|
verifier_by_rule = {item.rule: item for item in metadata.verifiers}
|
|
292
390
|
confirmation_by_rule = confirmations or {}
|
|
293
|
-
|
|
391
|
+
_load_result_cache(scope.root)
|
|
392
|
+
evidence_by_ref: dict[str, VerificationEvidence] = {}
|
|
393
|
+
parallel: list[tuple[SpecRule, SpecVerifier, Optional[Mapping[str, object]]]] = []
|
|
294
394
|
started = time.monotonic()
|
|
295
395
|
for rule in metadata.rules:
|
|
296
396
|
if rule.enforcement != "required":
|
|
@@ -298,13 +398,12 @@ def run_all_verifiers(
|
|
|
298
398
|
if timeout_budget is not None:
|
|
299
399
|
remaining = timeout_budget - (time.monotonic() - started)
|
|
300
400
|
if remaining <= 0:
|
|
301
|
-
|
|
401
|
+
evidence_by_ref[rule.ref] = _budget_evidence(metadata, rule, scope, timeout_budget)
|
|
302
402
|
continue
|
|
303
403
|
verifier = verifier_by_rule.get(rule.ref)
|
|
304
404
|
if verifier is None:
|
|
305
405
|
details = {"rule": rule.ref}
|
|
306
|
-
|
|
307
|
-
VerificationEvidence(
|
|
406
|
+
evidence_by_ref[rule.ref] = VerificationEvidence(
|
|
308
407
|
f"{metadata.id}#{rule.ref}",
|
|
309
408
|
datetime.now(timezone.utc).isoformat(),
|
|
310
409
|
"unverified",
|
|
@@ -315,14 +414,25 @@ def run_all_verifiers(
|
|
|
315
414
|
"verifier_missing",
|
|
316
415
|
details,
|
|
317
416
|
)
|
|
318
|
-
)
|
|
319
417
|
continue
|
|
320
418
|
if skip_command and verifier.type in ("command", "test"):
|
|
321
|
-
|
|
419
|
+
evidence_by_ref[rule.ref] = _skipped_evidence(metadata, rule, scope)
|
|
420
|
+
continue
|
|
421
|
+
confirmation = confirmation_by_rule.get(rule.ref)
|
|
422
|
+
if timeout_budget is None and verifier.type in ("document", "regex", "ast"):
|
|
423
|
+
parallel.append((rule, verifier, confirmation))
|
|
322
424
|
continue
|
|
323
425
|
cap = None if timeout_budget is None else max(0.1, timeout_budget - (time.monotonic() - started))
|
|
324
|
-
|
|
325
|
-
|
|
426
|
+
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)
|
|
427
|
+
if parallel:
|
|
428
|
+
with ThreadPoolExecutor(max_workers=min(8, len(parallel))) as pool:
|
|
429
|
+
futures = {
|
|
430
|
+
rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation)
|
|
431
|
+
for rule, verifier, confirmation in parallel
|
|
432
|
+
}
|
|
433
|
+
evidence_by_ref.update({ref: future.result() for ref, future in futures.items()})
|
|
434
|
+
result = tuple(evidence_by_ref[rule.ref] for rule in metadata.rules if rule.ref in evidence_by_ref)
|
|
435
|
+
_flush_result_cache(scope.root)
|
|
326
436
|
return VerificationResult(result, bool(result) and all(item.status == "verified" for item in result))
|
|
327
437
|
|
|
328
438
|
|
|
@@ -28,6 +28,7 @@ from cf_core import (
|
|
|
28
28
|
resolve_enforcement,
|
|
29
29
|
resolve_quality_loop,
|
|
30
30
|
resolve_session_id,
|
|
31
|
+
timing_log,
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
TOTAL_BUDGET_SECONDS = 30.0
|
|
@@ -310,3 +311,4 @@ def main() -> None:
|
|
|
310
311
|
|
|
311
312
|
if __name__ == "__main__":
|
|
312
313
|
main()
|
|
314
|
+
timing_log("cf_stop_hook")
|