@michaelxwb/code-flow 0.8.1 → 1.0.0
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 +73 -152
- package/bin/code-flow.js +12 -0
- package/docs/README.md +19 -0
- package/docs/USAGE.md +156 -1417
- package/docs/v1-design.md +1858 -0
- package/docs/v1-implementation.md +186 -0
- package/lib/adapters.js +126 -0
- package/lib/cli.js +258 -0
- package/lib/config.js +85 -0
- package/lib/doctor.js +283 -0
- package/lib/frontmatter.js +42 -0
- package/lib/fs.js +141 -0
- package/lib/glob.js +23 -0
- package/lib/hook.js +99 -0
- package/lib/init.js +132 -0
- package/lib/learn.js +170 -0
- package/lib/locks.js +52 -0
- package/lib/markdown.js +13 -0
- package/lib/process.js +116 -0
- package/lib/render.js +115 -0
- package/lib/repo.js +48 -0
- package/lib/runs.js +155 -0
- package/lib/session.js +40 -0
- package/lib/spec.js +80 -0
- package/lib/task.js +258 -0
- package/lib/taskdoc.js +308 -0
- package/lib/toml.js +140 -0
- package/lib/verify.js +168 -0
- package/lib/worktree.js +930 -0
- package/package.json +18 -21
- package/templates/adapters/opencode.mjs +65 -0
- package/templates/config.json +39 -0
- package/templates/instructions.md +13 -0
- package/templates/launcher/bridge.cjs +295 -0
- package/templates/launcher/hook.cmd +15 -0
- package/templates/launcher/hook.ps1 +10 -0
- package/templates/launcher/hook.sh +20 -0
- package/templates/skills/cf-check.md +19 -0
- package/templates/skills/cf-init.md +16 -0
- package/templates/skills/cf-spec.md +20 -0
- package/templates/skills/cf-task-align.md +19 -0
- package/templates/skills/cf-task-archive.md +20 -0
- package/templates/skills/cf-task-plan.md +31 -0
- package/templates/skills/cf-task-prd.md +14 -0
- package/templates/skills/cf-task-start.md +41 -0
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +12 -12
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +17 -19
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +12 -12
- package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
- package/templates/workflow/project-guidelines.md +48 -0
- package/templates/workflow/scenarios.md +67 -0
- package/templates/workflow/task.md +56 -0
- package/docs/acceptance-contract.md +0 -217
- package/docs/ci/acceptance-evidence.md +0 -77
- package/docs/migrations/runtime-0.7.0.md +0 -41
- package/docs/migrations/runtime-0.8.0.md +0 -154
- package/docs/migrations/runtime-0.9.0.md +0 -40
- package/docs/migrations/spec-workflow-0.6.0.md +0 -42
- package/docs/spec-injection-hardening.md +0 -54
- package/docs/spec-injection-performance.json +0 -73
- package/docs/spec-optimization.md +0 -12
- package/src/adapters/claude/CLAUDE.md +0 -45
- package/src/adapters/claude/commands/cf-init.md +0 -262
- package/src/adapters/claude/commands/cf-learn.md +0 -291
- package/src/adapters/claude/commands/cf-spec.md +0 -87
- package/src/adapters/claude/commands/cf-stats.md +0 -96
- package/src/adapters/claude/commands/cf-sync.md +0 -39
- package/src/adapters/claude/commands/cf-task/align.md +0 -331
- package/src/adapters/claude/commands/cf-task/archive.md +0 -155
- package/src/adapters/claude/commands/cf-task/block.md +0 -49
- package/src/adapters/claude/commands/cf-task/graph.md +0 -92
- package/src/adapters/claude/commands/cf-task/note.md +0 -248
- package/src/adapters/claude/commands/cf-task/plan.md +0 -477
- package/src/adapters/claude/commands/cf-task/prd.md +0 -173
- package/src/adapters/claude/commands/cf-task/start.md +0 -317
- package/src/adapters/claude/commands/cf-task/status.md +0 -71
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/claude/commands/cf-validate.md +0 -100
- package/src/adapters/claude/settings.local.json +0 -50
- package/src/adapters/codex/AGENTS.md +0 -45
- package/src/adapters/codex/config.toml +0 -3
- package/src/adapters/codex/hooks.json +0 -54
- package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
- package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
- package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -88
- package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -97
- package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -45
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -332
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -156
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -50
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -93
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -245
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -478
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -174
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -318
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -72
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -145
- package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -102
- package/src/adapters/commands.json +0 -115
- package/src/adapters/costrict/CLAUDE.md +0 -45
- package/src/adapters/costrict/commands/cf-init.md +0 -262
- package/src/adapters/costrict/commands/cf-learn.md +0 -291
- package/src/adapters/costrict/commands/cf-spec.md +0 -87
- package/src/adapters/costrict/commands/cf-stats.md +0 -96
- package/src/adapters/costrict/commands/cf-sync.md +0 -39
- package/src/adapters/costrict/commands/cf-task/align.md +0 -331
- package/src/adapters/costrict/commands/cf-task/archive.md +0 -155
- package/src/adapters/costrict/commands/cf-task/block.md +0 -49
- package/src/adapters/costrict/commands/cf-task/graph.md +0 -92
- package/src/adapters/costrict/commands/cf-task/note.md +0 -248
- package/src/adapters/costrict/commands/cf-task/plan.md +0 -477
- package/src/adapters/costrict/commands/cf-task/prd.md +0 -173
- package/src/adapters/costrict/commands/cf-task/start.md +0 -317
- package/src/adapters/costrict/commands/cf-task/status.md +0 -71
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/costrict/commands/cf-validate.md +0 -100
- package/src/adapters/costrict/settings.local.json +0 -50
- package/src/adapters/install-plan.json +0 -100
- package/src/adapters/opencode/AGENTS.md +0 -45
- package/src/adapters/opencode/commands/cf-init.md +0 -281
- package/src/adapters/opencode/commands/cf-learn.md +0 -295
- package/src/adapters/opencode/commands/cf-spec.md +0 -91
- package/src/adapters/opencode/commands/cf-stats.md +0 -100
- package/src/adapters/opencode/commands/cf-sync.md +0 -44
- package/src/adapters/opencode/commands/cf-task/align.md +0 -331
- package/src/adapters/opencode/commands/cf-task/archive.md +0 -160
- package/src/adapters/opencode/commands/cf-task/block.md +0 -54
- package/src/adapters/opencode/commands/cf-task/graph.md +0 -97
- package/src/adapters/opencode/commands/cf-task/note.md +0 -253
- package/src/adapters/opencode/commands/cf-task/plan.md +0 -477
- package/src/adapters/opencode/commands/cf-task/prd.md +0 -178
- package/src/adapters/opencode/commands/cf-task/start.md +0 -317
- package/src/adapters/opencode/commands/cf-task/status.md +0 -76
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/opencode/commands/cf-validate.md +0 -105
- package/src/adapters/opencode/opencode.json +0 -3
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -162
- package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
- package/src/adapters/platform-bindings.json +0 -370
- package/src/cli.js +0 -1076
- package/src/core/code-flow/config.yml +0 -161
- package/src/core/code-flow/runtime-commands.json +0 -259
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -10
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -10
- package/src/core/code-flow/scripts/cf_audit.py +0 -10
- package/src/core/code-flow/scripts/cf_codex_hook.py +0 -10
- package/src/core/code-flow/scripts/cf_design_lint.py +0 -10
- package/src/core/code-flow/scripts/cf_evidence_import.py +0 -10
- package/src/core/code-flow/scripts/cf_feedback.py +0 -10
- package/src/core/code-flow/scripts/cf_opencode_event.py +0 -10
- package/src/core/code-flow/scripts/cf_post_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_scenarios.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_context.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_gate.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_session.py +0 -10
- package/src/core/code-flow/scripts/cf_stats.py +0 -10
- package/src/core/code-flow/scripts/cf_stop_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_sync.py +0 -10
- package/src/core/code-flow/scripts/cf_task_index.py +0 -10
- package/src/core/code-flow/scripts/cf_task_parallel.py +0 -10
- package/src/core/code-flow/scripts/cf_task_workflow.py +0 -10
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_validation.py +0 -10
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +0 -347
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +0 -660
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +0 -202
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +0 -60
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +0 -73
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +0 -282
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +0 -266
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +0 -172
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +0 -326
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_checks.py +0 -333
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +0 -197
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +0 -174
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +0 -300
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +0 -502
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +0 -347
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +0 -1490
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +0 -533
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +0 -172
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +0 -607
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +0 -53
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +0 -58
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +0 -1175
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +0 -60
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +0 -46
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +0 -540
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +0 -144
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +0 -177
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +0 -1142
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +0 -742
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +0 -31
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +0 -67
- package/src/core/code-flow/scripts/codeflow/core/cf_core.py +0 -609
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +0 -325
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +0 -57
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +0 -92
- package/src/core/code-flow/scripts/codeflow/core/cf_log.py +0 -160
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +0 -77
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +0 -26
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +0 -33
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +0 -32
- package/src/core/code-flow/scripts/codeflow/core/cf_session_state.py +0 -37
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +0 -53
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +0 -9
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +0 -408
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +0 -253
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +0 -527
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +0 -95
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +0 -98
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +0 -30
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +0 -41
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +0 -292
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +0 -161
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +0 -412
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +0 -301
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +0 -149
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +0 -111
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +0 -114
- package/src/core/code-flow/scripts/codeflow/spec/cf_scan.py +0 -367
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +0 -55
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +0 -477
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +0 -119
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_metadata.py +0 -387
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_migrate.py +0 -529
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +0 -193
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +0 -187
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_resolver.py +0 -369
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_router.py +0 -304
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_verify.py +0 -687
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +0 -330
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +0 -83
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +0 -755
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_transaction.py +0 -110
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +0 -867
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +0 -178
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +0 -635
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +0 -251
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +0 -570
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +0 -73
- package/src/core/code-flow/scripts/runtime-resources.json +0 -29
- package/src/core/code-flow/specs/backend/_map.md +0 -56
- package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
- package/src/core/code-flow/specs/backend/database.md +0 -51
- package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
- package/src/core/code-flow/specs/backend/logging.md +0 -50
- package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
- package/src/core/code-flow/specs/frontend/_map.md +0 -52
- package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
- package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
- package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
- package/src/core/code-flow/specs/shared/_map.md +0 -57
- package/src/core/code-flow/specs/shared/scenarios-template.md +0 -55
- package/src/core/code-flow/tasks/.gitkeep +0 -0
- package/src/core/code-flow/validation.yml +0 -59
- package/src/migrate/runtime.js +0 -213
- package/src/migrate/spec-workflow.js +0 -253
- package/src/runtime.js +0 -219
|
@@ -1,369 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Deterministic stage/path candidate resolution for schema-v1 Specs."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
import fnmatch
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import Mapping, Optional, Sequence
|
|
10
|
-
|
|
11
|
-
from codeflow.spec.cf_spec_metadata import (
|
|
12
|
-
ALLOWED_STAGES,
|
|
13
|
-
SpecHeader,
|
|
14
|
-
SpecMetadata,
|
|
15
|
-
SpecRule,
|
|
16
|
-
load_spec_header,
|
|
17
|
-
load_spec_metadata,
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
SCOPE_PRIORITY = {"unmatched": 0, "global": 1, "path": 2, "task": 3}
|
|
22
|
-
_config_cache: dict[str, tuple[int, int, int, Mapping[str, object]]] = {}
|
|
23
|
-
_metadata_cache: dict[str, tuple[int, int, int, SpecMetadata]] = {}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class SpecResolutionError(ValueError):
|
|
27
|
-
"""An explicit resolver/configuration failure."""
|
|
28
|
-
|
|
29
|
-
def __init__(self, path: str, field: str, message: str) -> None:
|
|
30
|
-
self.path = path
|
|
31
|
-
self.field = field
|
|
32
|
-
self.message = message
|
|
33
|
-
super().__init__(f"{path}: resolution_error {field}: {message}")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@dataclass(frozen=True)
|
|
37
|
-
class SpecCandidate:
|
|
38
|
-
spec_id: str
|
|
39
|
-
path: str
|
|
40
|
-
scope: str
|
|
41
|
-
priority: int
|
|
42
|
-
matched_paths: tuple[str, ...]
|
|
43
|
-
metadata: SpecMetadata
|
|
44
|
-
|
|
45
|
-
@property
|
|
46
|
-
def required_rule_count(self) -> int:
|
|
47
|
-
if self.metadata.enforcement == "advisory":
|
|
48
|
-
return 0
|
|
49
|
-
return sum(rule.enforcement == "required" for rule in self.metadata.rules)
|
|
50
|
-
|
|
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
|
-
|
|
62
|
-
@dataclass(frozen=True)
|
|
63
|
-
class ResolvedRule:
|
|
64
|
-
ref: str
|
|
65
|
-
spec_id: str
|
|
66
|
-
text: str
|
|
67
|
-
enforcement: str
|
|
68
|
-
scope: str
|
|
69
|
-
priority: int
|
|
70
|
-
overridden_spec_ids: tuple[str, ...]
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
@dataclass(frozen=True)
|
|
74
|
-
class RuleConflict:
|
|
75
|
-
ref: str
|
|
76
|
-
priority: int
|
|
77
|
-
spec_ids: tuple[str, ...]
|
|
78
|
-
text_sha256: tuple[str, ...]
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
@dataclass(frozen=True)
|
|
82
|
-
class PrecedenceResolution:
|
|
83
|
-
candidates: tuple[SpecCandidate, ...]
|
|
84
|
-
effective_rules: tuple[ResolvedRule, ...]
|
|
85
|
-
conflicts: tuple[RuleConflict, ...]
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
@dataclass(frozen=True)
|
|
89
|
-
class CandidatePage:
|
|
90
|
-
items: tuple[SpecCandidate, ...]
|
|
91
|
-
total: int
|
|
92
|
-
has_more: bool
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
@dataclass(frozen=True)
|
|
96
|
-
class _RuleSource:
|
|
97
|
-
candidate: SpecCandidate
|
|
98
|
-
rule: SpecRule
|
|
99
|
-
enforcement: str
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _fingerprint(path: Path, field: str) -> tuple[int, int, int]:
|
|
103
|
-
try:
|
|
104
|
-
stat = path.stat()
|
|
105
|
-
except OSError as exc:
|
|
106
|
-
raise SpecResolutionError(str(path), field, f"读取状态失败: {exc}") from exc
|
|
107
|
-
return stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def _load_config(root: str) -> Mapping[str, object]:
|
|
111
|
-
import yaml
|
|
112
|
-
path = Path(root) / ".code-flow" / "config.yml"
|
|
113
|
-
if not path.is_file():
|
|
114
|
-
raise SpecResolutionError(str(path), "config", "缺失 config.yml")
|
|
115
|
-
signature = _fingerprint(path, "config")
|
|
116
|
-
cached = _config_cache.get(str(path))
|
|
117
|
-
if cached and cached[:3] == signature:
|
|
118
|
-
return cached[3]
|
|
119
|
-
try:
|
|
120
|
-
loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
121
|
-
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
122
|
-
raise SpecResolutionError(str(path), "config", f"解析失败: {exc}") from exc
|
|
123
|
-
if not isinstance(loaded, dict) or not all(isinstance(key, str) for key in loaded):
|
|
124
|
-
raise SpecResolutionError(str(path), "config", "顶层必须是字符串 key mapping")
|
|
125
|
-
_config_cache[str(path)] = (*signature, loaded)
|
|
126
|
-
return loaded
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _path_mapping(config: Mapping[str, object], path: str) -> Mapping[str, object]:
|
|
130
|
-
value = config.get("path_mapping")
|
|
131
|
-
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
|
132
|
-
raise SpecResolutionError(path, "path_mapping", "必须是字符串 key mapping")
|
|
133
|
-
return value
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _domain_config(mapping: Mapping[str, object], domain: str, config_path: str) -> Mapping[str, object]:
|
|
137
|
-
value = mapping.get(domain, {})
|
|
138
|
-
if not isinstance(value, dict):
|
|
139
|
-
raise SpecResolutionError(config_path, f"path_mapping.{domain}", "必须是 mapping")
|
|
140
|
-
return value
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _patterns(config: Mapping[str, object], field: str, config_path: str) -> tuple[str, ...]:
|
|
144
|
-
value = config.get("patterns", [])
|
|
145
|
-
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
146
|
-
raise SpecResolutionError(config_path, field, "patterns 必须是字符串列表")
|
|
147
|
-
return tuple(item.replace("\\", "/") for item in value)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def _non_injectable(mapping: Mapping[str, object], config_path: str) -> set[str]:
|
|
151
|
-
excluded: set[str] = set()
|
|
152
|
-
for domain in mapping:
|
|
153
|
-
config = _domain_config(mapping, domain, config_path)
|
|
154
|
-
entries = config.get("specs", [])
|
|
155
|
-
if not isinstance(entries, list):
|
|
156
|
-
raise SpecResolutionError(config_path, f"path_mapping.{domain}.specs", "必须是列表")
|
|
157
|
-
for entry in entries:
|
|
158
|
-
if isinstance(entry, dict) and entry.get("tags") == [] and isinstance(entry.get("path"), str):
|
|
159
|
-
excluded.add(str(entry["path"]).replace("\\", "/"))
|
|
160
|
-
return excluded
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def _load_metadata(path: Path) -> SpecMetadata:
|
|
164
|
-
signature = _fingerprint(path, "spec")
|
|
165
|
-
cached = _metadata_cache.get(str(path))
|
|
166
|
-
if cached and cached[:3] == signature:
|
|
167
|
-
return cached[3]
|
|
168
|
-
metadata = load_spec_metadata(str(path))
|
|
169
|
-
_metadata_cache[str(path)] = (*signature, metadata)
|
|
170
|
-
return metadata
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _candidate_scope(relative: str, patterns: Sequence[str], paths: Sequence[str]) -> tuple[str, tuple[str, ...]]:
|
|
174
|
-
if relative.startswith("_session/"):
|
|
175
|
-
return "task", ()
|
|
176
|
-
matched = tuple(path for path in paths if any(fnmatch.fnmatch(path, pattern) for pattern in patterns))
|
|
177
|
-
if matched:
|
|
178
|
-
return "path", matched
|
|
179
|
-
# A domain without patterns is truly global. A domain WITH patterns that
|
|
180
|
-
# match nothing is irrelevant to these paths ("unmatched") — discovery
|
|
181
|
-
# keeps it, but injection and scope expansion must not consume it.
|
|
182
|
-
return ("global", ()) if not patterns else ("unmatched", ())
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def _candidate_for(
|
|
186
|
-
path: Path,
|
|
187
|
-
spec_root: Path,
|
|
188
|
-
mapping: Mapping[str, object],
|
|
189
|
-
config_path: str,
|
|
190
|
-
stage: str,
|
|
191
|
-
paths: Sequence[str],
|
|
192
|
-
) -> Optional[SpecCandidate]:
|
|
193
|
-
relative = path.relative_to(spec_root).as_posix()
|
|
194
|
-
metadata = _load_metadata(path)
|
|
195
|
-
if stage not in metadata.stages:
|
|
196
|
-
return None
|
|
197
|
-
domain = relative.split("/", 1)[0]
|
|
198
|
-
config = _domain_config(mapping, domain, config_path)
|
|
199
|
-
patterns = _patterns(config, f"path_mapping.{domain}.patterns", config_path)
|
|
200
|
-
scope, matched = _candidate_scope(relative, patterns, paths)
|
|
201
|
-
return SpecCandidate(metadata.id, relative, scope, SCOPE_PRIORITY[scope], matched, metadata)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def _candidate_header_for(
|
|
205
|
-
path: Path,
|
|
206
|
-
spec_root: Path,
|
|
207
|
-
mapping: Mapping[str, object],
|
|
208
|
-
config_path: str,
|
|
209
|
-
stage: str,
|
|
210
|
-
paths: Sequence[str],
|
|
211
|
-
) -> Optional[SpecCandidateHeader]:
|
|
212
|
-
relative = path.relative_to(spec_root).as_posix()
|
|
213
|
-
header: SpecHeader = load_spec_header(str(path))
|
|
214
|
-
if stage not in header.stages:
|
|
215
|
-
return None
|
|
216
|
-
domain = relative.split("/", 1)[0]
|
|
217
|
-
config = _domain_config(mapping, domain, config_path)
|
|
218
|
-
patterns = _patterns(config, f"path_mapping.{domain}.patterns", config_path)
|
|
219
|
-
scope, matched = _candidate_scope(relative, patterns, paths)
|
|
220
|
-
return SpecCandidateHeader(
|
|
221
|
-
header.id, relative, scope, SCOPE_PRIORITY[scope], matched, header.enforcement
|
|
222
|
-
)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def _spec_paths(spec_root: Path, excluded: set[str]) -> tuple[Path, ...]:
|
|
226
|
-
result: list[Path] = []
|
|
227
|
-
for path in sorted(spec_root.rglob("*.md")):
|
|
228
|
-
relative = path.relative_to(spec_root).as_posix()
|
|
229
|
-
if path.name != "_map.md" and not relative.startswith("_session/") and relative not in excluded:
|
|
230
|
-
result.append(path)
|
|
231
|
-
return tuple(result)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
def _routing_paths(
|
|
235
|
-
spec_root: Path, excluded: set[str], mapping: Mapping[str, object],
|
|
236
|
-
config_path: str, paths: Sequence[str], matched_only: bool,
|
|
237
|
-
) -> tuple[Path, ...]:
|
|
238
|
-
discovered = _spec_paths(spec_root, excluded)
|
|
239
|
-
if not matched_only:
|
|
240
|
-
return discovered
|
|
241
|
-
relevant = []
|
|
242
|
-
for path in discovered:
|
|
243
|
-
relative = path.relative_to(spec_root).as_posix()
|
|
244
|
-
domain = relative.split("/", 1)[0]
|
|
245
|
-
patterns = _patterns(_domain_config(mapping, domain, config_path),
|
|
246
|
-
f"path_mapping.{domain}.patterns", config_path)
|
|
247
|
-
if _candidate_scope(relative, patterns, paths)[0] != "unmatched":
|
|
248
|
-
relevant.append(path)
|
|
249
|
-
return tuple(relevant)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def resolve_candidates(
|
|
253
|
-
root: str, stage: str, paths: Sequence[str], *, matched_only: bool = False,
|
|
254
|
-
) -> tuple[SpecCandidate, ...]:
|
|
255
|
-
if stage not in ALLOWED_STAGES:
|
|
256
|
-
raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
|
|
257
|
-
normalized_paths = tuple(path.replace("\\", "/") for path in paths)
|
|
258
|
-
config = _load_config(root)
|
|
259
|
-
config_path = str(Path(root) / ".code-flow" / "config.yml")
|
|
260
|
-
mapping = _path_mapping(config, config_path)
|
|
261
|
-
excluded = _non_injectable(mapping, config_path)
|
|
262
|
-
spec_root = Path(root) / ".code-flow" / "specs"
|
|
263
|
-
if not spec_root.is_dir():
|
|
264
|
-
raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
|
|
265
|
-
candidates: list[SpecCandidate] = []
|
|
266
|
-
ids: dict[str, str] = {}
|
|
267
|
-
for path in _routing_paths(spec_root, excluded, mapping, config_path, normalized_paths, matched_only):
|
|
268
|
-
candidate = _candidate_for(path, spec_root, mapping, config_path, stage, normalized_paths)
|
|
269
|
-
if candidate is None:
|
|
270
|
-
continue
|
|
271
|
-
if candidate.spec_id in ids:
|
|
272
|
-
detail = f"重复 id {candidate.spec_id}: {ids[candidate.spec_id]}, {candidate.path}"
|
|
273
|
-
raise SpecResolutionError(str(spec_root), "spec_id", detail)
|
|
274
|
-
ids[candidate.spec_id] = candidate.path
|
|
275
|
-
candidates.append(candidate)
|
|
276
|
-
return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def resolve_candidate_headers(
|
|
280
|
-
root: str, stage: str, paths: Sequence[str], *, matched_only: bool = False,
|
|
281
|
-
excluded_paths: Sequence[str] = (),
|
|
282
|
-
) -> tuple[SpecCandidateHeader, ...]:
|
|
283
|
-
"""Resolve candidates from routing frontmatter without parsing bodies or hashes."""
|
|
284
|
-
if stage not in ALLOWED_STAGES:
|
|
285
|
-
raise SpecResolutionError(root, "stage", f"不支持 stage: {stage!r}")
|
|
286
|
-
normalized_paths = tuple(path.replace("\\", "/") for path in paths)
|
|
287
|
-
config = _load_config(root)
|
|
288
|
-
config_path = str(Path(root) / ".code-flow" / "config.yml")
|
|
289
|
-
mapping = _path_mapping(config, config_path)
|
|
290
|
-
excluded = _non_injectable(mapping, config_path)
|
|
291
|
-
excluded.update(excluded_paths)
|
|
292
|
-
spec_root = Path(root) / ".code-flow" / "specs"
|
|
293
|
-
if not spec_root.is_dir():
|
|
294
|
-
raise SpecResolutionError(str(spec_root), "specs", "缺失 specs 目录")
|
|
295
|
-
candidates: list[SpecCandidateHeader] = []
|
|
296
|
-
ids: dict[str, str] = {}
|
|
297
|
-
for path in _routing_paths(spec_root, excluded, mapping, config_path, normalized_paths, matched_only):
|
|
298
|
-
candidate = _candidate_header_for(path, spec_root, mapping, config_path, stage, normalized_paths)
|
|
299
|
-
if candidate is None:
|
|
300
|
-
continue
|
|
301
|
-
if candidate.spec_id in ids:
|
|
302
|
-
detail = f"重复 id {candidate.spec_id}: {ids[candidate.spec_id]}, {candidate.path}"
|
|
303
|
-
raise SpecResolutionError(str(spec_root), "spec_id", detail)
|
|
304
|
-
ids[candidate.spec_id] = candidate.path
|
|
305
|
-
candidates.append(candidate)
|
|
306
|
-
return tuple(sorted(candidates, key=lambda item: (-item.priority, item.path)))
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def _effective_enforcement(candidate: SpecCandidate, rule: SpecRule) -> str:
|
|
310
|
-
if candidate.metadata.enforcement == "advisory" and rule.enforcement == "required":
|
|
311
|
-
return "advisory"
|
|
312
|
-
return rule.enforcement
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
def _rule_sources(candidates: Sequence[SpecCandidate]) -> dict[str, list[_RuleSource]]:
|
|
316
|
-
grouped: dict[str, list[_RuleSource]] = {}
|
|
317
|
-
for candidate in candidates:
|
|
318
|
-
for rule in candidate.metadata.rules:
|
|
319
|
-
source = _RuleSource(candidate, rule, _effective_enforcement(candidate, rule))
|
|
320
|
-
grouped.setdefault(rule.ref, []).append(source)
|
|
321
|
-
for sources in grouped.values():
|
|
322
|
-
sources.sort(key=lambda item: (-item.candidate.priority, item.candidate.path))
|
|
323
|
-
return grouped
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def _resolve_rule(ref: str, sources: Sequence[_RuleSource]) -> tuple[Optional[ResolvedRule], Optional[RuleConflict]]:
|
|
327
|
-
priority = sources[0].candidate.priority
|
|
328
|
-
top = tuple(source for source in sources if source.candidate.priority == priority)
|
|
329
|
-
required = tuple(source for source in top if source.enforcement == "required")
|
|
330
|
-
hashes = {source.rule.text_sha256 for source in required}
|
|
331
|
-
if len(required) > 1 and len(hashes) > 1:
|
|
332
|
-
conflict = RuleConflict(
|
|
333
|
-
ref,
|
|
334
|
-
priority,
|
|
335
|
-
tuple(source.candidate.spec_id for source in required),
|
|
336
|
-
tuple(source.rule.text_sha256 for source in required),
|
|
337
|
-
)
|
|
338
|
-
return None, conflict
|
|
339
|
-
winner = required[0] if required else top[0]
|
|
340
|
-
overridden = tuple(source.candidate.spec_id for source in sources if source is not winner)
|
|
341
|
-
resolved = ResolvedRule(
|
|
342
|
-
ref,
|
|
343
|
-
winner.candidate.spec_id,
|
|
344
|
-
winner.rule.text,
|
|
345
|
-
winner.enforcement,
|
|
346
|
-
winner.candidate.scope,
|
|
347
|
-
priority,
|
|
348
|
-
overridden,
|
|
349
|
-
)
|
|
350
|
-
return resolved, None
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
def resolve_precedence(candidates: Sequence[SpecCandidate]) -> PrecedenceResolution:
|
|
354
|
-
effective: list[ResolvedRule] = []
|
|
355
|
-
conflicts: list[RuleConflict] = []
|
|
356
|
-
for ref, sources in sorted(_rule_sources(candidates).items()):
|
|
357
|
-
resolved, conflict = _resolve_rule(ref, sources)
|
|
358
|
-
if resolved is not None:
|
|
359
|
-
effective.append(resolved)
|
|
360
|
-
if conflict is not None:
|
|
361
|
-
conflicts.append(conflict)
|
|
362
|
-
return PrecedenceResolution(tuple(candidates), tuple(effective), tuple(conflicts))
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
def build_candidate_page(candidates: Sequence[SpecCandidate], limit: int) -> CandidatePage:
|
|
366
|
-
if limit <= 0:
|
|
367
|
-
raise SpecResolutionError("<memory>", "limit", "必须大于 0")
|
|
368
|
-
items = tuple(candidates[:limit])
|
|
369
|
-
return CandidatePage(items=items, total=len(candidates), has_more=len(candidates) > limit)
|
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Context-first prompt router for task, path, and Catalog modes."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
import hashlib
|
|
8
|
-
import json
|
|
9
|
-
import os
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
import tempfile
|
|
12
|
-
import codeflow.cli.cf_spec_session as cf_spec_session
|
|
13
|
-
from typing import Mapping, Optional, Sequence
|
|
14
|
-
|
|
15
|
-
from codeflow.core.cf_core import (
|
|
16
|
-
assemble_context,
|
|
17
|
-
ConfigError,
|
|
18
|
-
build_effective_mapping,
|
|
19
|
-
build_spec_catalog,
|
|
20
|
-
estimate_tokens,
|
|
21
|
-
load_config,
|
|
22
|
-
resolve_compress,
|
|
23
|
-
)
|
|
24
|
-
from codeflow.core.cf_log import warn
|
|
25
|
-
from codeflow.cli.cf_spec_context import ContextError, SpecContext, load_active_task, load_context
|
|
26
|
-
from codeflow.core.cf_context_cache import load_snapshot
|
|
27
|
-
from codeflow.spec.cf_spec_resolver import resolve_candidate_headers
|
|
28
|
-
from codeflow.spec.cf_spec_context_model import context_sha256
|
|
29
|
-
from codeflow.spec.cf_task_projection import project_task_session
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
_PROJECTION_CACHE_PATH = ".code-flow/.task-projection-state.json"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
@dataclass(frozen=True)
|
|
36
|
-
class RouteResult:
|
|
37
|
-
mode: str
|
|
38
|
-
text: str
|
|
39
|
-
specs: tuple[str, ...]
|
|
40
|
-
context_sha256: Optional[str] = None
|
|
41
|
-
expanded_specs: tuple[str, ...] = ()
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class RouterError(ValueError):
|
|
45
|
-
"""Fail-closed routing error with a stable code."""
|
|
46
|
-
|
|
47
|
-
def __init__(self, code: str, message: str) -> None:
|
|
48
|
-
self.code = code
|
|
49
|
-
super().__init__(message)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _task_file(task_dir: Path, task_id: str) -> Path:
|
|
53
|
-
matches = []
|
|
54
|
-
for path in sorted(task_dir.glob("*.md")):
|
|
55
|
-
if path.name.endswith((".prd.md", ".design.md")):
|
|
56
|
-
continue
|
|
57
|
-
if f"## {task_id}:" in path.read_text(encoding="utf-8"):
|
|
58
|
-
matches.append(path)
|
|
59
|
-
if len(matches) != 1:
|
|
60
|
-
raise RouterError("task_file_ambiguous", f"{task_id} matched {len(matches)} task files")
|
|
61
|
-
return matches[0]
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _projection_cache_path(root: str) -> Path:
|
|
65
|
-
return Path(root) / _PROJECTION_CACHE_PATH
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _context_stat(task_dir: Path) -> list[int]:
|
|
69
|
-
info = (task_dir / "spec-context.yml").stat()
|
|
70
|
-
return [info.st_mtime_ns, info.st_ctime_ns, info.st_size]
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _load_projection_cache(root: str) -> Mapping[str, object]:
|
|
74
|
-
try:
|
|
75
|
-
value = json.loads(_projection_cache_path(root).read_text(encoding="utf-8"))
|
|
76
|
-
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
77
|
-
return {}
|
|
78
|
-
if not isinstance(value, dict):
|
|
79
|
-
return {}
|
|
80
|
-
checksum = value.pop("checksum", None)
|
|
81
|
-
expected = hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
82
|
-
return value if checksum == expected else {}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _cached_projection(
|
|
86
|
-
root: str, task_dir: Path, task_id: str, current_hash: str
|
|
87
|
-
) -> Optional[tuple[str, bool]]:
|
|
88
|
-
cache = _load_projection_cache(root)
|
|
89
|
-
if (
|
|
90
|
-
cache.get("version") != 1
|
|
91
|
-
or cache.get("task_id") != task_id
|
|
92
|
-
or cache.get("context_sha256") != current_hash
|
|
93
|
-
or not isinstance(cache.get("task_file"), str)
|
|
94
|
-
or not isinstance(cache.get("text"), str)
|
|
95
|
-
or not isinstance(cache.get("truncated"), bool)
|
|
96
|
-
):
|
|
97
|
-
return None
|
|
98
|
-
task_file = Path(str(cache["task_file"]))
|
|
99
|
-
if task_file.parent != task_dir or not task_file.is_file():
|
|
100
|
-
return None
|
|
101
|
-
try:
|
|
102
|
-
directory_mtime = task_dir.stat().st_mtime_ns
|
|
103
|
-
task_mtime = task_file.stat().st_mtime_ns
|
|
104
|
-
except OSError:
|
|
105
|
-
return None
|
|
106
|
-
if cache.get("task_dir_mtime_ns") != directory_mtime or cache.get("task_mtime_ns") != task_mtime:
|
|
107
|
-
return None
|
|
108
|
-
if cache.get("context_stat") != _context_stat(task_dir):
|
|
109
|
-
return None
|
|
110
|
-
if cache.get("projector_sha256") != hashlib.sha256(Path(cf_spec_session.__file__).read_bytes()).hexdigest():
|
|
111
|
-
return None
|
|
112
|
-
if cache.get("task_sha256") != hashlib.sha256(task_file.read_bytes()).hexdigest():
|
|
113
|
-
return None
|
|
114
|
-
return str(cache["text"]), bool(cache["truncated"])
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _save_projection_cache(
|
|
118
|
-
root: str,
|
|
119
|
-
task_dir: Path,
|
|
120
|
-
task_file: Path,
|
|
121
|
-
task_id: str,
|
|
122
|
-
current_hash: str,
|
|
123
|
-
text: str,
|
|
124
|
-
truncated: bool,
|
|
125
|
-
) -> None:
|
|
126
|
-
try:
|
|
127
|
-
payload = {
|
|
128
|
-
"version": 1,
|
|
129
|
-
"task_id": task_id,
|
|
130
|
-
"context_sha256": current_hash,
|
|
131
|
-
"context_stat": _context_stat(task_dir),
|
|
132
|
-
"projector_sha256": hashlib.sha256(Path(cf_spec_session.__file__).read_bytes()).hexdigest(),
|
|
133
|
-
"task_file": str(task_file),
|
|
134
|
-
"task_dir_mtime_ns": task_dir.stat().st_mtime_ns,
|
|
135
|
-
"task_mtime_ns": task_file.stat().st_mtime_ns,
|
|
136
|
-
"task_sha256": hashlib.sha256(task_file.read_bytes()).hexdigest(),
|
|
137
|
-
"text": text,
|
|
138
|
-
"truncated": truncated,
|
|
139
|
-
}
|
|
140
|
-
path = _projection_cache_path(root)
|
|
141
|
-
payload["checksum"] = hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
142
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
-
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
144
|
-
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
145
|
-
os.replace(temporary, path)
|
|
146
|
-
except (OSError, TypeError, UnicodeError):
|
|
147
|
-
if "temporary" in locals() and temporary.exists():
|
|
148
|
-
try:
|
|
149
|
-
temporary.unlink()
|
|
150
|
-
except OSError:
|
|
151
|
-
pass
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def _verify_bound_specs(root: str, context: SpecContext) -> None:
|
|
155
|
-
spec_root = Path(root) / ".code-flow/specs"
|
|
156
|
-
for binding in context.bindings:
|
|
157
|
-
target = spec_root / binding.path
|
|
158
|
-
try:
|
|
159
|
-
current = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
160
|
-
except OSError as exc:
|
|
161
|
-
raise RouterError("bound_spec_missing", f"{binding.path}: {exc}; refresh Context") from exc
|
|
162
|
-
if current != binding.hashes.file_sha256:
|
|
163
|
-
raise RouterError("bound_spec_drift", f"{binding.path} changed; refresh Context before coding")
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def _scope_expansion(root: str, context: SpecContext, paths: Sequence[str]) -> tuple[str, ...]:
|
|
167
|
-
bound = {binding.spec_id for binding in context.bindings}
|
|
168
|
-
candidates = resolve_candidate_headers(
|
|
169
|
-
root, "code", paths, matched_only=True,
|
|
170
|
-
excluded_paths=tuple(binding.path for binding in context.bindings),
|
|
171
|
-
)
|
|
172
|
-
return tuple(item.spec_id for item in candidates
|
|
173
|
-
if item.spec_id not in bound and item.enforcement == "required")
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def _active_route(root: str, paths: Sequence[str] = (), check_scope: bool = False) -> RouteResult:
|
|
177
|
-
try:
|
|
178
|
-
active = load_active_task(root)
|
|
179
|
-
task_dir = (Path(root) / active.task_dir).resolve()
|
|
180
|
-
context, current_hash = load_snapshot(root, str(task_dir / "spec-context.yml"), load_context)
|
|
181
|
-
except (ContextError, OSError, ValueError) as exc:
|
|
182
|
-
raise RouterError("invalid_active_task", str(exc)) from exc
|
|
183
|
-
if current_hash != active.context_sha256:
|
|
184
|
-
raise RouterError(
|
|
185
|
-
"active_context_drift",
|
|
186
|
-
"active marker Context hash does not match; run `code-flow spec refresh --task-dir <dir> --root <root> --json` to re-sync it (refresh updates the marker hash), or `code-flow spec active doctor --resync` with the hash from `code-flow spec status --json`",
|
|
187
|
-
)
|
|
188
|
-
_verify_bound_specs(root, context)
|
|
189
|
-
cached = _cached_projection(root, task_dir, active.task_id, current_hash)
|
|
190
|
-
if cached is None:
|
|
191
|
-
task_file = _task_file(task_dir, active.task_id)
|
|
192
|
-
try:
|
|
193
|
-
projection = project_task_session(context, str(task_file), active.task_id, context_hash=current_hash)
|
|
194
|
-
except ValueError as exc:
|
|
195
|
-
raise RouterError("invalid_task_projection", str(exc)) from exc
|
|
196
|
-
_save_projection_cache(
|
|
197
|
-
root, task_dir, task_file, active.task_id, current_hash, projection.text, projection.truncated
|
|
198
|
-
)
|
|
199
|
-
text, truncated = projection.text, projection.truncated
|
|
200
|
-
else:
|
|
201
|
-
text, truncated = cached
|
|
202
|
-
if truncated:
|
|
203
|
-
raise RouterError("task_projection_truncated", "split the TASK before coding")
|
|
204
|
-
refs = tuple(binding.spec_id for binding in context.bindings
|
|
205
|
-
if any(f"`{binding.spec_id}#{rule.ref}`" in text for rule in binding.rules))
|
|
206
|
-
expanded = _scope_expansion(root, context, paths) if check_scope else ()
|
|
207
|
-
return RouteResult("task", text, refs, current_hash, expanded)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _read_candidates(root: str, paths: Sequence[str], compress: bool = True) -> RouteResult:
|
|
211
|
-
from codeflow.spec.cf_spec_budget import SpecBudgetError, path_specs
|
|
212
|
-
try:
|
|
213
|
-
specs = path_specs(root, paths, compress)
|
|
214
|
-
except SpecBudgetError as exc:
|
|
215
|
-
raise RouterError(exc.code, str(exc)) from exc
|
|
216
|
-
text = assemble_context(specs, "## Active Specs (path-mapped)") if specs else ""
|
|
217
|
-
budget = load_config(root).get("budget", {})
|
|
218
|
-
if not isinstance(budget, dict):
|
|
219
|
-
raise RouterError("invalid_budget", "budget must be a mapping")
|
|
220
|
-
maximum = min(int(budget.get("total", 2500)), int(budget.get("l1_max", 1700)))
|
|
221
|
-
if maximum <= 0 or estimate_tokens(text) > maximum:
|
|
222
|
-
raise RouterError("spec_budget_exceeded", f"Matched Specs exceed {maximum} estimated tokens; create a focused TASK Context or split the editing scope. Required constraints were not truncated.")
|
|
223
|
-
return RouteResult("path", text, tuple(item["path"] for item in specs))
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def _catalog_path(root: str) -> Path:
|
|
227
|
-
return Path(root) / ".code-flow/.catalog-state.json"
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def _load_catalog_state(root: str) -> dict[str, object]:
|
|
231
|
-
path = _catalog_path(root)
|
|
232
|
-
if not path.exists():
|
|
233
|
-
return {"version": 1, "session_id": "", "prompt_count": 0, "last_emitted": 0}
|
|
234
|
-
try:
|
|
235
|
-
value = json.loads(path.read_text(encoding="utf-8"))
|
|
236
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
237
|
-
raise RouterError("invalid_catalog_state", str(exc)) from exc
|
|
238
|
-
if not isinstance(value, dict) or value.get("version") != 1:
|
|
239
|
-
raise RouterError("invalid_catalog_state", "Catalog state schema must be version 1")
|
|
240
|
-
if any(type(value.get(key)) is not int or value[key] < 0 for key in ("prompt_count", "last_emitted")):
|
|
241
|
-
raise RouterError("invalid_catalog_state", "Catalog counters must be nonnegative integers")
|
|
242
|
-
return value
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def _save_catalog_state(root: str, state: Mapping[str, object]) -> None:
|
|
246
|
-
path = _catalog_path(root)
|
|
247
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
248
|
-
temporary = ""
|
|
249
|
-
try:
|
|
250
|
-
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
|
|
251
|
-
temporary = handle.name
|
|
252
|
-
json.dump(state, handle, ensure_ascii=False)
|
|
253
|
-
handle.flush()
|
|
254
|
-
os.fsync(handle.fileno())
|
|
255
|
-
os.replace(temporary, path)
|
|
256
|
-
except OSError as exc:
|
|
257
|
-
if temporary and Path(temporary).exists():
|
|
258
|
-
Path(temporary).unlink()
|
|
259
|
-
warn(f"Spec Catalog dedup state could not be saved: {exc}")
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def _catalog_route(root: str, session_id: str, config: Mapping[str, object]) -> RouteResult:
|
|
263
|
-
state = _load_catalog_state(root)
|
|
264
|
-
same_session = state.get("session_id") == session_id
|
|
265
|
-
count = int(state.get("prompt_count", 0)) + 1 if same_session else 1
|
|
266
|
-
last = int(state.get("last_emitted", 0)) if same_session else 0
|
|
267
|
-
workflow = config.get("spec_workflow") if isinstance(config.get("spec_workflow"), dict) else {}
|
|
268
|
-
catalog_config = workflow.get("catalog") if isinstance(workflow.get("catalog"), dict) else {}
|
|
269
|
-
window = int(catalog_config.get("dedup_window", 5))
|
|
270
|
-
due = window <= 0 or last == 0 or count - last >= window
|
|
271
|
-
next_state = {"version": 1, "session_id": session_id, "prompt_count": count, "last_emitted": count if due else last}
|
|
272
|
-
if not due:
|
|
273
|
-
_save_catalog_state(root, next_state)
|
|
274
|
-
return RouteResult("catalog", "", ())
|
|
275
|
-
budget = config.get("budget") if isinstance(config.get("budget"), dict) else {}
|
|
276
|
-
maximum = int(budget.get("catalog_max", 200))
|
|
277
|
-
mapping = build_effective_mapping(root, config.get("path_mapping") or {})
|
|
278
|
-
text = build_spec_catalog(root, mapping, maximum)
|
|
279
|
-
from codeflow.spec.cf_injection import defer_commit
|
|
280
|
-
defer_commit(lambda: _save_catalog_state(root, next_state))
|
|
281
|
-
return RouteResult("catalog", text, ())
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
def route_prompt(root: str, paths: Sequence[str], session_id: str, *, check_scope: bool = False) -> RouteResult:
|
|
285
|
-
try:
|
|
286
|
-
return _route_prompt(root, paths, session_id, check_scope)
|
|
287
|
-
except RouterError:
|
|
288
|
-
raise
|
|
289
|
-
except (ConfigError, ContextError, OSError, UnicodeError, ValueError, TypeError) as exc:
|
|
290
|
-
raise RouterError(getattr(exc, "code", "invalid_routing_input"), str(exc)) from exc
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def _route_prompt(root: str, paths: Sequence[str], session_id: str, check_scope: bool) -> RouteResult:
|
|
294
|
-
"""Choose exactly one route; an invalid active marker never falls back."""
|
|
295
|
-
marker = Path(root) / ".code-flow/.active-task.json"
|
|
296
|
-
if marker.exists():
|
|
297
|
-
return _active_route(root, paths, check_scope)
|
|
298
|
-
config = load_config(root)
|
|
299
|
-
if not config:
|
|
300
|
-
return RouteResult("none", "", ())
|
|
301
|
-
if paths:
|
|
302
|
-
quality = config.get("quality_loop")
|
|
303
|
-
return _read_candidates(root, paths, resolve_compress(quality if isinstance(quality, dict) else {}))
|
|
304
|
-
return _catalog_route(root, session_id, config)
|