@michaelxwb/code-flow 0.5.2 → 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.
Files changed (76) hide show
  1. package/docs/migrations/spec-workflow-0.6.0.md +42 -0
  2. package/package.json +4 -2
  3. package/src/adapters/claude/CLAUDE.md +11 -17
  4. package/src/adapters/claude/commands/cf-init.md +5 -4
  5. package/src/adapters/claude/commands/cf-spec.md +50 -0
  6. package/src/adapters/claude/commands/cf-task/align.md +23 -1
  7. package/src/adapters/claude/commands/cf-task/archive.md +7 -0
  8. package/src/adapters/claude/commands/cf-task/plan.md +16 -0
  9. package/src/adapters/claude/commands/cf-task/prd.md +24 -0
  10. package/src/adapters/claude/commands/cf-task/start.md +13 -10
  11. package/src/adapters/claude/settings.local.json +1 -11
  12. package/src/adapters/codex/AGENTS.md +11 -17
  13. package/src/adapters/codex/hooks.json +6 -4
  14. package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
  15. package/src/adapters/codex/skills/cf-spec/SKILL.md +55 -0
  16. package/src/adapters/codex/skills/cf-task-align/SKILL.md +23 -1
  17. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +7 -0
  18. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +16 -0
  19. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
  20. package/src/adapters/codex/skills/cf-task-start/SKILL.md +13 -10
  21. package/src/adapters/costrict/CLAUDE.md +11 -17
  22. package/src/adapters/costrict/commands/cf-init.md +5 -4
  23. package/src/adapters/costrict/commands/cf-spec.md +50 -0
  24. package/src/adapters/costrict/commands/cf-task/align.md +23 -1
  25. package/src/adapters/costrict/commands/cf-task/archive.md +7 -0
  26. package/src/adapters/costrict/commands/cf-task/plan.md +16 -0
  27. package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
  28. package/src/adapters/costrict/commands/cf-task/start.md +13 -10
  29. package/src/adapters/costrict/settings.local.json +1 -11
  30. package/src/adapters/opencode/AGENTS.md +11 -17
  31. package/src/adapters/opencode/commands/cf-init.md +4 -3
  32. package/src/adapters/opencode/commands/cf-spec.md +54 -0
  33. package/src/adapters/opencode/commands/cf-task/align.md +24 -1
  34. package/src/adapters/opencode/commands/cf-task/archive.md +8 -0
  35. package/src/adapters/opencode/commands/cf-task/plan.md +17 -0
  36. package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
  37. package/src/adapters/opencode/commands/cf-task/start.md +14 -10
  38. package/src/adapters/opencode/plugins/code-flow/index.js +0 -1
  39. package/src/cli.js +51 -2
  40. package/src/core/code-flow/config.yml +13 -16
  41. package/src/core/code-flow/scripts/cf_checks.py +30 -0
  42. package/src/core/code-flow/scripts/cf_core.py +12 -255
  43. package/src/core/code-flow/scripts/cf_log.py +8 -0
  44. package/src/core/code-flow/scripts/cf_post_hook.py +33 -9
  45. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +65 -0
  46. package/src/core/code-flow/scripts/cf_spec_context.py +1233 -0
  47. package/src/core/code-flow/scripts/cf_spec_gate.py +224 -0
  48. package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
  49. package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
  50. package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
  51. package/src/core/code-flow/scripts/cf_spec_router.py +145 -0
  52. package/src/core/code-flow/scripts/cf_spec_session.py +122 -0
  53. package/src/core/code-flow/scripts/cf_spec_verify.py +293 -0
  54. package/src/core/code-flow/scripts/cf_stats.py +40 -5
  55. package/src/core/code-flow/scripts/cf_stop_hook.py +22 -2
  56. package/src/core/code-flow/scripts/cf_task_runtime.py +142 -0
  57. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +34 -360
  58. package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
  59. package/src/core/code-flow/specs/backend/database.md +13 -1
  60. package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
  61. package/src/core/code-flow/specs/backend/logging.md +13 -1
  62. package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
  63. package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
  64. package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
  65. package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
  66. package/src/core/code-flow/specs/shared/design/design-frontend.md +10 -0
  67. package/src/core/code-flow/specs/shared/design/design-full.md +10 -0
  68. package/src/core/code-flow/specs/shared/design/design-lite.md +10 -0
  69. package/src/core/code-flow/specs/shared/prd-template.md +14 -3
  70. package/src/migrate/spec-workflow.js +238 -0
  71. package/src/adapters/claude/commands/cf-inject.md +0 -55
  72. package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
  73. package/src/adapters/costrict/commands/cf-inject.md +0 -55
  74. package/src/adapters/opencode/commands/cf-inject.md +0 -59
  75. package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
  76. 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())