@michaelxwb/code-flow 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/migrations/spec-workflow-0.6.0.md +42 -0
- package/package.json +4 -2
- package/src/adapters/claude/CLAUDE.md +11 -17
- package/src/adapters/claude/commands/cf-init.md +5 -4
- package/src/adapters/claude/commands/cf-spec.md +50 -0
- package/src/adapters/claude/commands/cf-task/align.md +29 -3
- package/src/adapters/claude/commands/cf-task/archive.md +32 -4
- package/src/adapters/claude/commands/cf-task/plan.md +54 -1
- package/src/adapters/claude/commands/cf-task/prd.md +24 -0
- package/src/adapters/claude/commands/cf-task/start.md +56 -14
- package/src/adapters/claude/settings.local.json +1 -11
- package/src/adapters/codex/AGENTS.md +11 -17
- package/src/adapters/codex/hooks.json +6 -4
- package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +55 -0
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +29 -3
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +32 -4
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +54 -1
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +56 -14
- package/src/adapters/costrict/CLAUDE.md +11 -17
- package/src/adapters/costrict/commands/cf-init.md +5 -4
- package/src/adapters/costrict/commands/cf-spec.md +50 -0
- package/src/adapters/costrict/commands/cf-task/align.md +29 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +32 -4
- package/src/adapters/costrict/commands/cf-task/plan.md +54 -1
- package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
- package/src/adapters/costrict/commands/cf-task/start.md +56 -14
- package/src/adapters/costrict/settings.local.json +1 -11
- package/src/adapters/opencode/AGENTS.md +11 -17
- package/src/adapters/opencode/commands/cf-init.md +4 -3
- package/src/adapters/opencode/commands/cf-spec.md +54 -0
- package/src/adapters/opencode/commands/cf-task/align.md +30 -3
- package/src/adapters/opencode/commands/cf-task/archive.md +33 -4
- package/src/adapters/opencode/commands/cf-task/plan.md +55 -1
- package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
- package/src/adapters/opencode/commands/cf-task/start.md +57 -14
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -1
- package/src/cli.js +51 -2
- package/src/core/code-flow/config.yml +13 -16
- package/src/core/code-flow/scripts/cf_checks.py +30 -0
- package/src/core/code-flow/scripts/cf_core.py +12 -255
- package/src/core/code-flow/scripts/cf_log.py +8 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +33 -9
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +65 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +1233 -0
- package/src/core/code-flow/scripts/cf_spec_gate.py +224 -0
- package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
- package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
- package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
- package/src/core/code-flow/scripts/cf_spec_router.py +145 -0
- package/src/core/code-flow/scripts/cf_spec_session.py +122 -0
- package/src/core/code-flow/scripts/cf_spec_verify.py +293 -0
- package/src/core/code-flow/scripts/cf_stats.py +40 -5
- package/src/core/code-flow/scripts/cf_stop_hook.py +107 -7
- package/src/core/code-flow/scripts/cf_task_runtime.py +142 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +34 -360
- package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
- package/src/core/code-flow/specs/backend/database.md +13 -1
- package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
- package/src/core/code-flow/specs/backend/logging.md +13 -1
- package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
- package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
- package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
- package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
- package/src/core/code-flow/specs/shared/design/design-frontend.md +20 -10
- package/src/core/code-flow/specs/shared/design/design-full.md +36 -24
- package/src/core/code-flow/specs/shared/design/design-lite.md +21 -9
- package/src/core/code-flow/specs/shared/prd-template.md +14 -3
- package/src/migrate/spec-workflow.js +238 -0
- package/src/adapters/claude/commands/cf-inject.md +0 -55
- package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
- package/src/adapters/costrict/commands/cf-inject.md +0 -55
- package/src/adapters/opencode/commands/cf-inject.md +0 -59
- package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
- package/src/core/code-flow/scripts/cf_session_hook.py +0 -42
|
@@ -0,0 +1,1233 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Persisted Spec Context schema, binding and confirmation commands."""
|
|
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
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from typing import IO, Mapping, Optional, Sequence
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from cf_spec_metadata import SpecHashes, SpecMetadata, SpecRule, load_spec_metadata
|
|
22
|
+
from cf_spec_resolver import SpecCandidate, resolve_candidates
|
|
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
|
+
ACTIVE_STATUSES = frozenset(("activating", "active", "paused", "blocked", "completed"))
|
|
32
|
+
DEFAULT_ACTIVE_EXCLUDES = (
|
|
33
|
+
".code-flow/tasks/*",
|
|
34
|
+
".code-flow/specs/_session/*",
|
|
35
|
+
".code-flow/migrations/*",
|
|
36
|
+
".code-flow/.*state*",
|
|
37
|
+
".code-flow/.session-log.jsonl",
|
|
38
|
+
".code-flow/.active-task.json",
|
|
39
|
+
".code-flow/.active-task.lock",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ContextError(ValueError):
|
|
44
|
+
"""A stable context error suitable for JSON CLI output."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, code: str, field: str, message: str, path: str = "") -> None:
|
|
47
|
+
self.code = code
|
|
48
|
+
self.field = field
|
|
49
|
+
self.message = message
|
|
50
|
+
self.path = path
|
|
51
|
+
location = f"{path}: " if path else ""
|
|
52
|
+
super().__init__(f"{location}{code} {field}: {message}")
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, str]:
|
|
55
|
+
return {"code": self.code, "field": self.field, "message": self.message, "path": self.path}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class ContextSource:
|
|
60
|
+
type: str
|
|
61
|
+
ref: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Decision:
|
|
66
|
+
kind: str
|
|
67
|
+
reason: str
|
|
68
|
+
confirmed_by: str
|
|
69
|
+
confirmed_at: str
|
|
70
|
+
source: str
|
|
71
|
+
expires_at: Optional[str]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class ArtifactRef:
|
|
76
|
+
artifact: str
|
|
77
|
+
section_id: str
|
|
78
|
+
item_id: str
|
|
79
|
+
artifact_sha256: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class RuleStageStatus:
|
|
84
|
+
status: str
|
|
85
|
+
refs: tuple[ArtifactRef, ...]
|
|
86
|
+
decision: Optional[Decision]
|
|
87
|
+
evidence: tuple[Mapping[str, object], ...]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class RuleBinding:
|
|
92
|
+
ref: str
|
|
93
|
+
summary: str
|
|
94
|
+
text_sha256: str
|
|
95
|
+
enforcement: str
|
|
96
|
+
verifier_ref: str
|
|
97
|
+
stage_status: Mapping[str, RuleStageStatus]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class SpecBinding:
|
|
102
|
+
spec_id: str
|
|
103
|
+
path: str
|
|
104
|
+
hashes: SpecHashes
|
|
105
|
+
selected_by: str
|
|
106
|
+
reason: str
|
|
107
|
+
enforcement: str
|
|
108
|
+
stages: tuple[str, ...]
|
|
109
|
+
rules: tuple[RuleBinding, ...]
|
|
110
|
+
status: str = "active"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class SpecContext:
|
|
115
|
+
version: int
|
|
116
|
+
task: str
|
|
117
|
+
enforcement: str
|
|
118
|
+
updated_at: str
|
|
119
|
+
sources: tuple[ContextSource, ...]
|
|
120
|
+
bindings: tuple[SpecBinding, ...]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class BindingInput:
|
|
125
|
+
candidate: SpecCandidate
|
|
126
|
+
selected_by: str
|
|
127
|
+
reason: str
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class DriftChange:
|
|
132
|
+
kind: str
|
|
133
|
+
spec_id: str
|
|
134
|
+
rule_ref: str
|
|
135
|
+
old_hash: str
|
|
136
|
+
new_hash: str
|
|
137
|
+
stages: tuple[str, ...]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class DriftResult:
|
|
142
|
+
context: SpecContext
|
|
143
|
+
changes: tuple[DriftChange, ...]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class PathSnapshot:
|
|
148
|
+
status: str
|
|
149
|
+
content_sha256: str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class ActiveBaseline:
|
|
154
|
+
head: Optional[str]
|
|
155
|
+
captured_at: str
|
|
156
|
+
preexisting_changes: Mapping[str, PathSnapshot]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class ActiveTask:
|
|
161
|
+
version: int
|
|
162
|
+
task_dir: str
|
|
163
|
+
task_id: str
|
|
164
|
+
status: str
|
|
165
|
+
context_sha256: str
|
|
166
|
+
baseline: ActiveBaseline
|
|
167
|
+
owned_paths: tuple[str, ...]
|
|
168
|
+
excluded_paths: tuple[str, ...]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class DoctorResult:
|
|
173
|
+
action: str
|
|
174
|
+
active: ActiveTask
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _now() -> str:
|
|
178
|
+
return datetime.now(timezone.utc).isoformat()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _atomic_text(target: Path, text: str) -> None:
|
|
182
|
+
temporary = ""
|
|
183
|
+
try:
|
|
184
|
+
with tempfile.NamedTemporaryFile(
|
|
185
|
+
"w",
|
|
186
|
+
encoding="utf-8",
|
|
187
|
+
dir=str(target.parent),
|
|
188
|
+
prefix=f".{target.name}.",
|
|
189
|
+
suffix=".tmp",
|
|
190
|
+
delete=False,
|
|
191
|
+
) as handle:
|
|
192
|
+
temporary = handle.name
|
|
193
|
+
handle.write(text)
|
|
194
|
+
handle.flush()
|
|
195
|
+
os.fsync(handle.fileno())
|
|
196
|
+
os.replace(temporary, target)
|
|
197
|
+
except OSError as exc:
|
|
198
|
+
if temporary and Path(temporary).exists():
|
|
199
|
+
Path(temporary).unlink()
|
|
200
|
+
raise ContextError("atomic_write_error", "file", str(exc), str(target)) from exc
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _active_paths(root: str) -> tuple[Path, Path]:
|
|
204
|
+
flow = Path(root) / ".code-flow"
|
|
205
|
+
return flow / ".active-task.json", flow / ".active-task.lock"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _run_git(root: str, arguments: Sequence[str]) -> str:
|
|
209
|
+
try:
|
|
210
|
+
result = subprocess.run(
|
|
211
|
+
("git", *arguments), cwd=root, text=True, capture_output=True, check=False
|
|
212
|
+
)
|
|
213
|
+
except OSError as exc:
|
|
214
|
+
raise ContextError("git_unavailable", "git", str(exc), root) from exc
|
|
215
|
+
if result.returncode != 0:
|
|
216
|
+
message = result.stderr.strip() or "Git command failed"
|
|
217
|
+
raise ContextError("git_error", "git", message, root)
|
|
218
|
+
return result.stdout
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _git_head(root: str) -> Optional[str]:
|
|
222
|
+
try:
|
|
223
|
+
return _run_git(root, ("rev-parse", "HEAD")).strip()
|
|
224
|
+
except ContextError as exc:
|
|
225
|
+
if "unknown revision" in exc.message or "ambiguous argument" in exc.message:
|
|
226
|
+
return None
|
|
227
|
+
raise
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _status_name(code: str) -> str:
|
|
231
|
+
if code == "??":
|
|
232
|
+
return "untracked"
|
|
233
|
+
if "D" in code:
|
|
234
|
+
return "deleted"
|
|
235
|
+
if "A" in code:
|
|
236
|
+
return "added"
|
|
237
|
+
if "M" in code:
|
|
238
|
+
return "modified"
|
|
239
|
+
return "changed"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _git_changes(root: str) -> Mapping[str, str]:
|
|
243
|
+
output = _run_git(root, ("status", "--porcelain=v1", "-z", "--untracked-files=all"))
|
|
244
|
+
records = output.split("\0")
|
|
245
|
+
changes: dict[str, str] = {}
|
|
246
|
+
index = 0
|
|
247
|
+
while index < len(records) and records[index]:
|
|
248
|
+
record = records[index]
|
|
249
|
+
if len(record) < 4:
|
|
250
|
+
raise ContextError("invalid_git_status", "git.status", record, root)
|
|
251
|
+
code, path = record[:2], record[3:]
|
|
252
|
+
if "R" in code or "C" in code:
|
|
253
|
+
raise ContextError("unsupported_git_status", "git.status", code, path)
|
|
254
|
+
changes[path] = _status_name(code)
|
|
255
|
+
index += 1
|
|
256
|
+
return changes
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _is_excluded(path: str, patterns: Sequence[str]) -> bool:
|
|
260
|
+
return any(fnmatchcase(path, pattern) for pattern in patterns)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _path_hash(root: str, path: str) -> str:
|
|
264
|
+
target = Path(root) / path
|
|
265
|
+
digest = hashlib.sha256()
|
|
266
|
+
if target.is_file():
|
|
267
|
+
with target.open("rb") as handle:
|
|
268
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
269
|
+
digest.update(chunk)
|
|
270
|
+
return digest.hexdigest()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _active_data(active: ActiveTask) -> dict[str, object]:
|
|
274
|
+
changes = {
|
|
275
|
+
path: {"status": item.status, "content_sha256": item.content_sha256}
|
|
276
|
+
for path, item in sorted(active.baseline.preexisting_changes.items())
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
"version": active.version,
|
|
280
|
+
"task_dir": active.task_dir,
|
|
281
|
+
"task_id": active.task_id,
|
|
282
|
+
"status": active.status,
|
|
283
|
+
"context_sha256": active.context_sha256,
|
|
284
|
+
"baseline": {
|
|
285
|
+
"head": active.baseline.head,
|
|
286
|
+
"captured_at": active.baseline.captured_at,
|
|
287
|
+
"preexisting_changes": changes,
|
|
288
|
+
},
|
|
289
|
+
"owned_paths": list(active.owned_paths),
|
|
290
|
+
"excluded_paths": list(active.excluded_paths),
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _snapshot_from_data(value: object, path: str) -> PathSnapshot:
|
|
295
|
+
data = _mapping(value, "preexisting_changes[]", path)
|
|
296
|
+
return PathSnapshot(
|
|
297
|
+
_string(data.get("status"), "status", path),
|
|
298
|
+
_string(data.get("content_sha256"), "content_sha256", path),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _active_from_data(value: object, path: str) -> ActiveTask:
|
|
303
|
+
data = _mapping(value, "active", path)
|
|
304
|
+
baseline_data = _mapping(data.get("baseline"), "baseline", path)
|
|
305
|
+
raw_changes = _mapping(
|
|
306
|
+
baseline_data.get("preexisting_changes"), "baseline.preexisting_changes", path
|
|
307
|
+
)
|
|
308
|
+
version = data.get("version")
|
|
309
|
+
head = baseline_data.get("head")
|
|
310
|
+
if version != 1 or (head is not None and not isinstance(head, str)):
|
|
311
|
+
raise ContextError("invalid_active_marker", "version/baseline.head", "schema 不受支持", path)
|
|
312
|
+
status = _string(data.get("status"), "status", path)
|
|
313
|
+
if status not in ACTIVE_STATUSES:
|
|
314
|
+
raise ContextError("invalid_active_marker", "status", status, path)
|
|
315
|
+
baseline = ActiveBaseline(
|
|
316
|
+
head,
|
|
317
|
+
_string(baseline_data.get("captured_at"), "captured_at", path),
|
|
318
|
+
{key: _snapshot_from_data(item, path) for key, item in raw_changes.items()},
|
|
319
|
+
)
|
|
320
|
+
return ActiveTask(
|
|
321
|
+
1,
|
|
322
|
+
_string(data.get("task_dir"), "task_dir", path),
|
|
323
|
+
_string(data.get("task_id"), "task_id", path),
|
|
324
|
+
status,
|
|
325
|
+
_string(data.get("context_sha256"), "context_sha256", path),
|
|
326
|
+
baseline,
|
|
327
|
+
tuple(_string(item, "owned_paths[]", path) for item in _sequence(data.get("owned_paths"), "owned_paths", path)),
|
|
328
|
+
tuple(_string(item, "excluded_paths[]", path) for item in _sequence(data.get("excluded_paths"), "excluded_paths", path)),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def save_active_task(root: str, active: ActiveTask) -> None:
|
|
333
|
+
marker, unused_lock = _active_paths(root)
|
|
334
|
+
del unused_lock
|
|
335
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
336
|
+
_atomic_text(marker, json.dumps(_active_data(active), ensure_ascii=False, indent=2) + "\n")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def load_active_task(root: str) -> ActiveTask:
|
|
340
|
+
marker, unused_lock = _active_paths(root)
|
|
341
|
+
del unused_lock
|
|
342
|
+
try:
|
|
343
|
+
value = json.loads(marker.read_text(encoding="utf-8"))
|
|
344
|
+
return _active_from_data(value, str(marker))
|
|
345
|
+
except ContextError:
|
|
346
|
+
raise
|
|
347
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
348
|
+
raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _acquire_active_lock(root: str) -> Path:
|
|
352
|
+
marker, lock = _active_paths(root)
|
|
353
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
try:
|
|
355
|
+
descriptor = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
356
|
+
except FileExistsError as exc:
|
|
357
|
+
raise ContextError("active_lock_exists", "active.lock", "运行 doctor 检查残留 lock", str(lock)) from exc
|
|
358
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
359
|
+
handle.write(json.dumps({"pid": os.getpid(), "marker": str(marker)}))
|
|
360
|
+
return lock
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _release_active_lock(lock: Path) -> None:
|
|
364
|
+
try:
|
|
365
|
+
lock.unlink()
|
|
366
|
+
except FileNotFoundError:
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _business_changes(root: str, excludes: Sequence[str]) -> Mapping[str, str]:
|
|
371
|
+
return {
|
|
372
|
+
path: status
|
|
373
|
+
for path, status in _git_changes(root).items()
|
|
374
|
+
if not _is_excluded(path, excludes)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _new_active(
|
|
379
|
+
root: str,
|
|
380
|
+
task_dir: str,
|
|
381
|
+
task_id: str,
|
|
382
|
+
context_sha256: str,
|
|
383
|
+
owned_preexisting: Sequence[str],
|
|
384
|
+
) -> ActiveTask:
|
|
385
|
+
changes = _business_changes(root, DEFAULT_ACTIVE_EXCLUDES)
|
|
386
|
+
owned = tuple(sorted(set(owned_preexisting)))
|
|
387
|
+
unknown = tuple(path for path in owned if path not in changes)
|
|
388
|
+
unowned = tuple(path for path in changes if path not in owned)
|
|
389
|
+
if unknown:
|
|
390
|
+
raise ContextError("unknown_owned_path", "owned_paths", ", ".join(unknown), root)
|
|
391
|
+
if unowned:
|
|
392
|
+
raise ContextError("unowned_changes", "owned_paths", ", ".join(unowned), root)
|
|
393
|
+
snapshots = {
|
|
394
|
+
path: PathSnapshot(status, _path_hash(root, path)) for path, status in changes.items()
|
|
395
|
+
}
|
|
396
|
+
baseline = ActiveBaseline(_git_head(root), _now(), snapshots)
|
|
397
|
+
return ActiveTask(
|
|
398
|
+
1, task_dir, task_id, "activating", context_sha256, baseline, owned, DEFAULT_ACTIVE_EXCLUDES
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def start_active_task(
|
|
403
|
+
root: str,
|
|
404
|
+
task_dir: str,
|
|
405
|
+
task_id: str,
|
|
406
|
+
context_sha256: str,
|
|
407
|
+
owned_preexisting: Sequence[str] = (),
|
|
408
|
+
) -> ActiveTask:
|
|
409
|
+
marker, unused_lock = _active_paths(root)
|
|
410
|
+
del unused_lock
|
|
411
|
+
lock = _acquire_active_lock(root)
|
|
412
|
+
try:
|
|
413
|
+
if marker.exists():
|
|
414
|
+
raise ContextError("active_exists", "active", "当前 worktree 已有 active TASK", str(marker))
|
|
415
|
+
active = _new_active(root, task_dir, task_id, context_sha256, owned_preexisting)
|
|
416
|
+
save_active_task(root, active)
|
|
417
|
+
active = replace(active, status="active")
|
|
418
|
+
save_active_task(root, active)
|
|
419
|
+
return active
|
|
420
|
+
finally:
|
|
421
|
+
_release_active_lock(lock)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _transition_active(root: str, expected: Sequence[str], target: str) -> ActiveTask:
|
|
425
|
+
lock = _acquire_active_lock(root)
|
|
426
|
+
try:
|
|
427
|
+
active = load_active_task(root)
|
|
428
|
+
if active.status not in expected:
|
|
429
|
+
raise ContextError("invalid_active_transition", "status", f"{active.status} -> {target}", root)
|
|
430
|
+
if active.baseline.head != _git_head(root):
|
|
431
|
+
raise ContextError("baseline_head_changed", "baseline.head", "Git HEAD 已变化,请运行 doctor", root)
|
|
432
|
+
updated = replace(active, status=target)
|
|
433
|
+
save_active_task(root, updated)
|
|
434
|
+
return updated
|
|
435
|
+
finally:
|
|
436
|
+
_release_active_lock(lock)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def pause_active_task(root: str) -> ActiveTask:
|
|
440
|
+
return _transition_active(root, ("active",), "paused")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def block_active_task(root: str) -> ActiveTask:
|
|
444
|
+
return _transition_active(root, ("active",), "blocked")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def resume_active_task(root: str) -> ActiveTask:
|
|
448
|
+
return _transition_active(root, ("paused", "blocked"), "active")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def current_owned_paths(root: str, active: ActiveTask) -> tuple[str, ...]:
|
|
452
|
+
changes = _business_changes(root, active.excluded_paths)
|
|
453
|
+
return tuple(sorted(set(active.owned_paths).union(changes)))
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
|
|
457
|
+
if not gate_passed:
|
|
458
|
+
raise ContextError("gate_failed", "gate", "Done Gate 未通过", root)
|
|
459
|
+
completed = _transition_active(root, ("active",), "completed")
|
|
460
|
+
marker, unused_lock = _active_paths(root)
|
|
461
|
+
del unused_lock
|
|
462
|
+
try:
|
|
463
|
+
marker.unlink()
|
|
464
|
+
except OSError as exc:
|
|
465
|
+
raise ContextError("active_cleanup_failed", "active", str(exc), str(marker)) from exc
|
|
466
|
+
return completed
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def doctor_active_task(root: str, expected_context_sha256: str, abandon: bool = False) -> DoctorResult:
|
|
470
|
+
marker, lock = _active_paths(root)
|
|
471
|
+
try:
|
|
472
|
+
active = load_active_task(root)
|
|
473
|
+
except ContextError as exc:
|
|
474
|
+
raise ContextError("recovery_required", "active", exc.message, str(marker)) from exc
|
|
475
|
+
if abandon:
|
|
476
|
+
marker.unlink()
|
|
477
|
+
_release_active_lock(lock)
|
|
478
|
+
return DoctorResult("abandoned", replace(active, status="completed"))
|
|
479
|
+
proven = active.context_sha256 == expected_context_sha256
|
|
480
|
+
proven = proven and active.baseline.head == _git_head(root)
|
|
481
|
+
if active.status != "activating" or not proven:
|
|
482
|
+
raise ContextError("recovery_required", "active", "状态或 hash 无法证明可自动恢复", str(marker))
|
|
483
|
+
repaired = replace(active, status="active")
|
|
484
|
+
save_active_task(root, repaired)
|
|
485
|
+
_release_active_lock(lock)
|
|
486
|
+
return DoctorResult("resumed", repaired)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _mapping(value: object, field: str, path: str) -> Mapping[str, object]:
|
|
490
|
+
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
|
491
|
+
raise ContextError("invalid_context", field, "必须是字符串 key mapping", path)
|
|
492
|
+
return value
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _sequence(value: object, field: str, path: str) -> Sequence[object]:
|
|
496
|
+
if not isinstance(value, list):
|
|
497
|
+
raise ContextError("invalid_context", field, "必须是列表", path)
|
|
498
|
+
return value
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _string(value: object, field: str, path: str) -> str:
|
|
502
|
+
if not isinstance(value, str) or not value.strip():
|
|
503
|
+
raise ContextError("invalid_context", field, "必须是非空字符串", path)
|
|
504
|
+
return value.strip()
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _decision_string(value: object, field: str, path: str) -> str:
|
|
508
|
+
if not isinstance(value, str) or not value.strip():
|
|
509
|
+
raise ContextError("invalid_decision", field, "必须是非空字符串", path)
|
|
510
|
+
return value.strip()
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _timestamp(value: str, field: str) -> None:
|
|
514
|
+
try:
|
|
515
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
516
|
+
except ValueError as exc:
|
|
517
|
+
raise ContextError("invalid_decision", field, "必须是 RFC3339 时间") from exc
|
|
518
|
+
if parsed.tzinfo is None:
|
|
519
|
+
raise ContextError("invalid_decision", field, "必须包含时区")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _validate_decision(decision: Decision) -> None:
|
|
523
|
+
if decision.kind not in DECISION_KINDS:
|
|
524
|
+
raise ContextError("invalid_decision", "decision.kind", f"不支持 {decision.kind!r}")
|
|
525
|
+
for field in ("reason", "confirmed_by", "confirmed_at", "source"):
|
|
526
|
+
if not getattr(decision, field).strip():
|
|
527
|
+
raise ContextError("invalid_decision", f"decision.{field}", "必须是非空字符串")
|
|
528
|
+
identity = decision.confirmed_by.lower().split(":", 1)[0]
|
|
529
|
+
if identity in _AGENT_IDENTITIES:
|
|
530
|
+
raise ContextError("agent_confirmation_forbidden", "decision.confirmed_by", "Agent 不能自行确认")
|
|
531
|
+
_timestamp(decision.confirmed_at, "decision.confirmed_at")
|
|
532
|
+
if decision.kind == "waived" and not decision.expires_at:
|
|
533
|
+
raise ContextError("invalid_decision", "decision.expires_at", "waived 必须有失效时间")
|
|
534
|
+
if decision.expires_at:
|
|
535
|
+
_timestamp(decision.expires_at, "decision.expires_at")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _decision_from_data(value: object, path: str) -> Optional[Decision]:
|
|
539
|
+
if value is None:
|
|
540
|
+
return None
|
|
541
|
+
data = _mapping(value, "decision", path)
|
|
542
|
+
expires = data.get("expires_at")
|
|
543
|
+
if expires is not None and not isinstance(expires, str):
|
|
544
|
+
raise ContextError("invalid_decision", "decision.expires_at", "必须是字符串或 null", path)
|
|
545
|
+
decision = Decision(
|
|
546
|
+
_decision_string(data.get("kind"), "decision.kind", path),
|
|
547
|
+
_decision_string(data.get("reason"), "decision.reason", path),
|
|
548
|
+
_decision_string(data.get("confirmed_by"), "decision.confirmed_by", path),
|
|
549
|
+
_decision_string(data.get("confirmed_at"), "decision.confirmed_at", path),
|
|
550
|
+
_decision_string(data.get("source"), "decision.source", path),
|
|
551
|
+
expires,
|
|
552
|
+
)
|
|
553
|
+
_validate_decision(decision)
|
|
554
|
+
return decision
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _decision_data(decision: Optional[Decision]) -> Optional[dict[str, object]]:
|
|
558
|
+
if decision is None:
|
|
559
|
+
return None
|
|
560
|
+
return {
|
|
561
|
+
"kind": decision.kind,
|
|
562
|
+
"reason": decision.reason,
|
|
563
|
+
"confirmed_by": decision.confirmed_by,
|
|
564
|
+
"confirmed_at": decision.confirmed_at,
|
|
565
|
+
"source": decision.source,
|
|
566
|
+
"expires_at": decision.expires_at,
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _artifact_from_data(value: object, path: str) -> ArtifactRef:
|
|
571
|
+
data = _mapping(value, "refs[]", path)
|
|
572
|
+
return ArtifactRef(
|
|
573
|
+
_string(data.get("artifact"), "refs[].artifact", path),
|
|
574
|
+
_string(data.get("section_id"), "refs[].section_id", path),
|
|
575
|
+
_string(data.get("item_id"), "refs[].item_id", path),
|
|
576
|
+
_string(data.get("artifact_sha256"), "refs[].artifact_sha256", path),
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _artifact_data(reference: ArtifactRef) -> dict[str, str]:
|
|
581
|
+
return {
|
|
582
|
+
"artifact": reference.artifact,
|
|
583
|
+
"section_id": reference.section_id,
|
|
584
|
+
"item_id": reference.item_id,
|
|
585
|
+
"artifact_sha256": reference.artifact_sha256,
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _stage_from_data(value: object, field: str, path: str) -> RuleStageStatus:
|
|
590
|
+
data = _mapping(value, field, path)
|
|
591
|
+
status = _string(data.get("status"), f"{field}.status", path)
|
|
592
|
+
if status not in STAGE_STATUSES:
|
|
593
|
+
raise ContextError("invalid_context", f"{field}.status", f"不支持 {status!r}", path)
|
|
594
|
+
refs = tuple(_artifact_from_data(item, path) for item in _sequence(data.get("refs", []), f"{field}.refs", path))
|
|
595
|
+
evidence = tuple(_mapping(item, f"{field}.evidence[]", path) for item in _sequence(data.get("evidence", []), f"{field}.evidence", path))
|
|
596
|
+
decision = _decision_from_data(data.get("decision"), path)
|
|
597
|
+
if status == "applied" and not refs:
|
|
598
|
+
raise ContextError("invalid_context", f"{field}.refs", "applied 必须有 artifact ref", path)
|
|
599
|
+
if status in ("not_applicable", "waived") and decision is None:
|
|
600
|
+
raise ContextError("invalid_context", f"{field}.decision", f"{status} 必须有确认记录", path)
|
|
601
|
+
return RuleStageStatus(status, refs, decision, evidence)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _stage_data(status: RuleStageStatus) -> dict[str, object]:
|
|
605
|
+
return {
|
|
606
|
+
"status": status.status,
|
|
607
|
+
"refs": [_artifact_data(item) for item in status.refs],
|
|
608
|
+
"decision": _decision_data(status.decision),
|
|
609
|
+
"evidence": [dict(item) for item in status.evidence],
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _rule_from_data(value: object, path: str) -> RuleBinding:
|
|
614
|
+
data = _mapping(value, "rules[]", path)
|
|
615
|
+
stages = _mapping(data.get("stage_status"), "rules[].stage_status", path)
|
|
616
|
+
parsed = {stage: _stage_from_data(item, f"stage_status.{stage}", path) for stage, item in stages.items()}
|
|
617
|
+
return RuleBinding(
|
|
618
|
+
_string(data.get("ref"), "rules[].ref", path),
|
|
619
|
+
_string(data.get("summary"), "rules[].summary", path),
|
|
620
|
+
_string(data.get("text_sha256"), "rules[].text_sha256", path),
|
|
621
|
+
_string(data.get("enforcement"), "rules[].enforcement", path),
|
|
622
|
+
_string(data.get("verifier_ref"), "rules[].verifier_ref", path),
|
|
623
|
+
parsed,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _rule_data(rule: RuleBinding) -> dict[str, object]:
|
|
628
|
+
return {
|
|
629
|
+
"ref": rule.ref,
|
|
630
|
+
"summary": rule.summary,
|
|
631
|
+
"text_sha256": rule.text_sha256,
|
|
632
|
+
"enforcement": rule.enforcement,
|
|
633
|
+
"verifier_ref": rule.verifier_ref,
|
|
634
|
+
"stage_status": {stage: _stage_data(status) for stage, status in rule.stage_status.items()},
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _hashes_from_data(value: object, path: str) -> SpecHashes:
|
|
639
|
+
data = _mapping(value, "hashes", path)
|
|
640
|
+
return SpecHashes(
|
|
641
|
+
_string(data.get("file_sha256"), "hashes.file_sha256", path),
|
|
642
|
+
_string(data.get("metadata_sha256"), "hashes.metadata_sha256", path),
|
|
643
|
+
_string(data.get("rules_sha256"), "hashes.rules_sha256", path),
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _binding_from_data(value: object, path: str) -> SpecBinding:
|
|
648
|
+
data = _mapping(value, "bindings[]", path)
|
|
649
|
+
stages = tuple(_string(item, "bindings[].stages[]", path) for item in _sequence(data.get("stages"), "bindings[].stages", path))
|
|
650
|
+
rules = tuple(_rule_from_data(item, path) for item in _sequence(data.get("rules"), "bindings[].rules", path))
|
|
651
|
+
return SpecBinding(
|
|
652
|
+
_string(data.get("spec_id"), "bindings[].spec_id", path),
|
|
653
|
+
_string(data.get("path"), "bindings[].path", path),
|
|
654
|
+
_hashes_from_data(data.get("hashes"), path),
|
|
655
|
+
_string(data.get("selected_by"), "bindings[].selected_by", path),
|
|
656
|
+
_string(data.get("reason"), "bindings[].reason", path),
|
|
657
|
+
_string(data.get("enforcement"), "bindings[].enforcement", path),
|
|
658
|
+
stages,
|
|
659
|
+
rules,
|
|
660
|
+
_string(data.get("status", "active"), "bindings[].status", path),
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _binding_data(binding: SpecBinding) -> dict[str, object]:
|
|
665
|
+
return {
|
|
666
|
+
"spec_id": binding.spec_id,
|
|
667
|
+
"path": binding.path,
|
|
668
|
+
"status": binding.status,
|
|
669
|
+
"hashes": {
|
|
670
|
+
"file_sha256": binding.hashes.file_sha256,
|
|
671
|
+
"metadata_sha256": binding.hashes.metadata_sha256,
|
|
672
|
+
"rules_sha256": binding.hashes.rules_sha256,
|
|
673
|
+
},
|
|
674
|
+
"selected_by": binding.selected_by,
|
|
675
|
+
"reason": binding.reason,
|
|
676
|
+
"enforcement": binding.enforcement,
|
|
677
|
+
"stages": list(binding.stages),
|
|
678
|
+
"rules": [_rule_data(rule) for rule in binding.rules],
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def context_to_data(context: SpecContext) -> dict[str, object]:
|
|
683
|
+
return {
|
|
684
|
+
"version": context.version,
|
|
685
|
+
"task": context.task,
|
|
686
|
+
"enforcement": context.enforcement,
|
|
687
|
+
"updated_at": context.updated_at,
|
|
688
|
+
"sources": [{"type": item.type, "ref": item.ref} for item in context.sources],
|
|
689
|
+
"bindings": [_binding_data(binding) for binding in context.bindings],
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def new_context(task: str, sources: Sequence[tuple[str, str]]) -> SpecContext:
|
|
694
|
+
if not task.strip():
|
|
695
|
+
raise ContextError("invalid_context", "task", "必须是非空字符串")
|
|
696
|
+
parsed = tuple(ContextSource(_string(kind, "sources[].type", ""), _string(ref, "sources[].ref", "")) for kind, ref in sources)
|
|
697
|
+
return SpecContext(CONTEXT_VERSION, task.strip(), "required", _now(), parsed, ())
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def load_context(path: str) -> SpecContext:
|
|
701
|
+
try:
|
|
702
|
+
loaded = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
|
703
|
+
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
704
|
+
raise ContextError("context_read_error", "file", str(exc), path) from exc
|
|
705
|
+
data = _mapping(loaded, "context", path)
|
|
706
|
+
version = data.get("version")
|
|
707
|
+
if version != CONTEXT_VERSION:
|
|
708
|
+
raise ContextError("unsupported_version", "version", f"仅支持 {CONTEXT_VERSION},收到 {version!r}", path)
|
|
709
|
+
source_values = _sequence(data.get("sources", []), "sources", path)
|
|
710
|
+
sources = tuple(
|
|
711
|
+
ContextSource(
|
|
712
|
+
_string(_mapping(item, "sources[]", path).get("type"), "sources[].type", path),
|
|
713
|
+
_string(_mapping(item, "sources[]", path).get("ref"), "sources[].ref", path),
|
|
714
|
+
)
|
|
715
|
+
for item in source_values
|
|
716
|
+
)
|
|
717
|
+
bindings = tuple(_binding_from_data(item, path) for item in _sequence(data.get("bindings", []), "bindings", path))
|
|
718
|
+
return SpecContext(
|
|
719
|
+
CONTEXT_VERSION,
|
|
720
|
+
_string(data.get("task"), "task", path),
|
|
721
|
+
_string(data.get("enforcement"), "enforcement", path),
|
|
722
|
+
_string(data.get("updated_at"), "updated_at", path),
|
|
723
|
+
sources,
|
|
724
|
+
bindings,
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def save_context(path: str, context: SpecContext) -> None:
|
|
729
|
+
target = Path(path)
|
|
730
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
731
|
+
text = yaml.safe_dump(context_to_data(context), sort_keys=False, allow_unicode=True)
|
|
732
|
+
try:
|
|
733
|
+
_atomic_text(target, text)
|
|
734
|
+
except ContextError as exc:
|
|
735
|
+
raise ContextError("context_write_error", "file", str(exc), path) from exc
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _binding_from_input(selection: BindingInput) -> SpecBinding:
|
|
739
|
+
if not selection.selected_by.strip() or not selection.reason.strip():
|
|
740
|
+
raise ContextError("invalid_binding", "selection", "selected_by/reason 必须非空")
|
|
741
|
+
candidate = selection.candidate
|
|
742
|
+
verifier_by_rule = {item.rule: item for item in candidate.metadata.verifiers}
|
|
743
|
+
rules: list[RuleBinding] = []
|
|
744
|
+
for rule in candidate.metadata.rules:
|
|
745
|
+
if rule.enforcement == "informational":
|
|
746
|
+
continue
|
|
747
|
+
enforcement = "advisory" if candidate.metadata.enforcement == "advisory" else rule.enforcement
|
|
748
|
+
statuses = {
|
|
749
|
+
stage: RuleStageStatus("pending", (), None, ()) for stage in candidate.metadata.stages
|
|
750
|
+
}
|
|
751
|
+
verifier = verifier_by_rule.get(rule.ref)
|
|
752
|
+
verifier_ref = f"{candidate.spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
|
|
753
|
+
rules.append(RuleBinding(rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses))
|
|
754
|
+
return SpecBinding(
|
|
755
|
+
candidate.spec_id,
|
|
756
|
+
candidate.path,
|
|
757
|
+
candidate.metadata.hashes,
|
|
758
|
+
selection.selected_by.strip(),
|
|
759
|
+
selection.reason.strip(),
|
|
760
|
+
candidate.metadata.enforcement,
|
|
761
|
+
candidate.metadata.stages,
|
|
762
|
+
tuple(rules),
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _rule_from_metadata(spec_id: str, metadata: SpecMetadata, rule: SpecRule) -> RuleBinding:
|
|
767
|
+
enforcement = "advisory" if metadata.enforcement == "advisory" else rule.enforcement
|
|
768
|
+
statuses = {stage: RuleStageStatus("pending", (), None, ()) for stage in metadata.stages}
|
|
769
|
+
has_verifier = any(item.rule == rule.ref for item in metadata.verifiers)
|
|
770
|
+
verifier_ref = f"{spec_id}#{rule.ref}" if has_verifier else "advisory:none"
|
|
771
|
+
return RuleBinding(rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def bind_specs(context: SpecContext, selections: Sequence[BindingInput]) -> SpecContext:
|
|
775
|
+
bindings = {binding.spec_id: binding for binding in context.bindings}
|
|
776
|
+
for selection in selections:
|
|
777
|
+
incoming = _binding_from_input(selection)
|
|
778
|
+
existing = bindings.get(incoming.spec_id)
|
|
779
|
+
if existing is not None and existing.hashes != incoming.hashes:
|
|
780
|
+
raise ContextError(
|
|
781
|
+
"stale_binding",
|
|
782
|
+
"selection",
|
|
783
|
+
f"{incoming.spec_id} 已绑定版本发生变化,请先 refresh",
|
|
784
|
+
)
|
|
785
|
+
if existing is not None:
|
|
786
|
+
incoming = replace(
|
|
787
|
+
existing,
|
|
788
|
+
selected_by=incoming.selected_by,
|
|
789
|
+
reason=incoming.reason,
|
|
790
|
+
enforcement=incoming.enforcement,
|
|
791
|
+
stages=incoming.stages,
|
|
792
|
+
)
|
|
793
|
+
bindings[incoming.spec_id] = incoming
|
|
794
|
+
ordered = tuple(bindings[key] for key in sorted(bindings))
|
|
795
|
+
return replace(context, updated_at=_now(), bindings=ordered)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def refresh_missing_specs(context: SpecContext, root: str) -> SpecContext:
|
|
799
|
+
spec_root = Path(root) / ".code-flow" / "specs"
|
|
800
|
+
bindings: list[SpecBinding] = []
|
|
801
|
+
for binding in context.bindings:
|
|
802
|
+
if (spec_root / binding.path).is_file():
|
|
803
|
+
bindings.append(binding)
|
|
804
|
+
continue
|
|
805
|
+
rules = []
|
|
806
|
+
for rule in binding.rules:
|
|
807
|
+
stages = {stage: replace(status, status="stale") for stage, status in rule.stage_status.items()}
|
|
808
|
+
rules.append(replace(rule, stage_status=stages))
|
|
809
|
+
bindings.append(replace(binding, status="missing", rules=tuple(rules)))
|
|
810
|
+
return replace(context, updated_at=_now(), bindings=tuple(bindings))
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _stale_rule(rule: RuleBinding) -> RuleBinding:
|
|
814
|
+
statuses = {stage: replace(status, status="stale") for stage, status in rule.stage_status.items()}
|
|
815
|
+
return replace(rule, stage_status=statuses)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _removed_rule(rule: RuleBinding) -> RuleBinding:
|
|
819
|
+
statuses = {
|
|
820
|
+
stage: status
|
|
821
|
+
if status.status in ("not_applicable", "waived") and status.decision is not None
|
|
822
|
+
else replace(status, status="stale")
|
|
823
|
+
for stage, status in rule.stage_status.items()
|
|
824
|
+
}
|
|
825
|
+
return replace(rule, stage_status=statuses)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _file_sha256(path: Path) -> str:
|
|
829
|
+
try:
|
|
830
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
831
|
+
except OSError as exc:
|
|
832
|
+
raise ContextError("artifact_read_error", "artifact", str(exc), str(path)) from exc
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _refresh_artifacts(
|
|
836
|
+
spec_id: str, rule: RuleBinding, artifact_root: Path
|
|
837
|
+
) -> tuple[RuleBinding, tuple[DriftChange, ...]]:
|
|
838
|
+
statuses = dict(rule.stage_status)
|
|
839
|
+
changes: list[DriftChange] = []
|
|
840
|
+
for stage, status in rule.stage_status.items():
|
|
841
|
+
for reference in status.refs:
|
|
842
|
+
path = artifact_root / reference.artifact
|
|
843
|
+
current = _file_sha256(path) if path.is_file() else "missing"
|
|
844
|
+
if current == reference.artifact_sha256:
|
|
845
|
+
continue
|
|
846
|
+
statuses[stage] = replace(status, status="stale")
|
|
847
|
+
changes.append(
|
|
848
|
+
DriftChange("artifact_changed", spec_id, rule.ref, reference.artifact_sha256, current, (stage,))
|
|
849
|
+
)
|
|
850
|
+
break
|
|
851
|
+
return replace(rule, stage_status=statuses), tuple(changes)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _refresh_existing_rule(
|
|
855
|
+
binding: SpecBinding, existing: RuleBinding, current: SpecRule, metadata: SpecMetadata
|
|
856
|
+
) -> tuple[RuleBinding, Optional[DriftChange]]:
|
|
857
|
+
enforcement = "advisory" if metadata.enforcement == "advisory" else current.enforcement
|
|
858
|
+
has_verifier = any(item.rule == current.ref for item in metadata.verifiers)
|
|
859
|
+
verifier_ref = f"{binding.spec_id}#{current.ref}" if has_verifier else "advisory:none"
|
|
860
|
+
updated = replace(
|
|
861
|
+
existing,
|
|
862
|
+
summary=current.text,
|
|
863
|
+
text_sha256=current.text_sha256,
|
|
864
|
+
enforcement=enforcement,
|
|
865
|
+
verifier_ref=verifier_ref,
|
|
866
|
+
)
|
|
867
|
+
if existing.text_sha256 == current.text_sha256:
|
|
868
|
+
return updated, None
|
|
869
|
+
change = DriftChange(
|
|
870
|
+
"rule_changed", binding.spec_id, current.ref, existing.text_sha256, current.text_sha256, tuple(existing.stage_status)
|
|
871
|
+
)
|
|
872
|
+
return _stale_rule(updated), change
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _refresh_binding(
|
|
876
|
+
binding: SpecBinding, spec_root: Path, artifact_root: Path
|
|
877
|
+
) -> tuple[SpecBinding, tuple[DriftChange, ...]]:
|
|
878
|
+
path = spec_root / binding.path
|
|
879
|
+
if not path.is_file():
|
|
880
|
+
stale = replace(binding, status="missing", rules=tuple(_stale_rule(rule) for rule in binding.rules))
|
|
881
|
+
change = DriftChange("spec_missing", binding.spec_id, "", binding.hashes.file_sha256, "missing", binding.stages)
|
|
882
|
+
return stale, (change,)
|
|
883
|
+
metadata = load_spec_metadata(str(path))
|
|
884
|
+
existing = {rule.ref: rule for rule in binding.rules}
|
|
885
|
+
rules: list[RuleBinding] = []
|
|
886
|
+
changes: list[DriftChange] = []
|
|
887
|
+
if metadata.hashes.metadata_sha256 != binding.hashes.metadata_sha256:
|
|
888
|
+
changes.append(
|
|
889
|
+
DriftChange("metadata_changed", binding.spec_id, "", binding.hashes.metadata_sha256, metadata.hashes.metadata_sha256, ())
|
|
890
|
+
)
|
|
891
|
+
for current in metadata.rules:
|
|
892
|
+
if current.enforcement == "informational":
|
|
893
|
+
continue
|
|
894
|
+
prior = existing.pop(current.ref, None)
|
|
895
|
+
if prior is None:
|
|
896
|
+
rule = _rule_from_metadata(binding.spec_id, metadata, current)
|
|
897
|
+
changes.append(DriftChange("rule_added", binding.spec_id, current.ref, "", current.text_sha256, metadata.stages))
|
|
898
|
+
else:
|
|
899
|
+
rule, change = _refresh_existing_rule(binding, prior, current, metadata)
|
|
900
|
+
if change is not None:
|
|
901
|
+
changes.append(change)
|
|
902
|
+
rule, artifact_changes = _refresh_artifacts(binding.spec_id, rule, artifact_root)
|
|
903
|
+
rules.append(rule)
|
|
904
|
+
changes.extend(artifact_changes)
|
|
905
|
+
for removed in existing.values():
|
|
906
|
+
rules.append(_removed_rule(removed))
|
|
907
|
+
changes.append(DriftChange("rule_removed", binding.spec_id, removed.ref, removed.text_sha256, "missing", tuple(removed.stage_status)))
|
|
908
|
+
updated = replace(binding, hashes=metadata.hashes, enforcement=metadata.enforcement, stages=metadata.stages, rules=tuple(rules), status="active")
|
|
909
|
+
return updated, tuple(changes)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def refresh_context(context: SpecContext, root: str, artifact_root: Optional[str] = None) -> DriftResult:
|
|
913
|
+
specs = Path(root) / ".code-flow" / "specs"
|
|
914
|
+
artifacts = Path(artifact_root) if artifact_root is not None else Path(root)
|
|
915
|
+
bindings: list[SpecBinding] = []
|
|
916
|
+
changes: list[DriftChange] = []
|
|
917
|
+
for binding in context.bindings:
|
|
918
|
+
updated, binding_changes = _refresh_binding(binding, specs, artifacts)
|
|
919
|
+
bindings.append(updated)
|
|
920
|
+
changes.extend(binding_changes)
|
|
921
|
+
refreshed = replace(context, updated_at=_now(), bindings=tuple(bindings))
|
|
922
|
+
return DriftResult(refreshed, tuple(changes))
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def diff_spec_hashes(context: SpecContext, root: str) -> DriftResult:
|
|
926
|
+
return refresh_context(context, root)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def apply_decision(
|
|
930
|
+
context: SpecContext,
|
|
931
|
+
spec_id: str,
|
|
932
|
+
rule_ref: str,
|
|
933
|
+
stage: str,
|
|
934
|
+
decision: Decision,
|
|
935
|
+
batch: bool = False,
|
|
936
|
+
) -> SpecContext:
|
|
937
|
+
if batch:
|
|
938
|
+
raise ContextError("batch_confirmation_forbidden", "batch", "N/A/豁免必须逐项确认")
|
|
939
|
+
_validate_decision(decision)
|
|
940
|
+
target_status = {"not_applicable": "not_applicable", "waived": "waived", "manual_verification": "verified"}[decision.kind]
|
|
941
|
+
found = False
|
|
942
|
+
bindings: list[SpecBinding] = []
|
|
943
|
+
for binding in context.bindings:
|
|
944
|
+
changed_rules: list[RuleBinding] = []
|
|
945
|
+
for rule in binding.rules:
|
|
946
|
+
if binding.spec_id == spec_id and rule.ref == rule_ref:
|
|
947
|
+
if stage not in rule.stage_status:
|
|
948
|
+
raise ContextError("unknown_stage", "stage", f"Rule 不适用于 {stage}")
|
|
949
|
+
statuses = dict(rule.stage_status)
|
|
950
|
+
statuses[stage] = replace(statuses[stage], status=target_status, decision=decision)
|
|
951
|
+
rule = replace(rule, stage_status=statuses)
|
|
952
|
+
found = True
|
|
953
|
+
changed_rules.append(rule)
|
|
954
|
+
bindings.append(replace(binding, rules=tuple(changed_rules)))
|
|
955
|
+
if not found:
|
|
956
|
+
raise ContextError("unknown_rule", "rule_ref", f"未找到 {spec_id}#{rule_ref}")
|
|
957
|
+
return replace(context, updated_at=_now(), bindings=tuple(bindings))
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _upsert_artifact_ref(
|
|
961
|
+
references: Sequence[ArtifactRef], replacement: ArtifactRef
|
|
962
|
+
) -> tuple[ArtifactRef, ...]:
|
|
963
|
+
identity = (replacement.artifact, replacement.section_id, replacement.item_id)
|
|
964
|
+
updated: list[ArtifactRef] = []
|
|
965
|
+
replaced = False
|
|
966
|
+
for reference in references:
|
|
967
|
+
current = (reference.artifact, reference.section_id, reference.item_id)
|
|
968
|
+
if current != identity:
|
|
969
|
+
updated.append(reference)
|
|
970
|
+
elif not replaced:
|
|
971
|
+
updated.append(replacement)
|
|
972
|
+
replaced = True
|
|
973
|
+
if not replaced:
|
|
974
|
+
updated.append(replacement)
|
|
975
|
+
return tuple(updated)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def apply_artifact_ref(
|
|
979
|
+
context: SpecContext,
|
|
980
|
+
spec_id: str,
|
|
981
|
+
rule_ref: str,
|
|
982
|
+
stage: str,
|
|
983
|
+
reference: ArtifactRef,
|
|
984
|
+
) -> SpecContext:
|
|
985
|
+
found = False
|
|
986
|
+
bindings: list[SpecBinding] = []
|
|
987
|
+
for binding in context.bindings:
|
|
988
|
+
rules: list[RuleBinding] = []
|
|
989
|
+
for rule in binding.rules:
|
|
990
|
+
if binding.spec_id == spec_id and rule.ref == rule_ref:
|
|
991
|
+
if stage not in rule.stage_status:
|
|
992
|
+
raise ContextError("unknown_stage", "stage", f"Rule 不适用于 {stage}")
|
|
993
|
+
statuses = dict(rule.stage_status)
|
|
994
|
+
current = statuses[stage]
|
|
995
|
+
refs = _upsert_artifact_ref(current.refs, reference)
|
|
996
|
+
statuses[stage] = replace(current, status="applied", refs=refs)
|
|
997
|
+
rule = replace(rule, stage_status=statuses)
|
|
998
|
+
found = True
|
|
999
|
+
rules.append(rule)
|
|
1000
|
+
bindings.append(replace(binding, rules=tuple(rules)))
|
|
1001
|
+
if not found:
|
|
1002
|
+
raise ContextError("unknown_rule", "rule_ref", f"未找到 {spec_id}#{rule_ref}")
|
|
1003
|
+
return replace(context, updated_at=_now(), bindings=tuple(bindings))
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _json_payload(stream: IO[str]) -> Mapping[str, object]:
|
|
1007
|
+
try:
|
|
1008
|
+
loaded = json.load(stream)
|
|
1009
|
+
except (json.JSONDecodeError, UnicodeError) as exc:
|
|
1010
|
+
raise ContextError("invalid_json", "stdin", str(exc)) from exc
|
|
1011
|
+
return _mapping(loaded, "stdin", "")
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _candidate_data(candidate: SpecCandidate) -> dict[str, object]:
|
|
1015
|
+
return {
|
|
1016
|
+
"spec_id": candidate.spec_id,
|
|
1017
|
+
"path": candidate.path,
|
|
1018
|
+
"scope": candidate.scope,
|
|
1019
|
+
"priority": candidate.priority,
|
|
1020
|
+
"matched_paths": list(candidate.matched_paths),
|
|
1021
|
+
"description": candidate.metadata.description,
|
|
1022
|
+
"enforcement": candidate.metadata.enforcement,
|
|
1023
|
+
"stages": list(candidate.metadata.stages),
|
|
1024
|
+
"hashes": {
|
|
1025
|
+
"file_sha256": candidate.metadata.hashes.file_sha256,
|
|
1026
|
+
"metadata_sha256": candidate.metadata.hashes.metadata_sha256,
|
|
1027
|
+
"rules_sha256": candidate.metadata.hashes.rules_sha256,
|
|
1028
|
+
},
|
|
1029
|
+
"rules": [
|
|
1030
|
+
{"ref": rule.ref, "text": rule.text, "enforcement": rule.enforcement}
|
|
1031
|
+
for rule in candidate.metadata.rules
|
|
1032
|
+
],
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _catalog_command(args: argparse.Namespace) -> dict[str, object]:
|
|
1037
|
+
candidates = resolve_candidates(args.root, args.stage, tuple(args.paths))
|
|
1038
|
+
return {
|
|
1039
|
+
"ok": True,
|
|
1040
|
+
"stage": args.stage,
|
|
1041
|
+
"total": len(candidates),
|
|
1042
|
+
"candidates": [_candidate_data(item) for item in candidates],
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _application_ref(task_dir: str, value: Mapping[str, object]) -> ArtifactRef:
|
|
1047
|
+
artifact = _string(value.get("artifact"), "applications[].artifact", "")
|
|
1048
|
+
base = Path(task_dir).resolve()
|
|
1049
|
+
target = (base / artifact).resolve()
|
|
1050
|
+
if base != target and base not in target.parents:
|
|
1051
|
+
raise ContextError("artifact_outside_task", "applications[].artifact", artifact, task_dir)
|
|
1052
|
+
try:
|
|
1053
|
+
artifact_hash = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
1054
|
+
except OSError as exc:
|
|
1055
|
+
raise ContextError("artifact_read_error", "applications[].artifact", str(exc), str(target)) from exc
|
|
1056
|
+
return ArtifactRef(
|
|
1057
|
+
artifact,
|
|
1058
|
+
_string(value.get("section_id"), "applications[].section_id", ""),
|
|
1059
|
+
_string(value.get("item_id"), "applications[].item_id", ""),
|
|
1060
|
+
artifact_hash,
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _apply_payload(context: SpecContext, task_dir: str, payload: Mapping[str, object]) -> SpecContext:
|
|
1065
|
+
updated = context
|
|
1066
|
+
for raw in _sequence(payload.get("applications", []), "applications", ""):
|
|
1067
|
+
item = _mapping(raw, "applications[]", "")
|
|
1068
|
+
updated = apply_artifact_ref(
|
|
1069
|
+
updated,
|
|
1070
|
+
_string(item.get("spec_id"), "applications[].spec_id", ""),
|
|
1071
|
+
_string(item.get("rule_ref"), "applications[].rule_ref", ""),
|
|
1072
|
+
_string(item.get("stage"), "applications[].stage", ""),
|
|
1073
|
+
_application_ref(task_dir, item),
|
|
1074
|
+
)
|
|
1075
|
+
return updated
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _decision_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
1079
|
+
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
1080
|
+
decision = _decision_from_data(payload.get("decision"), str(context_path))
|
|
1081
|
+
if decision is None:
|
|
1082
|
+
raise ContextError("invalid_decision", "decision", "必须提供 decision")
|
|
1083
|
+
context = apply_decision(
|
|
1084
|
+
load_context(str(context_path)),
|
|
1085
|
+
_string(payload.get("spec_id"), "spec_id", ""),
|
|
1086
|
+
_string(payload.get("rule_ref"), "rule_ref", ""),
|
|
1087
|
+
_string(payload.get("stage"), "stage", ""),
|
|
1088
|
+
decision,
|
|
1089
|
+
payload.get("batch") is True,
|
|
1090
|
+
)
|
|
1091
|
+
save_context(str(context_path), context)
|
|
1092
|
+
return {"ok": True, "status": context.bindings[0].rules[0].stage_status[_string(payload.get("stage"), "stage", "")].status}
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _bind_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
1096
|
+
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
1097
|
+
paths = tuple(_string(item, "paths[]", "") for item in _sequence(payload.get("paths", []), "paths", ""))
|
|
1098
|
+
candidates = {item.spec_id: item for item in resolve_candidates(args.root, args.stage, paths)}
|
|
1099
|
+
selections: list[BindingInput] = []
|
|
1100
|
+
for raw in _sequence(payload.get("selections"), "selections", ""):
|
|
1101
|
+
item = _mapping(raw, "selections[]", "")
|
|
1102
|
+
spec_id = _string(item.get("spec_id"), "selections[].spec_id", "")
|
|
1103
|
+
if spec_id not in candidates:
|
|
1104
|
+
raise ContextError("unknown_candidate", "selections[].spec_id", spec_id)
|
|
1105
|
+
selections.append(
|
|
1106
|
+
BindingInput(
|
|
1107
|
+
candidates[spec_id],
|
|
1108
|
+
_string(item.get("selected_by"), "selections[].selected_by", ""),
|
|
1109
|
+
_string(item.get("reason"), "selections[].reason", ""),
|
|
1110
|
+
)
|
|
1111
|
+
)
|
|
1112
|
+
if context_path.exists():
|
|
1113
|
+
context = load_context(str(context_path))
|
|
1114
|
+
else:
|
|
1115
|
+
task = _string(payload.get("task"), "task", "")
|
|
1116
|
+
context = new_context(task, (("cli", "bind"),))
|
|
1117
|
+
context = bind_specs(context, selections)
|
|
1118
|
+
context = _apply_payload(context, args.task_dir, payload)
|
|
1119
|
+
save_context(str(context_path), context)
|
|
1120
|
+
applied = sum(
|
|
1121
|
+
status.status == "applied"
|
|
1122
|
+
for binding in context.bindings
|
|
1123
|
+
for rule in binding.rules
|
|
1124
|
+
for status in rule.stage_status.values()
|
|
1125
|
+
)
|
|
1126
|
+
return {"ok": True, "bindings": len(context.bindings), "applied": applied}
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
|
|
1130
|
+
if args.active_action == "start":
|
|
1131
|
+
owned = tuple(
|
|
1132
|
+
_string(item, "owned_paths[]", "")
|
|
1133
|
+
for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
|
|
1134
|
+
)
|
|
1135
|
+
active = start_active_task(
|
|
1136
|
+
args.root, args.task_dir, args.task, args.context_sha256, owned
|
|
1137
|
+
)
|
|
1138
|
+
return {"ok": True, "active": _active_data(active)}
|
|
1139
|
+
if args.active_action == "pause":
|
|
1140
|
+
active = pause_active_task(args.root)
|
|
1141
|
+
elif args.active_action == "resume":
|
|
1142
|
+
active = resume_active_task(args.root)
|
|
1143
|
+
elif args.active_action == "block":
|
|
1144
|
+
active = block_active_task(args.root)
|
|
1145
|
+
elif args.active_action == "complete":
|
|
1146
|
+
active = complete_active_task(args.root, payload.get("gate_passed") is True)
|
|
1147
|
+
else:
|
|
1148
|
+
result = doctor_active_task(
|
|
1149
|
+
args.root, args.context_sha256, payload.get("abandon") is True
|
|
1150
|
+
)
|
|
1151
|
+
return {"ok": True, "action": result.action, "active": _active_data(result.active)}
|
|
1152
|
+
return {"ok": True, "active": _active_data(active)}
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _parser() -> argparse.ArgumentParser:
|
|
1156
|
+
parser = argparse.ArgumentParser(prog="cf_spec_context.py")
|
|
1157
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
1158
|
+
catalog = commands.add_parser("catalog")
|
|
1159
|
+
catalog.add_argument("--root", required=True)
|
|
1160
|
+
catalog.add_argument("--stage", required=True)
|
|
1161
|
+
catalog.add_argument("--paths", nargs="*", default=())
|
|
1162
|
+
catalog.add_argument("--json", action="store_true")
|
|
1163
|
+
decision = commands.add_parser("decision")
|
|
1164
|
+
decision.add_argument("--task-dir", required=True)
|
|
1165
|
+
decision.add_argument("--json", action="store_true")
|
|
1166
|
+
bind = commands.add_parser("bind")
|
|
1167
|
+
bind.add_argument("--task-dir", required=True)
|
|
1168
|
+
bind.add_argument("--root", required=True)
|
|
1169
|
+
bind.add_argument("--stage", required=True)
|
|
1170
|
+
bind.add_argument("--json", action="store_true")
|
|
1171
|
+
validate = commands.add_parser("validate")
|
|
1172
|
+
validate.add_argument("--task-dir", required=True)
|
|
1173
|
+
validate.add_argument("--json", action="store_true")
|
|
1174
|
+
for name in ("refresh", "refresh-missing"):
|
|
1175
|
+
refresh = commands.add_parser(name)
|
|
1176
|
+
refresh.add_argument("--task-dir", required=True)
|
|
1177
|
+
refresh.add_argument("--root", required=True)
|
|
1178
|
+
refresh.add_argument("--json", action="store_true")
|
|
1179
|
+
active = commands.add_parser("active")
|
|
1180
|
+
active_actions = active.add_subparsers(dest="active_action", required=True)
|
|
1181
|
+
for action in ("start", "pause", "resume", "block", "complete", "doctor"):
|
|
1182
|
+
command = active_actions.add_parser(action)
|
|
1183
|
+
command.add_argument("--root", required=True)
|
|
1184
|
+
command.add_argument("--task-dir", required=True)
|
|
1185
|
+
command.add_argument("--task", required=True)
|
|
1186
|
+
command.add_argument("--context-sha256", required=True)
|
|
1187
|
+
command.add_argument("--json", action="store_true")
|
|
1188
|
+
return parser
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _execute(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
|
|
1192
|
+
if args.command == "catalog":
|
|
1193
|
+
return _catalog_command(args)
|
|
1194
|
+
if args.command == "active":
|
|
1195
|
+
return _active_command(args, _json_payload(stdin))
|
|
1196
|
+
context_path = Path(args.task_dir) / "spec-context.yml"
|
|
1197
|
+
if args.command == "decision":
|
|
1198
|
+
return _decision_command(args, _json_payload(stdin))
|
|
1199
|
+
if args.command == "bind":
|
|
1200
|
+
return _bind_command(args, _json_payload(stdin))
|
|
1201
|
+
if args.command == "validate":
|
|
1202
|
+
context = load_context(str(context_path))
|
|
1203
|
+
return {"ok": True, "task": context.task, "bindings": len(context.bindings)}
|
|
1204
|
+
if args.command == "refresh-missing":
|
|
1205
|
+
context = refresh_missing_specs(load_context(str(context_path)), args.root)
|
|
1206
|
+
changes: tuple[DriftChange, ...] = ()
|
|
1207
|
+
else:
|
|
1208
|
+
result = refresh_context(load_context(str(context_path)), args.root, artifact_root=args.task_dir)
|
|
1209
|
+
context, changes = result.context, result.changes
|
|
1210
|
+
save_context(str(context_path), context)
|
|
1211
|
+
return {
|
|
1212
|
+
"ok": True,
|
|
1213
|
+
"missing": sum(item.status == "missing" for item in context.bindings),
|
|
1214
|
+
"changes": [change.__dict__ for change in changes],
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def main(argv: Optional[Sequence[str]] = None, stdin: IO[str] = sys.stdin, stdout: IO[str] = sys.stdout) -> int:
|
|
1219
|
+
try:
|
|
1220
|
+
result = _execute(_parser().parse_args(argv), stdin)
|
|
1221
|
+
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
1222
|
+
return 0
|
|
1223
|
+
except ContextError as exc:
|
|
1224
|
+
stdout.write(json.dumps({"ok": False, "error": exc.to_dict()}, ensure_ascii=False))
|
|
1225
|
+
return 3
|
|
1226
|
+
except Exception as exc:
|
|
1227
|
+
sys.stderr.write(f"cf_spec_context unexpected error: {exc}\n")
|
|
1228
|
+
stdout.write(json.dumps({"ok": False, "error": {"code": "internal_error"}}, ensure_ascii=False))
|
|
1229
|
+
return 2
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
if __name__ == "__main__":
|
|
1233
|
+
raise SystemExit(main())
|