@michaelxwb/code-flow 0.6.4 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -3
- package/docs/USAGE.md +1529 -0
- package/docs/acceptance-contract.md +217 -0
- package/docs/ci/acceptance-evidence.md +77 -0
- package/docs/migrations/runtime-0.7.0.md +41 -0
- package/docs/migrations/runtime-0.8.0.md +154 -0
- package/docs/migrations/runtime-0.9.0.md +40 -0
- package/docs/spec-injection-hardening.md +54 -0
- package/docs/spec-injection-performance.json +73 -0
- package/docs/spec-optimization.md +12 -0
- package/package.json +18 -3
- package/src/adapters/claude/CLAUDE.md +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -3
- package/src/adapters/claude/commands/cf-spec.md +23 -3
- package/src/adapters/claude/commands/cf-stats.md +11 -1
- package/src/adapters/claude/commands/cf-sync.md +20 -6
- package/src/adapters/claude/commands/cf-task/align.md +25 -3
- package/src/adapters/claude/commands/cf-task/archive.md +36 -40
- package/src/adapters/claude/commands/cf-task/block.md +15 -2
- package/src/adapters/claude/commands/cf-task/graph.md +13 -1
- package/src/adapters/claude/commands/cf-task/note.md +13 -1
- package/src/adapters/claude/commands/cf-task/plan.md +137 -23
- package/src/adapters/claude/commands/cf-task/prd.md +13 -2
- package/src/adapters/claude/commands/cf-task/start.md +67 -35
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/claude/commands/cf-validate.md +30 -5
- package/src/adapters/codex/AGENTS.md +1 -1
- package/src/adapters/codex/hooks.json +14 -10
- package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
- package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
- package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -40
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +67 -35
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
- package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
- package/src/adapters/commands.json +115 -0
- package/src/adapters/costrict/CLAUDE.md +1 -1
- package/src/adapters/costrict/commands/cf-init.md +1 -3
- package/src/adapters/costrict/commands/cf-spec.md +23 -3
- package/src/adapters/costrict/commands/cf-stats.md +11 -1
- package/src/adapters/costrict/commands/cf-sync.md +20 -6
- package/src/adapters/costrict/commands/cf-task/align.md +25 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +36 -40
- package/src/adapters/costrict/commands/cf-task/block.md +15 -2
- package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
- package/src/adapters/costrict/commands/cf-task/note.md +13 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
- package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
- package/src/adapters/costrict/commands/cf-task/start.md +67 -35
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/costrict/commands/cf-validate.md +30 -5
- package/src/adapters/install-plan.json +100 -0
- package/src/adapters/opencode/AGENTS.md +1 -1
- package/src/adapters/opencode/commands/cf-init.md +7 -7
- package/src/adapters/opencode/commands/cf-learn.md +6 -6
- package/src/adapters/opencode/commands/cf-spec.md +34 -2
- package/src/adapters/opencode/commands/cf-stats.md +17 -7
- package/src/adapters/opencode/commands/cf-sync.md +21 -6
- package/src/adapters/opencode/commands/cf-task/align.md +25 -8
- package/src/adapters/opencode/commands/cf-task/archive.md +36 -40
- package/src/adapters/opencode/commands/cf-task/block.md +16 -2
- package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
- package/src/adapters/opencode/commands/cf-task/note.md +14 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
- package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
- package/src/adapters/opencode/commands/cf-task/start.md +68 -41
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/opencode/commands/cf-validate.md +35 -9
- package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
- package/src/adapters/platform-bindings.json +370 -0
- package/src/cli.js +369 -316
- package/src/core/code-flow/config.yml +4 -1
- package/src/core/code-flow/runtime-commands.json +259 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
- package/src/core/code-flow/scripts/cf_audit.py +10 -0
- package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
- package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
- package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
- package/src/core/code-flow/scripts/cf_feedback.py +5 -51
- package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
- package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +5 -1714
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
- package/src/core/code-flow/scripts/cf_stats.py +5 -501
- package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
- package/src/core/code-flow/scripts/cf_sync.py +5 -135
- package/src/core/code-flow/scripts/cf_task_index.py +5 -143
- package/src/core/code-flow/scripts/cf_task_parallel.py +5 -380
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
- package/src/core/code-flow/scripts/cf_validation.py +6 -77
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
- package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
- package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
- package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
- package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
- package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +37 -3
- package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
- package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
- package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
- package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +252 -26
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
- package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
- package/src/core/code-flow/scripts/runtime-resources.json +29 -0
- package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
- package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
- package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
- package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
- package/src/core/code-flow/validation.yml +9 -3
- package/src/migrate/runtime.js +213 -0
- package/src/migrate/spec-workflow.js +16 -1
- package/src/runtime.js +219 -0
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -299
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
|
@@ -1,1719 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import argparse
|
|
7
|
-
from dataclasses import dataclass, replace
|
|
8
|
-
from datetime import datetime, timezone
|
|
9
|
-
from fnmatch import fnmatchcase
|
|
10
|
-
import hashlib
|
|
11
|
-
import json
|
|
12
|
-
import os
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
import subprocess
|
|
2
|
+
"""Path-frozen entry shim: implementation lives in codeflow.cli.cf_spec_context."""
|
|
15
3
|
import sys
|
|
16
|
-
import tempfile
|
|
17
|
-
import time
|
|
18
|
-
from typing import IO, Mapping, Optional, Sequence
|
|
19
|
-
|
|
20
|
-
from cf_spec_metadata import SpecHashes, SpecMetadata, SpecRule, load_spec_metadata
|
|
21
|
-
from cf_spec_resolver import SpecCandidate, resolve_candidates
|
|
22
|
-
from cf_core import phase_timing
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
CONTEXT_VERSION = 1
|
|
26
|
-
STAGE_STATUSES = frozenset(
|
|
27
|
-
("pending", "applied", "not_applicable", "waived", "verified", "stale", "conflict", "unverified")
|
|
28
|
-
)
|
|
29
|
-
DECISION_KINDS = frozenset(("not_applicable", "waived", "manual_verification"))
|
|
30
|
-
_AGENT_IDENTITIES = frozenset(("agent", "assistant", "codex", "claude", "opencode", "costrict"))
|
|
31
|
-
_FILE_HASH_CACHE: dict[str, tuple[int, int, int, str]] = {}
|
|
32
|
-
_PERSISTENT_HASH_CACHE: dict[str, dict[str, object]] = {}
|
|
33
|
-
_PERSISTENT_HASH_PATHS: set[str] = set()
|
|
34
|
-
_MAX_HASH_CACHE_ENTRIES = 4096
|
|
35
|
-
ACTIVE_STATUSES = frozenset(("activating", "active", "paused", "blocked", "completed"))
|
|
36
|
-
DEFAULT_ACTIVE_EXCLUDES = (
|
|
37
|
-
".code-flow/tasks/*",
|
|
38
|
-
".code-flow/specs/_session/*",
|
|
39
|
-
".code-flow/migrations/*",
|
|
40
|
-
".code-flow/.*state*",
|
|
41
|
-
".code-flow/.*cache*",
|
|
42
|
-
".code-flow/.session-log.jsonl",
|
|
43
|
-
".code-flow/.active-task.json",
|
|
44
|
-
".code-flow/.active-task.lock",
|
|
45
|
-
".code-flow/worktrees/*",
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class ContextError(ValueError):
|
|
50
|
-
"""A stable context error suitable for JSON CLI output."""
|
|
51
|
-
|
|
52
|
-
def __init__(self, code: str, field: str, message: str, path: str = "") -> None:
|
|
53
|
-
self.code = code
|
|
54
|
-
self.field = field
|
|
55
|
-
self.message = message
|
|
56
|
-
self.path = path
|
|
57
|
-
location = f"{path}: " if path else ""
|
|
58
|
-
super().__init__(f"{location}{code} {field}: {message}")
|
|
59
|
-
|
|
60
|
-
def to_dict(self) -> dict[str, str]:
|
|
61
|
-
return {"code": self.code, "field": self.field, "message": self.message, "path": self.path}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
@dataclass(frozen=True)
|
|
65
|
-
class ContextSource:
|
|
66
|
-
type: str
|
|
67
|
-
ref: str
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
@dataclass(frozen=True)
|
|
71
|
-
class Decision:
|
|
72
|
-
kind: str
|
|
73
|
-
reason: str
|
|
74
|
-
confirmed_by: str
|
|
75
|
-
confirmed_at: str
|
|
76
|
-
source: str
|
|
77
|
-
expires_at: Optional[str]
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
@dataclass(frozen=True)
|
|
81
|
-
class ArtifactRef:
|
|
82
|
-
artifact: str
|
|
83
|
-
section_id: str
|
|
84
|
-
item_id: str
|
|
85
|
-
artifact_sha256: str
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
@dataclass(frozen=True)
|
|
89
|
-
class RuleStageStatus:
|
|
90
|
-
status: str
|
|
91
|
-
refs: tuple[ArtifactRef, ...]
|
|
92
|
-
decision: Optional[Decision]
|
|
93
|
-
evidence: tuple[Mapping[str, object], ...]
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
@dataclass(frozen=True)
|
|
97
|
-
class RuleBinding:
|
|
98
|
-
ref: str
|
|
99
|
-
summary: str
|
|
100
|
-
text_sha256: str
|
|
101
|
-
enforcement: str
|
|
102
|
-
verifier_ref: str
|
|
103
|
-
stage_status: Mapping[str, RuleStageStatus]
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
@dataclass(frozen=True)
|
|
107
|
-
class SpecBinding:
|
|
108
|
-
spec_id: str
|
|
109
|
-
path: str
|
|
110
|
-
hashes: SpecHashes
|
|
111
|
-
selected_by: str
|
|
112
|
-
reason: str
|
|
113
|
-
enforcement: str
|
|
114
|
-
stages: tuple[str, ...]
|
|
115
|
-
rules: tuple[RuleBinding, ...]
|
|
116
|
-
status: str = "active"
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
@dataclass(frozen=True)
|
|
120
|
-
class SpecContext:
|
|
121
|
-
version: int
|
|
122
|
-
task: str
|
|
123
|
-
enforcement: str
|
|
124
|
-
updated_at: str
|
|
125
|
-
sources: tuple[ContextSource, ...]
|
|
126
|
-
bindings: tuple[SpecBinding, ...]
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
@dataclass(frozen=True)
|
|
130
|
-
class BindingInput:
|
|
131
|
-
candidate: SpecCandidate
|
|
132
|
-
selected_by: str
|
|
133
|
-
reason: str
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
@dataclass(frozen=True)
|
|
137
|
-
class DriftChange:
|
|
138
|
-
kind: str
|
|
139
|
-
spec_id: str
|
|
140
|
-
rule_ref: str
|
|
141
|
-
old_hash: str
|
|
142
|
-
new_hash: str
|
|
143
|
-
stages: tuple[str, ...]
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
@dataclass(frozen=True)
|
|
147
|
-
class DriftResult:
|
|
148
|
-
context: SpecContext
|
|
149
|
-
changes: tuple[DriftChange, ...]
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
@dataclass(frozen=True)
|
|
153
|
-
class PathSnapshot:
|
|
154
|
-
status: str
|
|
155
|
-
content_sha256: str
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
@dataclass(frozen=True)
|
|
159
|
-
class ActiveBaseline:
|
|
160
|
-
head: Optional[str]
|
|
161
|
-
captured_at: str
|
|
162
|
-
preexisting_changes: Mapping[str, PathSnapshot]
|
|
163
|
-
# head is frozen at start and never rewritten: task scope is the union of
|
|
164
|
-
# everything committed since head plus all working-tree changes.
|
|
165
|
-
# last_seen_head is observation-only (diagnostics), never scope input.
|
|
166
|
-
last_seen_head: Optional[str] = None
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
@dataclass(frozen=True)
|
|
170
|
-
class ActiveTask:
|
|
171
|
-
version: int
|
|
172
|
-
task_dir: str
|
|
173
|
-
task_id: str
|
|
174
|
-
status: str
|
|
175
|
-
context_sha256: str
|
|
176
|
-
baseline: ActiveBaseline
|
|
177
|
-
owned_paths: tuple[str, ...]
|
|
178
|
-
excluded_paths: tuple[str, ...]
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
@dataclass(frozen=True)
|
|
182
|
-
class DoctorResult:
|
|
183
|
-
action: str
|
|
184
|
-
active: ActiveTask
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def _now() -> str:
|
|
188
|
-
return datetime.now(timezone.utc).isoformat()
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def _atomic_text(target: Path, text: str) -> None:
|
|
192
|
-
temporary = ""
|
|
193
|
-
try:
|
|
194
|
-
with tempfile.NamedTemporaryFile(
|
|
195
|
-
"w",
|
|
196
|
-
encoding="utf-8",
|
|
197
|
-
dir=str(target.parent),
|
|
198
|
-
prefix=f".{target.name}.",
|
|
199
|
-
suffix=".tmp",
|
|
200
|
-
delete=False,
|
|
201
|
-
) as handle:
|
|
202
|
-
temporary = handle.name
|
|
203
|
-
handle.write(text)
|
|
204
|
-
handle.flush()
|
|
205
|
-
os.fsync(handle.fileno())
|
|
206
|
-
os.replace(temporary, target)
|
|
207
|
-
except OSError as exc:
|
|
208
|
-
if temporary and Path(temporary).exists():
|
|
209
|
-
Path(temporary).unlink()
|
|
210
|
-
raise ContextError("atomic_write_error", "file", str(exc), str(target)) from exc
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def _active_paths(root: str) -> tuple[Path, Path]:
|
|
214
|
-
flow = Path(root) / ".code-flow"
|
|
215
|
-
return flow / ".active-task.json", flow / ".active-task.lock"
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def _run_git(root: str, arguments: Sequence[str]) -> str:
|
|
219
|
-
try:
|
|
220
|
-
result = subprocess.run(
|
|
221
|
-
("git", *arguments), cwd=root, text=True, encoding="utf-8", capture_output=True, check=False
|
|
222
|
-
)
|
|
223
|
-
except OSError as exc:
|
|
224
|
-
raise ContextError("git_unavailable", "git", str(exc), root) from exc
|
|
225
|
-
if result.returncode != 0:
|
|
226
|
-
message = result.stderr.strip() or "Git command failed"
|
|
227
|
-
raise ContextError("git_error", "git", message, root)
|
|
228
|
-
return result.stdout
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def _git_head(root: str) -> Optional[str]:
|
|
232
|
-
try:
|
|
233
|
-
return _run_git(root, ("rev-parse", "HEAD")).strip()
|
|
234
|
-
except ContextError as exc:
|
|
235
|
-
if "unknown revision" in exc.message or "ambiguous argument" in exc.message:
|
|
236
|
-
return None
|
|
237
|
-
raise
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def _status_name(code: str) -> str:
|
|
241
|
-
if code == "??":
|
|
242
|
-
return "untracked"
|
|
243
|
-
if "D" in code:
|
|
244
|
-
return "deleted"
|
|
245
|
-
if "A" in code:
|
|
246
|
-
return "added"
|
|
247
|
-
if "M" in code:
|
|
248
|
-
return "modified"
|
|
249
|
-
return "changed"
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def _git_changes(root: str) -> Mapping[str, str]:
|
|
253
|
-
output = _run_git(root, ("status", "--porcelain=v1", "-z", "--untracked-files=all"))
|
|
254
|
-
records = output.split("\0")
|
|
255
|
-
changes: dict[str, str] = {}
|
|
256
|
-
index = 0
|
|
257
|
-
while index < len(records) and records[index]:
|
|
258
|
-
record = records[index]
|
|
259
|
-
if len(record) < 4:
|
|
260
|
-
raise ContextError("invalid_git_status", "git.status", record, root)
|
|
261
|
-
code, path = record[:2], record[3:]
|
|
262
|
-
index += 1
|
|
263
|
-
if "R" in code or "C" in code:
|
|
264
|
-
# porcelain -z rename/copy emits two records: "XY <new>" then "<old>"
|
|
265
|
-
if index >= len(records) or not records[index]:
|
|
266
|
-
raise ContextError("invalid_git_status", "git.status", f"{code} {path}", root)
|
|
267
|
-
old_path = records[index]
|
|
268
|
-
index += 1
|
|
269
|
-
changes[path] = _status_name(code)
|
|
270
|
-
changes[old_path] = "deleted"
|
|
271
|
-
continue
|
|
272
|
-
changes[path] = _status_name(code)
|
|
273
|
-
return changes
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def _is_excluded(path: str, patterns: Sequence[str]) -> bool:
|
|
277
|
-
return any(fnmatchcase(path, pattern) for pattern in patterns)
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
def _active_data(active: ActiveTask) -> dict[str, object]:
|
|
281
|
-
changes = {
|
|
282
|
-
path: {"status": item.status, "content_sha256": item.content_sha256}
|
|
283
|
-
for path, item in sorted(active.baseline.preexisting_changes.items())
|
|
284
|
-
}
|
|
285
|
-
return {
|
|
286
|
-
"version": active.version,
|
|
287
|
-
"task_dir": active.task_dir,
|
|
288
|
-
"task_id": active.task_id,
|
|
289
|
-
"status": active.status,
|
|
290
|
-
"context_sha256": active.context_sha256,
|
|
291
|
-
"baseline": {
|
|
292
|
-
"head": active.baseline.head,
|
|
293
|
-
"last_seen_head": active.baseline.last_seen_head,
|
|
294
|
-
"captured_at": active.baseline.captured_at,
|
|
295
|
-
"preexisting_changes": changes,
|
|
296
|
-
},
|
|
297
|
-
"owned_paths": list(active.owned_paths),
|
|
298
|
-
"excluded_paths": list(active.excluded_paths),
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
def _snapshot_from_data(value: object, path: str) -> PathSnapshot:
|
|
303
|
-
data = _mapping(value, "preexisting_changes[]", path)
|
|
304
|
-
content_hash = data.get("content_sha256")
|
|
305
|
-
return PathSnapshot(
|
|
306
|
-
_string(data.get("status"), "status", path),
|
|
307
|
-
content_hash if isinstance(content_hash, str) else "",
|
|
308
|
-
)
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
def _active_from_data(value: object, path: str) -> ActiveTask:
|
|
312
|
-
data = _mapping(value, "active", path)
|
|
313
|
-
baseline_data = _mapping(data.get("baseline"), "baseline", path)
|
|
314
|
-
raw_changes = _mapping(
|
|
315
|
-
baseline_data.get("preexisting_changes"), "baseline.preexisting_changes", path
|
|
316
|
-
)
|
|
317
|
-
version = data.get("version")
|
|
318
|
-
head = baseline_data.get("head")
|
|
319
|
-
if version != 1 or (head is not None and not isinstance(head, str)):
|
|
320
|
-
raise ContextError("invalid_active_marker", "version/baseline.head", "schema 不受支持", path)
|
|
321
|
-
status = _string(data.get("status"), "status", path)
|
|
322
|
-
if status not in ACTIVE_STATUSES:
|
|
323
|
-
raise ContextError("invalid_active_marker", "status", status, path)
|
|
324
|
-
baseline = ActiveBaseline(
|
|
325
|
-
head,
|
|
326
|
-
_string(baseline_data.get("captured_at"), "captured_at", path),
|
|
327
|
-
{key: _snapshot_from_data(item, path) for key, item in raw_changes.items()},
|
|
328
|
-
baseline_data.get("last_seen_head") if isinstance(baseline_data.get("last_seen_head"), str) else None,
|
|
329
|
-
)
|
|
330
|
-
return ActiveTask(
|
|
331
|
-
1,
|
|
332
|
-
_string(data.get("task_dir"), "task_dir", path),
|
|
333
|
-
_string(data.get("task_id"), "task_id", path),
|
|
334
|
-
status,
|
|
335
|
-
_string(data.get("context_sha256"), "context_sha256", path),
|
|
336
|
-
baseline,
|
|
337
|
-
tuple(_string(item, "owned_paths[]", path) for item in _sequence(data.get("owned_paths"), "owned_paths", path)),
|
|
338
|
-
tuple(_string(item, "excluded_paths[]", path) for item in _sequence(data.get("excluded_paths"), "excluded_paths", path)),
|
|
339
|
-
)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def save_active_task(root: str, active: ActiveTask) -> None:
|
|
343
|
-
marker, unused_lock = _active_paths(root)
|
|
344
|
-
del unused_lock
|
|
345
|
-
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
346
|
-
_atomic_text(marker, json.dumps(_active_data(active), ensure_ascii=False, indent=2) + "\n")
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
def load_active_task(root: str) -> ActiveTask:
|
|
350
|
-
marker, unused_lock = _active_paths(root)
|
|
351
|
-
del unused_lock
|
|
352
|
-
try:
|
|
353
|
-
value = json.loads(marker.read_text(encoding="utf-8"))
|
|
354
|
-
return _active_from_data(value, str(marker))
|
|
355
|
-
except ContextError:
|
|
356
|
-
raise
|
|
357
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
358
|
-
raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
def task_contract_digest(root: str, task_dir: str, task_id: str) -> str:
|
|
362
|
-
"""Digest of the active TASK's acceptance contract (Refs + Contract + Evidence).
|
|
363
|
-
|
|
364
|
-
Two TASKs may share one Context hash while owning different scenarios; the
|
|
365
|
-
digest separates their injection versions so switching TASKs re-injects.
|
|
366
|
-
Returns "" when the TASK section cannot be located (fail-open to the
|
|
367
|
-
legacy context-hash-only key rather than crashing the hook hot path).
|
|
368
|
-
"""
|
|
369
|
-
try:
|
|
370
|
-
files = sorted((Path(root) / task_dir).glob("*.md"))
|
|
371
|
-
except OSError:
|
|
372
|
-
return ""
|
|
373
|
-
for path in files:
|
|
374
|
-
if path.name.endswith((".prd.md", ".design.md")):
|
|
375
|
-
continue
|
|
376
|
-
try:
|
|
377
|
-
text = path.read_text(encoding="utf-8")
|
|
378
|
-
except (OSError, UnicodeError):
|
|
379
|
-
continue
|
|
380
|
-
start = text.find(f"## {task_id}:")
|
|
381
|
-
if start == -1:
|
|
382
|
-
continue
|
|
383
|
-
end = text.find("\n## TASK-", start)
|
|
384
|
-
section = text[start:] if end == -1 else text[start:end]
|
|
385
|
-
chunks: list[str] = []
|
|
386
|
-
for heading in ("Acceptance-Refs", "Acceptance Contract", "Acceptance Evidence"):
|
|
387
|
-
index = section.find(heading)
|
|
388
|
-
if index == -1:
|
|
389
|
-
chunks.append(f"{heading}:missing")
|
|
390
|
-
continue
|
|
391
|
-
end = section.find("\n### ", index)
|
|
392
|
-
chunks.append(section[index:] if end == -1 else section[index:end])
|
|
393
|
-
return hashlib.sha256("\n".join(chunks).encode("utf-8")).hexdigest()
|
|
394
|
-
return ""
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
def injection_version(root: str, session_id: str, context_sha: str) -> str:
|
|
398
|
-
"""Version key for hook injection dedup.
|
|
399
|
-
|
|
400
|
-
Covers session + task_dir + task_id + context hash + TASK contract digest,
|
|
401
|
-
so a TASK switch (or an in-place contract edit) always re-injects while a
|
|
402
|
-
repeated prompt on the same contract stays suppressed.
|
|
403
|
-
"""
|
|
404
|
-
try:
|
|
405
|
-
active = load_active_task(root)
|
|
406
|
-
except (ContextError, OSError, ValueError):
|
|
407
|
-
return f"none:{session_id}:{context_sha}"
|
|
408
|
-
digest = task_contract_digest(root, active.task_dir, active.task_id)
|
|
409
|
-
raw = "\n".join((session_id, active.task_dir, active.task_id, context_sha, digest))
|
|
410
|
-
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
def resync_active_hash(root: str, task_dir: str, current_sha256: str) -> bool:
|
|
414
|
-
"""Re-sync the active marker's context hash after toolchain-initiated
|
|
415
|
-
context edits (bind/decision/refresh) so they never surface as
|
|
416
|
-
unrecoverable active_context_drift. No-op without an active marker or when
|
|
417
|
-
the hash already matches; never raises on a missing marker."""
|
|
418
|
-
marker = Path(root) / ".code-flow" / ".active-task.json"
|
|
419
|
-
if not marker.exists():
|
|
420
|
-
return False
|
|
421
|
-
active = load_active_task(root)
|
|
422
|
-
if active.context_sha256 == current_sha256:
|
|
423
|
-
return False
|
|
424
|
-
save_active_task(root, replace(active, context_sha256=current_sha256))
|
|
425
|
-
return True
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
def _find_root(task_dir: str) -> Optional[str]:
|
|
429
|
-
"""Walk up from a task dir to the project root that owns its marker."""
|
|
430
|
-
current = Path(task_dir).resolve()
|
|
431
|
-
for candidate in (current, *current.parents):
|
|
432
|
-
if (candidate / ".code-flow" / ".active-task.json").exists():
|
|
433
|
-
return str(candidate)
|
|
434
|
-
return None
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
def _resync_after_save(args: argparse.Namespace, context_path: Path) -> bool:
|
|
438
|
-
"""Post-save marker re-sync used by CLI commands that persist the context."""
|
|
439
|
-
root = getattr(args, "root", "") or _find_root(str(context_path.parent)) or ""
|
|
440
|
-
if not root:
|
|
441
|
-
return False
|
|
442
|
-
current = context_sha256(load_context(str(context_path)))
|
|
443
|
-
return resync_active_hash(root, str(context_path.parent), current)
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
def _lock_stale(lock: Path) -> bool:
|
|
447
|
-
"""A lock is stale when its file is unreadable, carries no valid PID, or the
|
|
448
|
-
owning process is gone. A lock we cannot prove dead is never deleted (PID
|
|
449
|
-
reuse is accepted as a rare race)."""
|
|
450
|
-
try:
|
|
451
|
-
data = json.loads(lock.read_text(encoding="utf-8"))
|
|
452
|
-
except (OSError, json.JSONDecodeError):
|
|
453
|
-
return True
|
|
454
|
-
pid = data.get("pid")
|
|
455
|
-
if not isinstance(pid, int) or pid <= 0:
|
|
456
|
-
return True
|
|
457
|
-
try:
|
|
458
|
-
os.kill(pid, 0)
|
|
459
|
-
except ProcessLookupError:
|
|
460
|
-
return True
|
|
461
|
-
except (PermissionError, OSError):
|
|
462
|
-
return False
|
|
463
|
-
return False
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
def _acquire_active_lock(root: str) -> Path:
|
|
467
|
-
marker, lock = _active_paths(root)
|
|
468
|
-
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
469
|
-
descriptor = -1
|
|
470
|
-
for _attempt in range(2):
|
|
471
|
-
try:
|
|
472
|
-
descriptor = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
473
|
-
break
|
|
474
|
-
except FileExistsError:
|
|
475
|
-
if not _lock_stale(lock):
|
|
476
|
-
raise ContextError("active_lock_exists", "active.lock", "运行 doctor 检查残留 lock", str(lock)) from None
|
|
477
|
-
try:
|
|
478
|
-
lock.unlink()
|
|
479
|
-
except OSError as exc:
|
|
480
|
-
raise ContextError("active_lock_exists", "active.lock", f"残留 lock 清理失败: {exc}", str(lock)) from exc
|
|
481
|
-
else:
|
|
482
|
-
raise ContextError("active_lock_exists", "active.lock", "残留 lock 无法清理", str(lock))
|
|
483
|
-
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
484
|
-
handle.write(json.dumps({"pid": os.getpid(), "marker": str(marker)}))
|
|
485
|
-
return lock
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
def _release_active_lock(lock: Path) -> None:
|
|
489
|
-
try:
|
|
490
|
-
lock.unlink()
|
|
491
|
-
except FileNotFoundError:
|
|
492
|
-
return
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
def _committed_since(root: str, base_head: Optional[str]) -> Mapping[str, str]:
|
|
496
|
-
"""Paths committed between the frozen baseline head and current HEAD.
|
|
497
|
-
|
|
498
|
-
Returns {} when there is no baseline (pre-commit repo) or the range is
|
|
499
|
-
unresolvable (rewritten/pruned history) — working-tree changes still
|
|
500
|
-
flow through _business_changes, so scope never goes fully blind.
|
|
501
|
-
"""
|
|
502
|
-
if not base_head:
|
|
503
|
-
return {}
|
|
504
|
-
try:
|
|
505
|
-
output = _run_git(root, ("diff", "--name-status", "-z", base_head, "HEAD", "--"))
|
|
506
|
-
except ContextError:
|
|
507
|
-
return {}
|
|
508
|
-
names: dict[str, str] = {}
|
|
509
|
-
tokens = output.split("\0")
|
|
510
|
-
index = 0
|
|
511
|
-
while index < len(tokens) and tokens[index]:
|
|
512
|
-
status, index = tokens[index], index + 1
|
|
513
|
-
code = status[:1]
|
|
514
|
-
if code in ("R", "C"):
|
|
515
|
-
if index + 1 >= len(tokens):
|
|
516
|
-
break
|
|
517
|
-
new_path, old_path = tokens[index], tokens[index + 1]
|
|
518
|
-
index += 2
|
|
519
|
-
if new_path:
|
|
520
|
-
names[new_path] = "renamed"
|
|
521
|
-
if old_path:
|
|
522
|
-
names[old_path] = "deleted"
|
|
523
|
-
continue
|
|
524
|
-
if index >= len(tokens) or not tokens[index]:
|
|
525
|
-
break
|
|
526
|
-
names[tokens[index]] = {"M": "modified", "A": "added", "D": "deleted"}.get(code, "changed")
|
|
527
|
-
index += 1
|
|
528
|
-
return names
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
def _business_changes(root: str, excludes: Sequence[str]) -> Mapping[str, str]:
|
|
532
|
-
return {
|
|
533
|
-
path: status
|
|
534
|
-
for path, status in _git_changes(root).items()
|
|
535
|
-
if not _is_excluded(path, excludes)
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
def _new_active(
|
|
540
|
-
root: str,
|
|
541
|
-
task_dir: str,
|
|
542
|
-
task_id: str,
|
|
543
|
-
context_sha256: str,
|
|
544
|
-
owned_preexisting: Sequence[str],
|
|
545
|
-
) -> ActiveTask:
|
|
546
|
-
changes = _business_changes(root, DEFAULT_ACTIVE_EXCLUDES)
|
|
547
|
-
owned = tuple(sorted(set(owned_preexisting)))
|
|
548
|
-
unknown = tuple(path for path in owned if path not in changes)
|
|
549
|
-
unowned = tuple(path for path in changes if path not in owned)
|
|
550
|
-
if unknown:
|
|
551
|
-
raise ContextError("unknown_owned_path", "owned_paths", ", ".join(unknown), root)
|
|
552
|
-
if unowned:
|
|
553
|
-
raise ContextError("unowned_changes", "owned_paths", ", ".join(unowned), root)
|
|
554
|
-
# content_sha256 was a per-file hash but is never consumed by any gate (only
|
|
555
|
-
# baseline.head is compared); keep the schema field empty so start never hashes
|
|
556
|
-
# every uncommitted file.
|
|
557
|
-
snapshots = {
|
|
558
|
-
path: PathSnapshot(status, "") for path, status in changes.items()
|
|
559
|
-
}
|
|
560
|
-
head = _git_head(root)
|
|
561
|
-
baseline = ActiveBaseline(head, _now(), snapshots, head)
|
|
562
|
-
return ActiveTask(
|
|
563
|
-
1, task_dir, task_id, "activating", context_sha256, baseline, owned, DEFAULT_ACTIVE_EXCLUDES
|
|
564
|
-
)
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
def start_active_task(
|
|
568
|
-
root: str,
|
|
569
|
-
task_dir: str,
|
|
570
|
-
task_id: str,
|
|
571
|
-
context_sha256: str,
|
|
572
|
-
owned_preexisting: Sequence[str] = (),
|
|
573
|
-
) -> ActiveTask:
|
|
574
|
-
marker, unused_lock = _active_paths(root)
|
|
575
|
-
del unused_lock
|
|
576
|
-
lock = _acquire_active_lock(root)
|
|
577
|
-
try:
|
|
578
|
-
if marker.exists():
|
|
579
|
-
raise ContextError("active_exists", "active", "当前 worktree 已有 active TASK", str(marker))
|
|
580
|
-
active = _new_active(root, task_dir, task_id, context_sha256, owned_preexisting)
|
|
581
|
-
save_active_task(root, active)
|
|
582
|
-
active = replace(active, status="active")
|
|
583
|
-
save_active_task(root, active)
|
|
584
|
-
return active
|
|
585
|
-
finally:
|
|
586
|
-
_release_active_lock(lock)
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
def _transition_active(root: str, expected: Sequence[str], target: str) -> ActiveTask:
|
|
590
|
-
lock = _acquire_active_lock(root)
|
|
591
|
-
try:
|
|
592
|
-
active = load_active_task(root)
|
|
593
|
-
if active.status not in expected:
|
|
594
|
-
raise ContextError("invalid_active_transition", "status", f"{active.status} -> {target}", root)
|
|
595
|
-
current_head = _git_head(root)
|
|
596
|
-
if active.baseline.head != current_head:
|
|
597
|
-
# Mid-task commits are a normal workflow. The baseline head stays
|
|
598
|
-
# frozen so committed work never drops out of scope; only the
|
|
599
|
-
# observation field advances for diagnostics.
|
|
600
|
-
active = replace(active, baseline=replace(active.baseline, last_seen_head=current_head))
|
|
601
|
-
updated = replace(active, status=target)
|
|
602
|
-
save_active_task(root, updated)
|
|
603
|
-
return updated
|
|
604
|
-
finally:
|
|
605
|
-
_release_active_lock(lock)
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
def pause_active_task(root: str) -> ActiveTask:
|
|
609
|
-
return _transition_active(root, ("active",), "paused")
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
def block_active_task(root: str) -> ActiveTask:
|
|
613
|
-
return _transition_active(root, ("active",), "blocked")
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
def resume_active_task(root: str) -> ActiveTask:
|
|
617
|
-
return _transition_active(root, ("paused", "blocked"), "active")
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
def current_owned_paths(root: str, active: ActiveTask) -> tuple[str, ...]:
|
|
621
|
-
changes = _business_changes(root, active.excluded_paths)
|
|
622
|
-
committed = {
|
|
623
|
-
path: status
|
|
624
|
-
for path, status in _committed_since(root, active.baseline.head).items()
|
|
625
|
-
if not _is_excluded(path, active.excluded_paths)
|
|
626
|
-
}
|
|
627
|
-
return tuple(sorted(set(active.owned_paths).union(changes).union(committed)))
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
|
|
631
|
-
if not gate_passed:
|
|
632
|
-
raise ContextError("gate_failed", "gate", "Done Gate 未通过", root)
|
|
633
|
-
completed = _transition_active(root, ("active",), "completed")
|
|
634
|
-
marker, unused_lock = _active_paths(root)
|
|
635
|
-
del unused_lock
|
|
636
|
-
try:
|
|
637
|
-
marker.unlink()
|
|
638
|
-
except OSError as exc:
|
|
639
|
-
raise ContextError("active_cleanup_failed", "active", str(exc), str(marker)) from exc
|
|
640
|
-
return completed
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
def doctor_active_task(
|
|
644
|
-
root: str, expected_context_sha256: str, abandon: bool = False, resync: bool = False
|
|
645
|
-
) -> DoctorResult:
|
|
646
|
-
marker, lock = _active_paths(root)
|
|
647
|
-
try:
|
|
648
|
-
active = load_active_task(root)
|
|
649
|
-
except ContextError as exc:
|
|
650
|
-
raise ContextError("recovery_required", "active", exc.message, str(marker)) from exc
|
|
651
|
-
if abandon:
|
|
652
|
-
marker.unlink()
|
|
653
|
-
_release_active_lock(lock)
|
|
654
|
-
return DoctorResult("abandoned", replace(active, status="completed"))
|
|
655
|
-
if resync:
|
|
656
|
-
# Self-heal: the context file is authoritative and the marker hash is a
|
|
657
|
-
# cache. Accept the current context when the marker is the only
|
|
658
|
-
# inconsistency; bind/decision/refresh already re-sync automatically,
|
|
659
|
-
# so this covers legacy markers and manual context edits.
|
|
660
|
-
repaired = replace(active, status="active", context_sha256=expected_context_sha256)
|
|
661
|
-
save_active_task(root, repaired)
|
|
662
|
-
_release_active_lock(lock)
|
|
663
|
-
return DoctorResult("resynced", repaired)
|
|
664
|
-
proven = active.context_sha256 == expected_context_sha256
|
|
665
|
-
proven = proven and active.baseline.head == _git_head(root)
|
|
666
|
-
if active.status != "activating" or not proven:
|
|
667
|
-
raise ContextError("recovery_required", "active", "状态或 hash 无法证明可自动恢复", str(marker))
|
|
668
|
-
repaired = replace(active, status="active")
|
|
669
|
-
save_active_task(root, repaired)
|
|
670
|
-
_release_active_lock(lock)
|
|
671
|
-
return DoctorResult("resumed", repaired)
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
def _mapping(value: object, field: str, path: str) -> Mapping[str, object]:
|
|
675
|
-
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
|
676
|
-
raise ContextError("invalid_context", field, "必须是字符串 key mapping", path)
|
|
677
|
-
return value
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
def _sequence(value: object, field: str, path: str) -> Sequence[object]:
|
|
681
|
-
if not isinstance(value, list):
|
|
682
|
-
raise ContextError("invalid_context", field, "必须是列表", path)
|
|
683
|
-
return value
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
def _string(value: object, field: str, path: str) -> str:
|
|
687
|
-
if not isinstance(value, str) or not value.strip():
|
|
688
|
-
raise ContextError("invalid_context", field, "必须是非空字符串", path)
|
|
689
|
-
return value.strip()
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
def _decision_string(value: object, field: str, path: str) -> str:
|
|
693
|
-
if not isinstance(value, str) or not value.strip():
|
|
694
|
-
raise ContextError("invalid_decision", field, "必须是非空字符串", path)
|
|
695
|
-
return value.strip()
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
def _timestamp(value: str, field: str) -> None:
|
|
699
|
-
try:
|
|
700
|
-
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
701
|
-
except ValueError as exc:
|
|
702
|
-
raise ContextError("invalid_decision", field, "必须是 RFC3339 时间") from exc
|
|
703
|
-
if parsed.tzinfo is None:
|
|
704
|
-
raise ContextError("invalid_decision", field, "必须包含时区")
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
def _validate_decision(decision: Decision) -> None:
|
|
708
|
-
if decision.kind not in DECISION_KINDS:
|
|
709
|
-
raise ContextError("invalid_decision", "decision.kind", f"不支持 {decision.kind!r}")
|
|
710
|
-
for field in ("reason", "confirmed_by", "confirmed_at", "source"):
|
|
711
|
-
if not getattr(decision, field).strip():
|
|
712
|
-
raise ContextError("invalid_decision", f"decision.{field}", "必须是非空字符串")
|
|
713
|
-
identity = decision.confirmed_by.lower().split(":", 1)[0]
|
|
714
|
-
if identity in _AGENT_IDENTITIES:
|
|
715
|
-
raise ContextError("agent_confirmation_forbidden", "decision.confirmed_by", "Agent 不能自行确认")
|
|
716
|
-
_timestamp(decision.confirmed_at, "decision.confirmed_at")
|
|
717
|
-
if decision.kind == "waived" and not decision.expires_at:
|
|
718
|
-
raise ContextError("invalid_decision", "decision.expires_at", "waived 必须有失效时间")
|
|
719
|
-
if decision.expires_at:
|
|
720
|
-
_timestamp(decision.expires_at, "decision.expires_at")
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
def _decision_from_data(value: object, path: str) -> Optional[Decision]:
|
|
724
|
-
if value is None:
|
|
725
|
-
return None
|
|
726
|
-
data = _mapping(value, "decision", path)
|
|
727
|
-
expires = data.get("expires_at")
|
|
728
|
-
if expires is not None and not isinstance(expires, str):
|
|
729
|
-
raise ContextError("invalid_decision", "decision.expires_at", "必须是字符串或 null", path)
|
|
730
|
-
decision = Decision(
|
|
731
|
-
_decision_string(data.get("kind"), "decision.kind", path),
|
|
732
|
-
_decision_string(data.get("reason"), "decision.reason", path),
|
|
733
|
-
_decision_string(data.get("confirmed_by"), "decision.confirmed_by", path),
|
|
734
|
-
_decision_string(data.get("confirmed_at"), "decision.confirmed_at", path),
|
|
735
|
-
_decision_string(data.get("source"), "decision.source", path),
|
|
736
|
-
expires,
|
|
737
|
-
)
|
|
738
|
-
_validate_decision(decision)
|
|
739
|
-
return decision
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
def _decision_data(decision: Optional[Decision]) -> Optional[dict[str, object]]:
|
|
743
|
-
if decision is None:
|
|
744
|
-
return None
|
|
745
|
-
return {
|
|
746
|
-
"kind": decision.kind,
|
|
747
|
-
"reason": decision.reason,
|
|
748
|
-
"confirmed_by": decision.confirmed_by,
|
|
749
|
-
"confirmed_at": decision.confirmed_at,
|
|
750
|
-
"source": decision.source,
|
|
751
|
-
"expires_at": decision.expires_at,
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
def _artifact_from_data(value: object, path: str) -> ArtifactRef:
|
|
756
|
-
data = _mapping(value, "refs[]", path)
|
|
757
|
-
return ArtifactRef(
|
|
758
|
-
_string(data.get("artifact"), "refs[].artifact", path),
|
|
759
|
-
_string(data.get("section_id"), "refs[].section_id", path),
|
|
760
|
-
_string(data.get("item_id"), "refs[].item_id", path),
|
|
761
|
-
_string(data.get("artifact_sha256"), "refs[].artifact_sha256", path),
|
|
762
|
-
)
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
def _artifact_data(reference: ArtifactRef) -> dict[str, str]:
|
|
766
|
-
return {
|
|
767
|
-
"artifact": reference.artifact,
|
|
768
|
-
"section_id": reference.section_id,
|
|
769
|
-
"item_id": reference.item_id,
|
|
770
|
-
"artifact_sha256": reference.artifact_sha256,
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
def _stage_from_data(value: object, field: str, path: str) -> RuleStageStatus:
|
|
775
|
-
data = _mapping(value, field, path)
|
|
776
|
-
status = _string(data.get("status"), f"{field}.status", path)
|
|
777
|
-
if status not in STAGE_STATUSES:
|
|
778
|
-
raise ContextError("invalid_context", f"{field}.status", f"不支持 {status!r}", path)
|
|
779
|
-
refs = tuple(_artifact_from_data(item, path) for item in _sequence(data.get("refs", []), f"{field}.refs", path))
|
|
780
|
-
evidence = tuple(_mapping(item, f"{field}.evidence[]", path) for item in _sequence(data.get("evidence", []), f"{field}.evidence", path))
|
|
781
|
-
decision = _decision_from_data(data.get("decision"), path)
|
|
782
|
-
if status == "applied" and not refs:
|
|
783
|
-
raise ContextError("invalid_context", f"{field}.refs", "applied 必须有 artifact ref", path)
|
|
784
|
-
if status in ("not_applicable", "waived") and decision is None:
|
|
785
|
-
raise ContextError("invalid_context", f"{field}.decision", f"{status} 必须有确认记录", path)
|
|
786
|
-
return RuleStageStatus(status, refs, decision, evidence)
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
def _stage_data(status: RuleStageStatus) -> dict[str, object]:
|
|
790
|
-
return {
|
|
791
|
-
"status": status.status,
|
|
792
|
-
"refs": [_artifact_data(item) for item in status.refs],
|
|
793
|
-
"decision": _decision_data(status.decision),
|
|
794
|
-
"evidence": [dict(item) for item in status.evidence],
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
def _rule_from_data(value: object, path: str) -> RuleBinding:
|
|
799
|
-
data = _mapping(value, "rules[]", path)
|
|
800
|
-
stages = _mapping(data.get("stage_status"), "rules[].stage_status", path)
|
|
801
|
-
parsed = {stage: _stage_from_data(item, f"stage_status.{stage}", path) for stage, item in stages.items()}
|
|
802
|
-
return RuleBinding(
|
|
803
|
-
_string(data.get("ref"), "rules[].ref", path),
|
|
804
|
-
_string(data.get("summary"), "rules[].summary", path),
|
|
805
|
-
_string(data.get("text_sha256"), "rules[].text_sha256", path),
|
|
806
|
-
_string(data.get("enforcement"), "rules[].enforcement", path),
|
|
807
|
-
_string(data.get("verifier_ref"), "rules[].verifier_ref", path),
|
|
808
|
-
parsed,
|
|
809
|
-
)
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
def _rule_data(rule: RuleBinding) -> dict[str, object]:
|
|
813
|
-
return {
|
|
814
|
-
"ref": rule.ref,
|
|
815
|
-
"summary": rule.summary,
|
|
816
|
-
"text_sha256": rule.text_sha256,
|
|
817
|
-
"enforcement": rule.enforcement,
|
|
818
|
-
"verifier_ref": rule.verifier_ref,
|
|
819
|
-
"stage_status": {stage: _stage_data(status) for stage, status in rule.stage_status.items()},
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
def _hashes_from_data(value: object, path: str) -> SpecHashes:
|
|
824
|
-
data = _mapping(value, "hashes", path)
|
|
825
|
-
return SpecHashes(
|
|
826
|
-
_string(data.get("file_sha256"), "hashes.file_sha256", path),
|
|
827
|
-
_string(data.get("metadata_sha256"), "hashes.metadata_sha256", path),
|
|
828
|
-
_string(data.get("rules_sha256"), "hashes.rules_sha256", path),
|
|
829
|
-
)
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
def _binding_from_data(value: object, path: str) -> SpecBinding:
|
|
833
|
-
data = _mapping(value, "bindings[]", path)
|
|
834
|
-
stages = tuple(_string(item, "bindings[].stages[]", path) for item in _sequence(data.get("stages"), "bindings[].stages", path))
|
|
835
|
-
rules = tuple(_rule_from_data(item, path) for item in _sequence(data.get("rules"), "bindings[].rules", path))
|
|
836
|
-
return SpecBinding(
|
|
837
|
-
_string(data.get("spec_id"), "bindings[].spec_id", path),
|
|
838
|
-
_string(data.get("path"), "bindings[].path", path),
|
|
839
|
-
_hashes_from_data(data.get("hashes"), path),
|
|
840
|
-
_string(data.get("selected_by"), "bindings[].selected_by", path),
|
|
841
|
-
_string(data.get("reason"), "bindings[].reason", path),
|
|
842
|
-
_string(data.get("enforcement"), "bindings[].enforcement", path),
|
|
843
|
-
stages,
|
|
844
|
-
rules,
|
|
845
|
-
_string(data.get("status", "active"), "bindings[].status", path),
|
|
846
|
-
)
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
def _binding_data(binding: SpecBinding) -> dict[str, object]:
|
|
850
|
-
return {
|
|
851
|
-
"spec_id": binding.spec_id,
|
|
852
|
-
"path": binding.path,
|
|
853
|
-
"status": binding.status,
|
|
854
|
-
"hashes": {
|
|
855
|
-
"file_sha256": binding.hashes.file_sha256,
|
|
856
|
-
"metadata_sha256": binding.hashes.metadata_sha256,
|
|
857
|
-
"rules_sha256": binding.hashes.rules_sha256,
|
|
858
|
-
},
|
|
859
|
-
"selected_by": binding.selected_by,
|
|
860
|
-
"reason": binding.reason,
|
|
861
|
-
"enforcement": binding.enforcement,
|
|
862
|
-
"stages": list(binding.stages),
|
|
863
|
-
"rules": [_rule_data(rule) for rule in binding.rules],
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
def context_to_data(context: SpecContext) -> dict[str, object]:
|
|
868
|
-
return {
|
|
869
|
-
"version": context.version,
|
|
870
|
-
"task": context.task,
|
|
871
|
-
"enforcement": context.enforcement,
|
|
872
|
-
"updated_at": context.updated_at,
|
|
873
|
-
"sources": [{"type": item.type, "ref": item.ref} for item in context.sources],
|
|
874
|
-
"bindings": [_binding_data(binding) for binding in context.bindings],
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
def context_to_identity(context: SpecContext) -> dict[str, object]:
|
|
879
|
-
"""Stable identity projection for marker drift detection.
|
|
880
|
-
|
|
881
|
-
Excludes volatile runtime state (`updated_at`, `stage_status` including
|
|
882
|
-
evidence timestamps) so the active marker only invalidates when the bound
|
|
883
|
-
rules themselves change (rule/spec text, bindings, sources).
|
|
884
|
-
"""
|
|
885
|
-
return {
|
|
886
|
-
"version": context.version,
|
|
887
|
-
"task": context.task,
|
|
888
|
-
"enforcement": context.enforcement,
|
|
889
|
-
"sources": [{"type": item.type, "ref": item.ref} for item in context.sources],
|
|
890
|
-
"bindings": [
|
|
891
|
-
{
|
|
892
|
-
"spec_id": binding.spec_id,
|
|
893
|
-
"path": binding.path,
|
|
894
|
-
"status": binding.status,
|
|
895
|
-
"hashes": {
|
|
896
|
-
"file_sha256": binding.hashes.file_sha256,
|
|
897
|
-
"metadata_sha256": binding.hashes.metadata_sha256,
|
|
898
|
-
"rules_sha256": binding.hashes.rules_sha256,
|
|
899
|
-
},
|
|
900
|
-
"selected_by": binding.selected_by,
|
|
901
|
-
"reason": binding.reason,
|
|
902
|
-
"enforcement": binding.enforcement,
|
|
903
|
-
"stages": list(binding.stages),
|
|
904
|
-
"rules": [
|
|
905
|
-
{
|
|
906
|
-
"ref": rule.ref,
|
|
907
|
-
"summary": rule.summary,
|
|
908
|
-
"text_sha256": rule.text_sha256,
|
|
909
|
-
"enforcement": rule.enforcement,
|
|
910
|
-
"verifier_ref": rule.verifier_ref,
|
|
911
|
-
}
|
|
912
|
-
for rule in binding.rules
|
|
913
|
-
],
|
|
914
|
-
}
|
|
915
|
-
for binding in context.bindings
|
|
916
|
-
],
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
def context_sha256(context: SpecContext) -> str:
|
|
921
|
-
"""Deterministic identity hash binding the active marker to the bound rules.
|
|
922
|
-
|
|
923
|
-
Computed from the stable identity projection (never volatile runtime state),
|
|
924
|
-
so evidence timestamps and stage statuses cannot drift the marker.
|
|
925
|
-
"""
|
|
926
|
-
import yaml
|
|
927
|
-
data = yaml.safe_dump(context_to_identity(context), sort_keys=True, allow_unicode=True).encode()
|
|
928
|
-
return hashlib.sha256(data).hexdigest()
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
def new_context(task: str, sources: Sequence[tuple[str, str]]) -> SpecContext:
|
|
932
|
-
if not task.strip():
|
|
933
|
-
raise ContextError("invalid_context", "task", "必须是非空字符串")
|
|
934
|
-
parsed = tuple(ContextSource(_string(kind, "sources[].type", ""), _string(ref, "sources[].ref", "")) for kind, ref in sources)
|
|
935
|
-
return SpecContext(CONTEXT_VERSION, task.strip(), "required", _now(), parsed, ())
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
def load_context(path: str) -> SpecContext:
|
|
939
|
-
import yaml
|
|
940
|
-
try:
|
|
941
|
-
loaded = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
|
942
|
-
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
943
|
-
raise ContextError("context_read_error", "file", str(exc), path) from exc
|
|
944
|
-
data = _mapping(loaded, "context", path)
|
|
945
|
-
version = data.get("version")
|
|
946
|
-
if version != CONTEXT_VERSION:
|
|
947
|
-
raise ContextError("unsupported_version", "version", f"仅支持 {CONTEXT_VERSION},收到 {version!r}", path)
|
|
948
|
-
source_values = _sequence(data.get("sources", []), "sources", path)
|
|
949
|
-
sources = tuple(
|
|
950
|
-
ContextSource(
|
|
951
|
-
_string(_mapping(item, "sources[]", path).get("type"), "sources[].type", path),
|
|
952
|
-
_string(_mapping(item, "sources[]", path).get("ref"), "sources[].ref", path),
|
|
953
|
-
)
|
|
954
|
-
for item in source_values
|
|
955
|
-
)
|
|
956
|
-
bindings = tuple(_binding_from_data(item, path) for item in _sequence(data.get("bindings", []), "bindings", path))
|
|
957
|
-
return SpecContext(
|
|
958
|
-
CONTEXT_VERSION,
|
|
959
|
-
_string(data.get("task"), "task", path),
|
|
960
|
-
_string(data.get("enforcement"), "enforcement", path),
|
|
961
|
-
_string(data.get("updated_at"), "updated_at", path),
|
|
962
|
-
sources,
|
|
963
|
-
bindings,
|
|
964
|
-
)
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
def save_context(path: str, context: SpecContext) -> None:
|
|
968
|
-
import yaml
|
|
969
|
-
target = Path(path)
|
|
970
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
971
|
-
text = yaml.safe_dump(context_to_data(context), sort_keys=False, allow_unicode=True)
|
|
972
|
-
try:
|
|
973
|
-
_atomic_text(target, text)
|
|
974
|
-
except ContextError as exc:
|
|
975
|
-
raise ContextError("context_write_error", "file", str(exc), path) from exc
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
def _binding_from_input(selection: BindingInput) -> SpecBinding:
|
|
979
|
-
if not selection.selected_by.strip() or not selection.reason.strip():
|
|
980
|
-
raise ContextError("invalid_binding", "selection", "selected_by/reason 必须非空")
|
|
981
|
-
candidate = selection.candidate
|
|
982
|
-
verifier_by_rule = {item.rule: item for item in candidate.metadata.verifiers}
|
|
983
|
-
rules: list[RuleBinding] = []
|
|
984
|
-
for rule in candidate.metadata.rules:
|
|
985
|
-
if rule.enforcement == "informational":
|
|
986
|
-
continue
|
|
987
|
-
enforcement = "advisory" if candidate.metadata.enforcement == "advisory" else rule.enforcement
|
|
988
|
-
statuses = {
|
|
989
|
-
stage: RuleStageStatus("pending", (), None, ()) for stage in candidate.metadata.stages
|
|
990
|
-
}
|
|
991
|
-
verifier = verifier_by_rule.get(rule.ref)
|
|
992
|
-
verifier_ref = f"{candidate.spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
|
|
993
|
-
rules.append(RuleBinding(rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses))
|
|
994
|
-
return SpecBinding(
|
|
995
|
-
candidate.spec_id,
|
|
996
|
-
candidate.path,
|
|
997
|
-
candidate.metadata.hashes,
|
|
998
|
-
selection.selected_by.strip(),
|
|
999
|
-
selection.reason.strip(),
|
|
1000
|
-
candidate.metadata.enforcement,
|
|
1001
|
-
candidate.metadata.stages,
|
|
1002
|
-
tuple(rules),
|
|
1003
|
-
)
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
def _rule_from_metadata(spec_id: str, metadata: SpecMetadata, rule: SpecRule) -> RuleBinding:
|
|
1007
|
-
enforcement = "advisory" if metadata.enforcement == "advisory" else rule.enforcement
|
|
1008
|
-
statuses = {stage: RuleStageStatus("pending", (), None, ()) for stage in metadata.stages}
|
|
1009
|
-
has_verifier = any(item.rule == rule.ref for item in metadata.verifiers)
|
|
1010
|
-
verifier_ref = f"{spec_id}#{rule.ref}" if has_verifier else "advisory:none"
|
|
1011
|
-
return RuleBinding(rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses)
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
def bind_specs(context: SpecContext, selections: Sequence[BindingInput]) -> SpecContext:
|
|
1015
|
-
bindings = {binding.spec_id: binding for binding in context.bindings}
|
|
1016
|
-
for selection in selections:
|
|
1017
|
-
incoming = _binding_from_input(selection)
|
|
1018
|
-
existing = bindings.get(incoming.spec_id)
|
|
1019
|
-
if existing is not None and existing.hashes != incoming.hashes:
|
|
1020
|
-
raise ContextError(
|
|
1021
|
-
"stale_binding",
|
|
1022
|
-
"selection",
|
|
1023
|
-
f"{incoming.spec_id} 已绑定版本发生变化,请先 refresh",
|
|
1024
|
-
)
|
|
1025
|
-
if existing is not None:
|
|
1026
|
-
incoming = replace(
|
|
1027
|
-
existing,
|
|
1028
|
-
selected_by=incoming.selected_by,
|
|
1029
|
-
reason=incoming.reason,
|
|
1030
|
-
enforcement=incoming.enforcement,
|
|
1031
|
-
stages=incoming.stages,
|
|
1032
|
-
)
|
|
1033
|
-
bindings[incoming.spec_id] = incoming
|
|
1034
|
-
ordered = tuple(bindings[key] for key in sorted(bindings))
|
|
1035
|
-
if ordered == context.bindings:
|
|
1036
|
-
return context
|
|
1037
|
-
return replace(context, updated_at=_now(), bindings=ordered)
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
def refresh_missing_specs(context: SpecContext, root: str) -> SpecContext:
|
|
1041
|
-
spec_root = Path(root) / ".code-flow" / "specs"
|
|
1042
|
-
bindings: list[SpecBinding] = []
|
|
1043
|
-
for binding in context.bindings:
|
|
1044
|
-
if (spec_root / binding.path).is_file():
|
|
1045
|
-
bindings.append(binding)
|
|
1046
|
-
continue
|
|
1047
|
-
rules = []
|
|
1048
|
-
for rule in binding.rules:
|
|
1049
|
-
stages = {stage: replace(status, status="stale") for stage, status in rule.stage_status.items()}
|
|
1050
|
-
rules.append(replace(rule, stage_status=stages))
|
|
1051
|
-
bindings.append(replace(binding, status="missing", rules=tuple(rules)))
|
|
1052
|
-
return replace(context, updated_at=_now(), bindings=tuple(bindings))
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
def _stale_rule(rule: RuleBinding) -> RuleBinding:
|
|
1056
|
-
statuses = {stage: replace(status, status="stale") for stage, status in rule.stage_status.items()}
|
|
1057
|
-
return replace(rule, stage_status=statuses)
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
def _removed_rule(rule: RuleBinding) -> RuleBinding:
|
|
1061
|
-
statuses = {
|
|
1062
|
-
stage: status
|
|
1063
|
-
if status.status in ("not_applicable", "waived") and status.decision is not None
|
|
1064
|
-
else replace(status, status="stale")
|
|
1065
|
-
for stage, status in rule.stage_status.items()
|
|
1066
|
-
}
|
|
1067
|
-
return replace(rule, stage_status=statuses)
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
def _file_sha256(path: Path) -> str:
|
|
1071
|
-
try:
|
|
1072
|
-
stat = path.stat()
|
|
1073
|
-
key = str(path)
|
|
1074
|
-
cached = _FILE_HASH_CACHE.get(key)
|
|
1075
|
-
signature = (stat.st_mtime_ns, stat.st_size, stat.st_ino)
|
|
1076
|
-
if cached is not None and cached[:3] == signature:
|
|
1077
|
-
return cached[3]
|
|
1078
|
-
cache_path = next(
|
|
1079
|
-
(parent / ".artifact-hash-cache.json" for parent in path.parents if parent.name == ".code-flow"),
|
|
1080
|
-
None,
|
|
1081
|
-
)
|
|
1082
|
-
cache_key = str(cache_path) if cache_path is not None else ""
|
|
1083
|
-
if cache_key and cache_key not in _PERSISTENT_HASH_PATHS:
|
|
1084
|
-
try:
|
|
1085
|
-
loaded = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
1086
|
-
if isinstance(loaded, dict):
|
|
1087
|
-
_PERSISTENT_HASH_CACHE.update({str(k): v for k, v in loaded.items() if isinstance(v, dict)})
|
|
1088
|
-
except (OSError, ValueError):
|
|
1089
|
-
pass
|
|
1090
|
-
_PERSISTENT_HASH_PATHS.add(cache_key)
|
|
1091
|
-
persistent = _PERSISTENT_HASH_CACHE.get(key)
|
|
1092
|
-
if (
|
|
1093
|
-
isinstance(persistent, dict)
|
|
1094
|
-
and persistent.get("mtime_ns") == stat.st_mtime_ns
|
|
1095
|
-
and persistent.get("size") == stat.st_size
|
|
1096
|
-
and persistent.get("ino") == stat.st_ino
|
|
1097
|
-
):
|
|
1098
|
-
digest = persistent.get("sha256")
|
|
1099
|
-
if isinstance(digest, str) and digest:
|
|
1100
|
-
_FILE_HASH_CACHE[key] = (signature[0], signature[1], signature[2], digest)
|
|
1101
|
-
return digest
|
|
1102
|
-
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
1103
|
-
_FILE_HASH_CACHE[key] = (signature[0], signature[1], signature[2], digest)
|
|
1104
|
-
if cache_key:
|
|
1105
|
-
_PERSISTENT_HASH_CACHE[key] = {
|
|
1106
|
-
"mtime_ns": stat.st_mtime_ns,
|
|
1107
|
-
"size": stat.st_size,
|
|
1108
|
-
"ino": stat.st_ino,
|
|
1109
|
-
"sha256": digest,
|
|
1110
|
-
}
|
|
1111
|
-
while len(_PERSISTENT_HASH_CACHE) > _MAX_HASH_CACHE_ENTRIES:
|
|
1112
|
-
_PERSISTENT_HASH_CACHE.pop(next(iter(_PERSISTENT_HASH_CACHE)))
|
|
1113
|
-
try:
|
|
1114
|
-
temporary = cache_path.with_suffix(".json.tmp")
|
|
1115
|
-
temporary.write_text(
|
|
1116
|
-
json.dumps(_PERSISTENT_HASH_CACHE, ensure_ascii=False, sort_keys=True),
|
|
1117
|
-
encoding="utf-8",
|
|
1118
|
-
)
|
|
1119
|
-
temporary.replace(cache_path)
|
|
1120
|
-
except OSError:
|
|
1121
|
-
pass
|
|
1122
|
-
return digest
|
|
1123
|
-
except OSError as exc:
|
|
1124
|
-
raise ContextError("artifact_read_error", "artifact", str(exc), str(path)) from exc
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
def _refresh_artifacts(
|
|
1128
|
-
spec_id: str, rule: RuleBinding, artifact_root: Path
|
|
1129
|
-
) -> tuple[RuleBinding, tuple[DriftChange, ...]]:
|
|
1130
|
-
statuses = dict(rule.stage_status)
|
|
1131
|
-
changes: list[DriftChange] = []
|
|
1132
|
-
for stage, status in rule.stage_status.items():
|
|
1133
|
-
for reference in status.refs:
|
|
1134
|
-
path = artifact_root / reference.artifact
|
|
1135
|
-
current = _file_sha256(path) if path.is_file() else "missing"
|
|
1136
|
-
if current == reference.artifact_sha256:
|
|
1137
|
-
continue
|
|
1138
|
-
if status.status == "applied" and current != "missing":
|
|
1139
|
-
statuses[stage] = replace(
|
|
1140
|
-
status,
|
|
1141
|
-
refs=tuple(
|
|
1142
|
-
replace(item, artifact_sha256=current) if item is reference else item
|
|
1143
|
-
for item in status.refs
|
|
1144
|
-
),
|
|
1145
|
-
)
|
|
1146
|
-
else:
|
|
1147
|
-
statuses[stage] = replace(status, status="stale")
|
|
1148
|
-
changes.append(
|
|
1149
|
-
DriftChange("artifact_changed", spec_id, rule.ref, reference.artifact_sha256, current, (stage,))
|
|
1150
|
-
)
|
|
1151
|
-
break
|
|
1152
|
-
return replace(rule, stage_status=statuses), tuple(changes)
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
def _refresh_existing_rule(
|
|
1156
|
-
binding: SpecBinding, existing: RuleBinding, current: SpecRule, metadata: SpecMetadata
|
|
1157
|
-
) -> tuple[RuleBinding, Optional[DriftChange]]:
|
|
1158
|
-
enforcement = "advisory" if metadata.enforcement == "advisory" else current.enforcement
|
|
1159
|
-
has_verifier = any(item.rule == current.ref for item in metadata.verifiers)
|
|
1160
|
-
verifier_ref = f"{binding.spec_id}#{current.ref}" if has_verifier else "advisory:none"
|
|
1161
|
-
updated = replace(
|
|
1162
|
-
existing,
|
|
1163
|
-
summary=current.text,
|
|
1164
|
-
text_sha256=current.text_sha256,
|
|
1165
|
-
enforcement=enforcement,
|
|
1166
|
-
verifier_ref=verifier_ref,
|
|
1167
|
-
)
|
|
1168
|
-
if existing.text_sha256 == current.text_sha256:
|
|
1169
|
-
return updated, None
|
|
1170
|
-
change = DriftChange(
|
|
1171
|
-
"rule_changed", binding.spec_id, current.ref, existing.text_sha256, current.text_sha256, tuple(existing.stage_status)
|
|
1172
|
-
)
|
|
1173
|
-
return _stale_rule(updated), change
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
def _refresh_binding(
|
|
1177
|
-
binding: SpecBinding, spec_root: Path, artifact_root: Path
|
|
1178
|
-
) -> tuple[SpecBinding, tuple[DriftChange, ...]]:
|
|
1179
|
-
path = spec_root / binding.path
|
|
1180
|
-
if not path.is_file():
|
|
1181
|
-
stale = replace(binding, status="missing", rules=tuple(_stale_rule(rule) for rule in binding.rules))
|
|
1182
|
-
change = DriftChange("spec_missing", binding.spec_id, "", binding.hashes.file_sha256, "missing", binding.stages)
|
|
1183
|
-
return stale, (change,)
|
|
1184
|
-
metadata = load_spec_metadata(str(path))
|
|
1185
|
-
existing = {rule.ref: rule for rule in binding.rules}
|
|
1186
|
-
rules: list[RuleBinding] = []
|
|
1187
|
-
changes: list[DriftChange] = []
|
|
1188
|
-
if metadata.hashes.metadata_sha256 != binding.hashes.metadata_sha256:
|
|
1189
|
-
changes.append(
|
|
1190
|
-
DriftChange("metadata_changed", binding.spec_id, "", binding.hashes.metadata_sha256, metadata.hashes.metadata_sha256, ())
|
|
1191
|
-
)
|
|
1192
|
-
for current in metadata.rules:
|
|
1193
|
-
if current.enforcement == "informational":
|
|
1194
|
-
continue
|
|
1195
|
-
prior = existing.pop(current.ref, None)
|
|
1196
|
-
if prior is None:
|
|
1197
|
-
rule = _rule_from_metadata(binding.spec_id, metadata, current)
|
|
1198
|
-
changes.append(DriftChange("rule_added", binding.spec_id, current.ref, "", current.text_sha256, metadata.stages))
|
|
1199
|
-
else:
|
|
1200
|
-
rule, change = _refresh_existing_rule(binding, prior, current, metadata)
|
|
1201
|
-
if change is not None:
|
|
1202
|
-
changes.append(change)
|
|
1203
|
-
rule, artifact_changes = _refresh_artifacts(binding.spec_id, rule, artifact_root)
|
|
1204
|
-
rules.append(rule)
|
|
1205
|
-
changes.extend(artifact_changes)
|
|
1206
|
-
for removed in existing.values():
|
|
1207
|
-
rules.append(_removed_rule(removed))
|
|
1208
|
-
changes.append(DriftChange("rule_removed", binding.spec_id, removed.ref, removed.text_sha256, "missing", tuple(removed.stage_status)))
|
|
1209
|
-
updated = replace(binding, hashes=metadata.hashes, enforcement=metadata.enforcement, stages=metadata.stages, rules=tuple(rules), status="active")
|
|
1210
|
-
return updated, tuple(changes)
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
def refresh_context(context: SpecContext, root: str, artifact_root: Optional[str] = None) -> DriftResult:
|
|
1214
|
-
started = time.monotonic()
|
|
1215
|
-
specs = Path(root) / ".code-flow" / "specs"
|
|
1216
|
-
artifacts = Path(artifact_root) if artifact_root is not None else Path(root)
|
|
1217
|
-
bindings: list[SpecBinding] = []
|
|
1218
|
-
changes: list[DriftChange] = []
|
|
1219
|
-
for binding in context.bindings:
|
|
1220
|
-
phase_started = time.monotonic()
|
|
1221
|
-
updated, binding_changes = _refresh_binding(binding, specs, artifacts)
|
|
1222
|
-
phase_timing(f"context.refresh.{binding.spec_id}", phase_started)
|
|
1223
|
-
bindings.append(updated)
|
|
1224
|
-
changes.extend(binding_changes)
|
|
1225
|
-
refreshed = replace(context, updated_at=_now(), bindings=tuple(bindings))
|
|
1226
|
-
phase_timing("context.refresh.total", started)
|
|
1227
|
-
if refreshed.bindings == context.bindings:
|
|
1228
|
-
return DriftResult(context, tuple(changes))
|
|
1229
|
-
return DriftResult(refreshed, tuple(changes))
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
def diff_spec_hashes(context: SpecContext, root: str) -> DriftResult:
|
|
1233
|
-
return refresh_context(context, root)
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
def apply_decision(
|
|
1237
|
-
context: SpecContext,
|
|
1238
|
-
spec_id: str,
|
|
1239
|
-
rule_ref: str,
|
|
1240
|
-
stage: str,
|
|
1241
|
-
decision: Decision,
|
|
1242
|
-
batch: bool = False,
|
|
1243
|
-
) -> SpecContext:
|
|
1244
|
-
if batch:
|
|
1245
|
-
raise ContextError("batch_confirmation_forbidden", "batch", "N/A/豁免必须逐项确认")
|
|
1246
|
-
_validate_decision(decision)
|
|
1247
|
-
target_status = {"not_applicable": "not_applicable", "waived": "waived", "manual_verification": "verified"}[decision.kind]
|
|
1248
|
-
found = False
|
|
1249
|
-
bindings: list[SpecBinding] = []
|
|
1250
|
-
for binding in context.bindings:
|
|
1251
|
-
changed_rules: list[RuleBinding] = []
|
|
1252
|
-
for rule in binding.rules:
|
|
1253
|
-
if binding.spec_id == spec_id and rule.ref == rule_ref:
|
|
1254
|
-
if stage not in rule.stage_status:
|
|
1255
|
-
raise ContextError("unknown_stage", "stage", f"Rule 不适用于 {stage}")
|
|
1256
|
-
statuses = dict(rule.stage_status)
|
|
1257
|
-
statuses[stage] = replace(statuses[stage], status=target_status, decision=decision)
|
|
1258
|
-
rule = replace(rule, stage_status=statuses)
|
|
1259
|
-
found = True
|
|
1260
|
-
changed_rules.append(rule)
|
|
1261
|
-
bindings.append(replace(binding, rules=tuple(changed_rules)))
|
|
1262
|
-
if not found:
|
|
1263
|
-
raise ContextError("unknown_rule", "rule_ref", f"未找到 {spec_id}#{rule_ref}")
|
|
1264
|
-
return replace(context, updated_at=_now(), bindings=tuple(bindings))
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
def _upsert_artifact_ref(
|
|
1268
|
-
references: Sequence[ArtifactRef], replacement: ArtifactRef
|
|
1269
|
-
) -> tuple[ArtifactRef, ...]:
|
|
1270
|
-
identity = (replacement.artifact, replacement.section_id, replacement.item_id)
|
|
1271
|
-
updated: list[ArtifactRef] = []
|
|
1272
|
-
replaced = False
|
|
1273
|
-
for reference in references:
|
|
1274
|
-
current = (reference.artifact, reference.section_id, reference.item_id)
|
|
1275
|
-
if current != identity:
|
|
1276
|
-
updated.append(reference)
|
|
1277
|
-
elif not replaced:
|
|
1278
|
-
updated.append(replacement)
|
|
1279
|
-
replaced = True
|
|
1280
|
-
if not replaced:
|
|
1281
|
-
updated.append(replacement)
|
|
1282
|
-
return tuple(updated)
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
def apply_artifact_ref(
|
|
1286
|
-
context: SpecContext,
|
|
1287
|
-
spec_id: str,
|
|
1288
|
-
rule_ref: str,
|
|
1289
|
-
stage: str,
|
|
1290
|
-
reference: ArtifactRef,
|
|
1291
|
-
) -> SpecContext:
|
|
1292
|
-
found = False
|
|
1293
|
-
bindings: list[SpecBinding] = []
|
|
1294
|
-
for binding in context.bindings:
|
|
1295
|
-
rules: list[RuleBinding] = []
|
|
1296
|
-
for rule in binding.rules:
|
|
1297
|
-
if binding.spec_id == spec_id and rule.ref == rule_ref:
|
|
1298
|
-
if stage not in rule.stage_status:
|
|
1299
|
-
raise ContextError("unknown_stage", "stage", f"Rule 不适用于 {stage}")
|
|
1300
|
-
statuses = dict(rule.stage_status)
|
|
1301
|
-
current = statuses[stage]
|
|
1302
|
-
refs = _upsert_artifact_ref(current.refs, reference)
|
|
1303
|
-
if current.decision is not None:
|
|
1304
|
-
statuses[stage] = replace(current, refs=refs)
|
|
1305
|
-
else:
|
|
1306
|
-
statuses[stage] = replace(current, status="applied", refs=refs)
|
|
1307
|
-
rule = replace(rule, stage_status=statuses)
|
|
1308
|
-
found = True
|
|
1309
|
-
rules.append(rule)
|
|
1310
|
-
bindings.append(replace(binding, rules=tuple(rules)))
|
|
1311
|
-
if not found:
|
|
1312
|
-
raise ContextError("unknown_rule", "rule_ref", f"未找到 {spec_id}#{rule_ref}")
|
|
1313
|
-
return replace(context, updated_at=_now(), bindings=tuple(bindings))
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
def _json_payload(stream: IO[str]) -> Mapping[str, object]:
|
|
1317
|
-
try:
|
|
1318
|
-
raw = stream.read()
|
|
1319
|
-
except (OSError, UnicodeError) as exc:
|
|
1320
|
-
raise ContextError("invalid_json", "stdin", str(exc)) from exc
|
|
1321
|
-
if not raw.strip():
|
|
1322
|
-
# 空 stdin = 无 payload(例如 active doctor --resync 全参数走 CLI 开关)。
|
|
1323
|
-
return {}
|
|
1324
|
-
try:
|
|
1325
|
-
loaded = json.loads(raw)
|
|
1326
|
-
except json.JSONDecodeError as exc:
|
|
1327
|
-
raise ContextError("invalid_json", "stdin", str(exc)) from exc
|
|
1328
|
-
return _mapping(loaded, "stdin", "")
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
def _candidate_data(candidate: SpecCandidate) -> dict[str, object]:
|
|
1332
|
-
return {
|
|
1333
|
-
"spec_id": candidate.spec_id,
|
|
1334
|
-
"path": candidate.path,
|
|
1335
|
-
"scope": candidate.scope,
|
|
1336
|
-
"priority": candidate.priority,
|
|
1337
|
-
"matched_paths": list(candidate.matched_paths),
|
|
1338
|
-
"description": candidate.metadata.description,
|
|
1339
|
-
"enforcement": candidate.metadata.enforcement,
|
|
1340
|
-
"stages": list(candidate.metadata.stages),
|
|
1341
|
-
"hashes": {
|
|
1342
|
-
"file_sha256": candidate.metadata.hashes.file_sha256,
|
|
1343
|
-
"metadata_sha256": candidate.metadata.hashes.metadata_sha256,
|
|
1344
|
-
"rules_sha256": candidate.metadata.hashes.rules_sha256,
|
|
1345
|
-
},
|
|
1346
|
-
"rules": [
|
|
1347
|
-
{"ref": rule.ref, "text": rule.text, "enforcement": rule.enforcement}
|
|
1348
|
-
for rule in candidate.metadata.rules
|
|
1349
|
-
],
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
def _catalog_command(args: argparse.Namespace) -> dict[str, object]:
|
|
1354
|
-
candidates = resolve_candidates(args.root, args.stage, tuple(args.paths))
|
|
1355
|
-
return {
|
|
1356
|
-
"ok": True,
|
|
1357
|
-
"stage": args.stage,
|
|
1358
|
-
"total": len(candidates),
|
|
1359
|
-
"candidates": [_candidate_data(item) for item in candidates],
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
def _application_ref(task_dir: str, value: Mapping[str, object]) -> ArtifactRef:
|
|
1364
|
-
artifact = _string(value.get("artifact"), "applications[].artifact", "")
|
|
1365
|
-
base = Path(task_dir).resolve()
|
|
1366
|
-
target = (base / artifact).resolve()
|
|
1367
|
-
if base != target and base not in target.parents:
|
|
1368
|
-
raise ContextError("artifact_outside_task", "applications[].artifact", artifact, task_dir)
|
|
1369
|
-
try:
|
|
1370
|
-
artifact_hash = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
1371
|
-
except OSError as exc:
|
|
1372
|
-
raise ContextError("artifact_read_error", "applications[].artifact", str(exc), str(target)) from exc
|
|
1373
|
-
return ArtifactRef(
|
|
1374
|
-
artifact,
|
|
1375
|
-
_string(value.get("section_id"), "applications[].section_id", ""),
|
|
1376
|
-
_string(value.get("item_id"), "applications[].item_id", ""),
|
|
1377
|
-
artifact_hash,
|
|
1378
|
-
)
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
def _apply_payload(context: SpecContext, task_dir: str, payload: Mapping[str, object]) -> SpecContext:
|
|
1382
|
-
updated = context
|
|
1383
|
-
for raw in _sequence(payload.get("applications", []), "applications", ""):
|
|
1384
|
-
item = _mapping(raw, "applications[]", "")
|
|
1385
|
-
updated = apply_artifact_ref(
|
|
1386
|
-
updated,
|
|
1387
|
-
_string(item.get("spec_id"), "applications[].spec_id", ""),
|
|
1388
|
-
_string(item.get("rule_ref"), "applications[].rule_ref", ""),
|
|
1389
|
-
_string(item.get("stage"), "applications[].stage", ""),
|
|
1390
|
-
_application_ref(task_dir, item),
|
|
1391
|
-
)
|
|
1392
|
-
return updated
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
def _decision_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
1396
|
-
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
1397
|
-
decision = _decision_from_data(payload.get("decision"), str(context_path))
|
|
1398
|
-
if decision is None:
|
|
1399
|
-
raise ContextError("invalid_decision", "decision", "必须提供 decision")
|
|
1400
|
-
context = apply_decision(
|
|
1401
|
-
load_context(str(context_path)),
|
|
1402
|
-
_string(payload.get("spec_id"), "spec_id", ""),
|
|
1403
|
-
_string(payload.get("rule_ref"), "rule_ref", ""),
|
|
1404
|
-
_string(payload.get("stage"), "stage", ""),
|
|
1405
|
-
decision,
|
|
1406
|
-
payload.get("batch") is True,
|
|
1407
|
-
)
|
|
1408
|
-
save_context(str(context_path), context)
|
|
1409
|
-
_resync_after_save(args, context_path)
|
|
1410
|
-
return {"ok": True, "status": context.bindings[0].rules[0].stage_status[_string(payload.get("stage"), "stage", "")].status}
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
def _bind_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
1414
|
-
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
1415
|
-
paths = tuple(_string(item, "paths[]", "") for item in _sequence(payload.get("paths", []), "paths", ""))
|
|
1416
|
-
candidates = {item.spec_id: item for item in resolve_candidates(args.root, args.stage, paths)}
|
|
1417
|
-
selections: list[BindingInput] = []
|
|
1418
|
-
for raw in _sequence(payload.get("selections"), "selections", ""):
|
|
1419
|
-
item = _mapping(raw, "selections[]", "")
|
|
1420
|
-
spec_id = _string(item.get("spec_id"), "selections[].spec_id", "")
|
|
1421
|
-
if spec_id not in candidates:
|
|
1422
|
-
raise ContextError("unknown_candidate", "selections[].spec_id", spec_id)
|
|
1423
|
-
selections.append(
|
|
1424
|
-
BindingInput(
|
|
1425
|
-
candidates[spec_id],
|
|
1426
|
-
_string(item.get("selected_by"), "selections[].selected_by", ""),
|
|
1427
|
-
_string(item.get("reason"), "selections[].reason", ""),
|
|
1428
|
-
)
|
|
1429
|
-
)
|
|
1430
|
-
if context_path.exists():
|
|
1431
|
-
context = load_context(str(context_path))
|
|
1432
|
-
else:
|
|
1433
|
-
task = _string(payload.get("task"), "task", "")
|
|
1434
|
-
context = new_context(task, (("cli", "bind"),))
|
|
1435
|
-
context = bind_specs(context, selections)
|
|
1436
|
-
context = _apply_payload(context, args.task_dir, payload)
|
|
1437
|
-
save_context(str(context_path), context)
|
|
1438
|
-
_resync_after_save(args, context_path)
|
|
1439
|
-
applied = sum(
|
|
1440
|
-
status.status == "applied"
|
|
1441
|
-
for binding in context.bindings
|
|
1442
|
-
for rule in binding.rules
|
|
1443
|
-
for status in rule.stage_status.values()
|
|
1444
|
-
)
|
|
1445
|
-
return {"ok": True, "bindings": len(context.bindings), "applied": applied}
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
1449
|
-
from cf_workflow_service import WorkflowError, locate_task_file
|
|
1450
|
-
from cf_workflow_transaction import recover_transition
|
|
1451
|
-
|
|
1452
|
-
recover_transition(args.root)
|
|
1453
|
-
if args.active_action in ("pause", "resume", "block", "complete"):
|
|
1454
|
-
marker, _ = _active_paths(args.root)
|
|
1455
|
-
if marker.exists():
|
|
1456
|
-
current = load_active_task(args.root)
|
|
1457
|
-
if (current.task_id != args.task or
|
|
1458
|
-
(Path(args.root) / current.task_dir).resolve() != (Path(args.root) / args.task_dir).resolve()):
|
|
1459
|
-
raise ContextError("active_mismatch", "active", "requested TASK does not match marker", str(marker))
|
|
1460
|
-
|
|
1461
|
-
def _directory() -> str:
|
|
1462
|
-
return args.task_dir if Path(args.task_dir).is_absolute() else str(Path(args.root) / args.task_dir)
|
|
1463
|
-
|
|
1464
|
-
def _reraise(exc: WorkflowError) -> ContextError:
|
|
1465
|
-
return ContextError(exc.code, "workflow", str(exc), args.task_dir)
|
|
1466
|
-
|
|
1467
|
-
task_file = locate_task_file(_directory(), args.task)
|
|
1468
|
-
if args.active_action == "start":
|
|
1469
|
-
owned = tuple(
|
|
1470
|
-
_string(item, "owned_paths[]", "")
|
|
1471
|
-
for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
|
|
1472
|
-
)
|
|
1473
|
-
if task_file is None:
|
|
1474
|
-
active = start_active_task(
|
|
1475
|
-
args.root, args.task_dir, args.task, args.context_sha256, owned
|
|
1476
|
-
)
|
|
1477
|
-
return {"ok": True, "active": _active_data(active)}
|
|
1478
|
-
from cf_workflow_service import start_task
|
|
1479
|
-
|
|
1480
|
-
try:
|
|
1481
|
-
return start_task(args.root, args.task_dir, str(task_file), args.task, owned)
|
|
1482
|
-
except WorkflowError as exc:
|
|
1483
|
-
raise _reraise(exc) from exc
|
|
1484
|
-
if args.active_action == "pause":
|
|
1485
|
-
active = pause_active_task(args.root)
|
|
1486
|
-
elif args.active_action == "resume":
|
|
1487
|
-
if task_file is None:
|
|
1488
|
-
active = resume_active_task(args.root)
|
|
1489
|
-
else:
|
|
1490
|
-
from cf_workflow_service import resume_task
|
|
1491
|
-
|
|
1492
|
-
try:
|
|
1493
|
-
return resume_task(args.root, _directory(), str(task_file), args.task)
|
|
1494
|
-
except WorkflowError as exc:
|
|
1495
|
-
raise _reraise(exc) from exc
|
|
1496
|
-
elif args.active_action == "block":
|
|
1497
|
-
if task_file is None:
|
|
1498
|
-
active = block_active_task(args.root)
|
|
1499
|
-
else:
|
|
1500
|
-
from cf_workflow_service import block_task
|
|
1501
|
-
|
|
1502
|
-
reason = payload.get("reason", "")
|
|
1503
|
-
try:
|
|
1504
|
-
return block_task(args.root, _directory(), str(task_file), args.task, reason if isinstance(reason, str) else "")
|
|
1505
|
-
except WorkflowError as exc:
|
|
1506
|
-
raise _reraise(exc) from exc
|
|
1507
|
-
elif args.active_action == "complete":
|
|
1508
|
-
gate = payload.get("gate_passed") is True
|
|
1509
|
-
if task_file is None:
|
|
1510
|
-
active = complete_active_task(args.root, gate)
|
|
1511
|
-
else:
|
|
1512
|
-
from cf_workflow_service import complete_task
|
|
1513
|
-
|
|
1514
|
-
try:
|
|
1515
|
-
return complete_task(args.root, _directory(), str(task_file), args.task, gate)
|
|
1516
|
-
except WorkflowError as exc:
|
|
1517
|
-
raise _reraise(exc) from exc
|
|
1518
|
-
else:
|
|
1519
|
-
abandon = payload.get("abandon") is True or args.abandon
|
|
1520
|
-
resync = payload.get("resync") is True or args.resync
|
|
1521
|
-
if abandon and resync:
|
|
1522
|
-
raise ContextError("conflicting_recovery", "doctor", "abandon 与 resync 不能同时使用", args.task_dir)
|
|
1523
|
-
result = doctor_active_task(args.root, args.context_sha256, abandon, resync)
|
|
1524
|
-
return {"ok": True, "action": result.action, "active": _active_data(result.active)}
|
|
1525
|
-
return {"ok": True, "active": _active_data(active)}
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
def _start_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
1529
|
-
"""Run refresh, activation and TASK projection in one guarded process.
|
|
1530
|
-
|
|
1531
|
-
Delegates to the unified workflow service: hard preconditions (status,
|
|
1532
|
-
NOTES, depends, marker) are enforced before any state changes, and the
|
|
1533
|
-
Markdown view flips to in-progress in the same call.
|
|
1534
|
-
"""
|
|
1535
|
-
from cf_workflow_service import WorkflowError, start_task
|
|
1536
|
-
|
|
1537
|
-
owned = tuple(
|
|
1538
|
-
_string(item, "owned_paths[]", "")
|
|
1539
|
-
for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
|
|
1540
|
-
)
|
|
1541
|
-
try:
|
|
1542
|
-
return start_task(
|
|
1543
|
-
args.root, args.task_dir, args.task_file, args.task, owned, args.session_output
|
|
1544
|
-
)
|
|
1545
|
-
except WorkflowError as exc:
|
|
1546
|
-
raise ContextError(exc.code, "workflow", str(exc), args.task_dir) from exc
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
def _status_command(args: argparse.Namespace) -> dict[str, object]:
|
|
1550
|
-
"""Human-readable Spec Context status: task, marker health, gate, rules."""
|
|
1551
|
-
from cf_spec_gate import result_to_data, validate_stage # local import avoids module cycle
|
|
1552
|
-
|
|
1553
|
-
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
1554
|
-
context = load_context(str(context_path))
|
|
1555
|
-
gate = validate_stage(context, "code")
|
|
1556
|
-
root = getattr(args, "root", "") or _find_root(args.task_dir) or ""
|
|
1557
|
-
marker: dict[str, object] = {"exists": False}
|
|
1558
|
-
if root:
|
|
1559
|
-
marker_path = Path(root) / ".code-flow" / ".active-task.json"
|
|
1560
|
-
if marker_path.exists():
|
|
1561
|
-
active = load_active_task(root)
|
|
1562
|
-
marker = {
|
|
1563
|
-
"exists": True,
|
|
1564
|
-
"task_id": active.task_id,
|
|
1565
|
-
"status": active.status,
|
|
1566
|
-
"hash_match": active.context_sha256 == context_sha256(context),
|
|
1567
|
-
"baseline_head": active.baseline.head,
|
|
1568
|
-
}
|
|
1569
|
-
bindings = []
|
|
1570
|
-
for binding in context.bindings:
|
|
1571
|
-
rules = []
|
|
1572
|
-
for rule in binding.rules:
|
|
1573
|
-
status = rule.stage_status.get("code")
|
|
1574
|
-
rules.append(
|
|
1575
|
-
{
|
|
1576
|
-
"ref": f"{binding.spec_id}#{rule.ref}",
|
|
1577
|
-
"enforcement": rule.enforcement,
|
|
1578
|
-
"status": status.status if status else "missing",
|
|
1579
|
-
"verifier": rule.verifier_ref,
|
|
1580
|
-
}
|
|
1581
|
-
)
|
|
1582
|
-
bindings.append({"spec_id": binding.spec_id, "path": binding.path, "status": binding.status, "rules": rules})
|
|
1583
|
-
return {
|
|
1584
|
-
"task": context.task,
|
|
1585
|
-
"context_sha256": context_sha256(context),
|
|
1586
|
-
"gate": result_to_data(gate),
|
|
1587
|
-
"marker": marker,
|
|
1588
|
-
"bindings": bindings,
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
def _status_text(data: dict[str, object]) -> str:
|
|
1593
|
-
lines = [f"# {data['task']} — Spec Context 状态"]
|
|
1594
|
-
marker = data["marker"]
|
|
1595
|
-
if marker["exists"]:
|
|
1596
|
-
match = "✓ 一致" if marker["hash_match"] else "✗ 漂移"
|
|
1597
|
-
lines.append(f"- TASK {marker['task_id']}({marker['status']}),marker hash {match}")
|
|
1598
|
-
if not marker["hash_match"]:
|
|
1599
|
-
lines.append(" 下一步: 运行 cf-spec refresh 自动重同步 marker hash;仍不一致时用 active doctor --resync(hash 取本命令 --json 的 context_sha256)")
|
|
1600
|
-
else:
|
|
1601
|
-
lines.append("- 无 active TASK(path/catalog 路由模式)")
|
|
1602
|
-
gate = data["gate"]
|
|
1603
|
-
if gate["decision"] == "pass":
|
|
1604
|
-
lines.append("- code Gate: ✓ pass")
|
|
1605
|
-
else:
|
|
1606
|
-
lines.append(f"- code Gate: ✗ block({len(gate['errors'])} 项)")
|
|
1607
|
-
for issue in gate["errors"]:
|
|
1608
|
-
lines.append(f" ✗ {issue['message']}")
|
|
1609
|
-
for binding in data["bindings"]:
|
|
1610
|
-
lines.append(f"- {binding['spec_id']}({binding['path']})— {binding['status']}")
|
|
1611
|
-
for rule in binding["rules"]:
|
|
1612
|
-
lines.append(f" {rule['status']:<12} {rule['ref']}({rule['verifier']})")
|
|
1613
|
-
return "\n".join(lines)
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
def _parser() -> argparse.ArgumentParser:
|
|
1617
|
-
parser = argparse.ArgumentParser(prog="cf_spec_context.py")
|
|
1618
|
-
commands = parser.add_subparsers(dest="command", required=True)
|
|
1619
|
-
catalog = commands.add_parser("catalog")
|
|
1620
|
-
catalog.add_argument("--root", required=True)
|
|
1621
|
-
catalog.add_argument("--stage", required=True)
|
|
1622
|
-
catalog.add_argument("--paths", nargs="*", default=())
|
|
1623
|
-
catalog.add_argument("--json", action="store_true")
|
|
1624
|
-
decision = commands.add_parser("decision")
|
|
1625
|
-
decision.add_argument("--task-dir", required=True)
|
|
1626
|
-
decision.add_argument("--json", action="store_true")
|
|
1627
|
-
bind = commands.add_parser("bind")
|
|
1628
|
-
bind.add_argument("--task-dir", required=True)
|
|
1629
|
-
bind.add_argument("--root", required=True)
|
|
1630
|
-
bind.add_argument("--stage", required=True)
|
|
1631
|
-
bind.add_argument("--json", action="store_true")
|
|
1632
|
-
validate = commands.add_parser("validate")
|
|
1633
|
-
validate.add_argument("--task-dir", required=True)
|
|
1634
|
-
validate.add_argument("--json", action="store_true")
|
|
1635
|
-
status = commands.add_parser("status")
|
|
1636
|
-
status.add_argument("--task-dir", required=True)
|
|
1637
|
-
status.add_argument("--root", default="")
|
|
1638
|
-
status.add_argument("--json", action="store_true")
|
|
1639
|
-
for name in ("refresh", "refresh-missing"):
|
|
1640
|
-
refresh = commands.add_parser(name)
|
|
1641
|
-
refresh.add_argument("--task-dir", required=True)
|
|
1642
|
-
refresh.add_argument("--root", required=True)
|
|
1643
|
-
refresh.add_argument("--json", action="store_true")
|
|
1644
|
-
active = commands.add_parser("active")
|
|
1645
|
-
active_actions = active.add_subparsers(dest="active_action", required=True)
|
|
1646
|
-
for action in ("start", "pause", "resume", "block", "complete", "doctor"):
|
|
1647
|
-
command = active_actions.add_parser(action)
|
|
1648
|
-
command.add_argument("--root", required=True)
|
|
1649
|
-
command.add_argument("--task-dir", required=True)
|
|
1650
|
-
command.add_argument("--task", required=True)
|
|
1651
|
-
command.add_argument("--context-sha256", required=True)
|
|
1652
|
-
command.add_argument("--json", action="store_true")
|
|
1653
|
-
if action == "doctor":
|
|
1654
|
-
command.add_argument("--abandon", action="store_true", help="确认放弃无法证明的 marker(需用户确认)")
|
|
1655
|
-
command.add_argument("--resync", action="store_true", help="用 --context-sha256 重新绑定 marker 并恢复 active")
|
|
1656
|
-
start = commands.add_parser("start")
|
|
1657
|
-
start.add_argument("--task-dir", required=True)
|
|
1658
|
-
start.add_argument("--root", required=True)
|
|
1659
|
-
start.add_argument("--task", required=True)
|
|
1660
|
-
start.add_argument("--task-file", required=True)
|
|
1661
|
-
start.add_argument("--session-output", default="")
|
|
1662
|
-
start.add_argument("--json", action="store_true")
|
|
1663
|
-
return parser
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
def _execute(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
|
|
1667
|
-
if args.command == "catalog":
|
|
1668
|
-
return _catalog_command(args)
|
|
1669
|
-
if args.command == "active":
|
|
1670
|
-
return _active_command(args, _json_payload(stdin))
|
|
1671
|
-
if args.command == "start":
|
|
1672
|
-
return _start_command(args, _json_payload(stdin))
|
|
1673
|
-
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
1674
|
-
if args.command == "decision":
|
|
1675
|
-
return _decision_command(args, _json_payload(stdin))
|
|
1676
|
-
if args.command == "bind":
|
|
1677
|
-
return _bind_command(args, _json_payload(stdin))
|
|
1678
|
-
if args.command == "validate":
|
|
1679
|
-
context = load_context(str(context_path))
|
|
1680
|
-
return {"ok": True, "task": context.task, "bindings": len(context.bindings)}
|
|
1681
|
-
if args.command == "refresh-missing":
|
|
1682
|
-
context = refresh_missing_specs(load_context(str(context_path)), args.root)
|
|
1683
|
-
changes: tuple[DriftChange, ...] = ()
|
|
1684
|
-
else:
|
|
1685
|
-
result = refresh_context(load_context(str(context_path)), args.root, artifact_root=args.task_dir)
|
|
1686
|
-
context, changes = result.context, result.changes
|
|
1687
|
-
save_context(str(context_path), context)
|
|
1688
|
-
_resync_after_save(args, context_path)
|
|
1689
|
-
return {
|
|
1690
|
-
"ok": True,
|
|
1691
|
-
"missing": sum(item.status == "missing" for item in context.bindings),
|
|
1692
|
-
"changes": [change.__dict__ for change in changes],
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
def main(argv: Optional[Sequence[str]] = None, stdin: IO[str] = sys.stdin, stdout: IO[str] = sys.stdout) -> int:
|
|
1697
|
-
try:
|
|
1698
|
-
args = _parser().parse_args(argv)
|
|
1699
|
-
if args.command == "status":
|
|
1700
|
-
data = _status_command(args)
|
|
1701
|
-
if args.json:
|
|
1702
|
-
stdout.write(json.dumps(data, ensure_ascii=False))
|
|
1703
|
-
else:
|
|
1704
|
-
stdout.write(_status_text(data) + "\n")
|
|
1705
|
-
return 0
|
|
1706
|
-
result = _execute(args, stdin)
|
|
1707
|
-
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
1708
|
-
return 0
|
|
1709
|
-
except ContextError as exc:
|
|
1710
|
-
stdout.write(json.dumps({"ok": False, "error": exc.to_dict()}, ensure_ascii=False))
|
|
1711
|
-
return 3
|
|
1712
|
-
except Exception as exc:
|
|
1713
|
-
sys.stderr.write(f"cf_spec_context unexpected error: {exc}\n")
|
|
1714
|
-
stdout.write(json.dumps({"ok": False, "error": {"code": "internal_error"}}, ensure_ascii=False))
|
|
1715
|
-
return 2
|
|
1716
4
|
|
|
5
|
+
from codeflow.cli import cf_spec_context as _impl
|
|
1717
6
|
|
|
1718
7
|
if __name__ == "__main__":
|
|
1719
|
-
raise SystemExit(main())
|
|
8
|
+
raise SystemExit(_impl.main())
|
|
9
|
+
else:
|
|
10
|
+
sys.modules[__name__] = _impl
|